Use interface and not implementation in ApiRef<T>

Closes #4404

Signed-off-by: Oliver Sand <oliver.sand@sda-se.com>
This commit is contained in:
Oliver Sand
2021-04-22 12:41:53 +02:00
parent 74a5b00eb6
commit f09f6a166b
8 changed files with 296 additions and 255 deletions
+12
View File
@@ -0,0 +1,12 @@
---
'@backstage/plugin-techdocs': patch
---
Make `techdocsStorageApiRef` and `techdocsApiRef` use interfaces instead of the
actual implementation classes.
This renames the classes `TechDocsApi` to `TechDocsClient` and `TechDocsStorageApi`
to `TechDocsStorageClient` and renames the interfaces `TechDocs` to `TechDocsApi`
and `TechDocsStorage` to `TechDocsStorageApi` to comply the pattern elsewhere in
the project. This also fixes the types returned by some methods on those
interfaces.
+4 -4
View File
@@ -13,13 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DiscoveryApi, IdentityApi } from '@backstage/core';
import { Config } from '@backstage/config';
import { EntityName } from '@backstage/catalog-model';
import { TechDocsStorage } from '../src/api';
import { Config } from '@backstage/config';
import { DiscoveryApi, IdentityApi } from '@backstage/core';
import { NotFoundError } from '@backstage/errors';
import { TechDocsStorageApi } from '../src/api';
export class TechDocsDevStorageApi implements TechDocsStorage {
export class TechDocsDevStorageApi implements TechDocsStorageApi {
public configApi: Config;
public discoveryApi: DiscoveryApi;
public identityApi: IdentityApi;
+9 -227
View File
@@ -14,11 +14,9 @@
* limitations under the License.
*/
import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core';
import { Config } from '@backstage/config';
import { EntityName } from '@backstage/catalog-model';
import { TechDocsMetadata } from './types';
import { NotFoundError } from '@backstage/errors';
import { createApiRef } from '@backstage/core';
import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
export const techdocsStorageApiRef = createApiRef<TechDocsStorageApi>({
id: 'plugin.techdocs.storageservice',
@@ -30,7 +28,10 @@ export const techdocsApiRef = createApiRef<TechDocsApi>({
description: 'Used to make requests towards techdocs API',
});
export interface TechDocsStorage {
export interface TechDocsStorageApi {
getApiOrigin(): Promise<string>;
getStorageUrl(): Promise<string>;
getBuilder(): Promise<string>;
getEntityDocs(entityId: EntityName, path: string): Promise<string>;
syncEntityDocs(entityId: EntityName): Promise<boolean>;
getBaseUrl(
@@ -40,227 +41,8 @@ export interface TechDocsStorage {
): Promise<string>;
}
export interface TechDocs {
export interface TechDocsApi {
getApiOrigin(): Promise<string>;
getTechDocsMetadata(entityId: EntityName): Promise<TechDocsMetadata>;
getEntityMetadata(entityId: EntityName): Promise<string>;
}
/**
* API to talk to techdocs-backend.
*
* @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API
*/
export class TechDocsApi implements TechDocs {
public configApi: Config;
public discoveryApi: DiscoveryApi;
public identityApi: IdentityApi;
constructor({
configApi,
discoveryApi,
identityApi,
}: {
configApi: Config;
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}) {
this.configApi = configApi;
this.discoveryApi = discoveryApi;
this.identityApi = identityApi;
}
async getApiOrigin() {
return (
this.configApi.getOptionalString('techdocs.requestUrl') ??
(await this.discoveryApi.getBaseUrl('techdocs'))
);
}
/**
* Retrieve TechDocs metadata.
*
* When docs are built, we generate a techdocs_metadata.json and store it along with the generated
* static files. It includes necessary data about the docs site. This method requests techdocs-backend
* which retrieves the TechDocs metadata.
*
* @param {EntityName} entityId Object containing entity data like name, namespace, etc.
*/
async getTechDocsMetadata(entityId: EntityName) {
const { kind, namespace, name } = entityId;
const apiOrigin = await this.getApiOrigin();
const requestUrl = `${apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`;
const token = await this.identityApi.getIdToken();
const request = await fetch(`${requestUrl}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const res = await request.json();
return res;
}
/**
* Retrieve metadata about an entity.
*
* This method requests techdocs-backend which uses the catalog APIs to respond with filtered
* information required here.
*
* @param {EntityName} entityId Object containing entity data like name, namespace, etc.
*/
async getEntityMetadata(entityId: EntityName) {
const { kind, namespace, name } = entityId;
const apiOrigin = await this.getApiOrigin();
const requestUrl = `${apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`;
const token = await this.identityApi.getIdToken();
const request = await fetch(`${requestUrl}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const res = await request.json();
return res;
}
}
/**
* API which talks to TechDocs storage to fetch files to render.
*
* @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API
*/
export class TechDocsStorageApi implements TechDocsStorage {
public configApi: Config;
public discoveryApi: DiscoveryApi;
public identityApi: IdentityApi;
constructor({
configApi,
discoveryApi,
identityApi,
}: {
configApi: Config;
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}) {
this.configApi = configApi;
this.discoveryApi = discoveryApi;
this.identityApi = identityApi;
}
async getApiOrigin() {
return (
this.configApi.getOptionalString('techdocs.requestUrl') ??
(await this.discoveryApi.getBaseUrl('techdocs'))
);
}
async getStorageUrl() {
return (
this.configApi.getOptionalString('techdocs.storageUrl') ??
`${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs`
);
}
async getBuilder() {
return this.configApi.getString('techdocs.builder');
}
/**
* Fetch HTML content as text for an individual docs page in an entity's docs site.
*
* @param {EntityName} entityId Object containing entity data like name, namespace, etc.
* @param {string} path The unique path to an individual docs page e.g. overview/what-is-new
* @returns {string} HTML content of the docs page as string
* @throws {Error} Throws error when the page is not found.
*/
async getEntityDocs(entityId: EntityName, path: string) {
const { kind, namespace, name } = entityId;
const storageUrl = await this.getStorageUrl();
const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`;
const token = await this.identityApi.getIdToken();
const request = await fetch(
`${url.endsWith('/') ? url : `${url}/`}index.html`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
},
);
let errorMessage = '';
switch (request.status) {
case 404:
errorMessage = 'Page not found. ';
// path is empty for the home page of an entity's docs site
if (!path) {
errorMessage +=
'This could be because there is no index.md file in the root of the docs directory of this repository.';
}
throw new NotFoundError(errorMessage);
case 500:
errorMessage =
'Could not generate documentation or an error in the TechDocs backend. ';
throw new Error(errorMessage);
default:
// Do nothing
break;
}
return request.text();
}
/**
* Check if docs are on the latest version and trigger rebuild if not
*
* @param {EntityName} entityId Object containing entity data like name, namespace, etc.
* @returns {boolean} Whether documents are currently synchronized to newest version
* @throws {Error} Throws error on error from sync endpoint in Techdocs Backend
*/
async syncEntityDocs(entityId: EntityName) {
const { kind, namespace, name } = entityId;
const apiOrigin = await this.getApiOrigin();
const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`;
const token = await this.identityApi.getIdToken();
let request;
let attempts: number = 0;
// retry if request times out, up to 5 times
// can happen due to docs taking too long to generate
while (!request || (request.status === 408 && attempts < 5)) {
attempts++;
request = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
}
switch (request.status) {
case 404:
throw new NotFoundError((await request.json()).error);
case 200:
case 201:
case 304:
return true;
// for timeout and misc errors, handle without error to allow viewing older docs
// if older docs not available,
// Reader will show 404 error coming from getEntityDocs
case 408:
default:
return false;
}
}
async getBaseUrl(
oldBaseUrl: string,
entityId: EntityName,
path: string,
): Promise<string> {
const { kind, namespace, name } = entityId;
const apiOrigin = await this.getApiOrigin();
return new URL(
oldBaseUrl,
`${apiOrigin}/static/docs/${namespace}/${kind}/${name}/${path}`,
).toString();
}
getEntityMetadata(entityId: EntityName): Promise<TechDocsEntityMetadata>;
}
@@ -15,7 +15,7 @@
*/
import { Config } from '@backstage/config';
import { UrlPatternDiscovery } from '@backstage/core';
import { TechDocsStorageApi } from './api';
import { TechDocsStorageClient } from './client';
const mockEntity = {
kind: 'Component',
@@ -23,7 +23,7 @@ const mockEntity = {
name: 'test-component',
};
describe('TechDocsStorageApi', () => {
describe('TechDocsStorageClient', () => {
const mockBaseUrl = 'http://backstage:9191/api/techdocs';
const configApi = {
getOptionalString: () => 'http://backstage:9191/api/techdocs',
@@ -32,7 +32,7 @@ describe('TechDocsStorageApi', () => {
it('should return correct base url based on defined storage', async () => {
// @ts-ignore Partial<Config> not assignable to Config.
const storageApi = new TechDocsStorageApi({ configApi, discoveryApi });
const storageApi = new TechDocsStorageClient({ configApi, discoveryApi });
await expect(
storageApi.getBaseUrl('test.js', mockEntity, ''),
@@ -43,7 +43,7 @@ describe('TechDocsStorageApi', () => {
it('should return base url with correct entity structure', async () => {
// @ts-ignore Partial<Config> not assignable to Config.
const storageApi = new TechDocsStorageApi({ configApi, discoveryApi });
const storageApi = new TechDocsStorageClient({ configApi, discoveryApi });
await expect(
storageApi.getBaseUrl('test/', mockEntity, ''),
+244
View File
@@ -0,0 +1,244 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityName } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { DiscoveryApi, IdentityApi } from '@backstage/core';
import { NotFoundError } from '@backstage/errors';
import { TechDocsApi, TechDocsStorageApi } from './api';
import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
/**
* API to talk to techdocs-backend.
*
* @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API
*/
export class TechDocsClient implements TechDocsApi {
public configApi: Config;
public discoveryApi: DiscoveryApi;
public identityApi: IdentityApi;
constructor({
configApi,
discoveryApi,
identityApi,
}: {
configApi: Config;
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}) {
this.configApi = configApi;
this.discoveryApi = discoveryApi;
this.identityApi = identityApi;
}
async getApiOrigin(): Promise<string> {
return (
this.configApi.getOptionalString('techdocs.requestUrl') ??
(await this.discoveryApi.getBaseUrl('techdocs'))
);
}
/**
* Retrieve TechDocs metadata.
*
* When docs are built, we generate a techdocs_metadata.json and store it along with the generated
* static files. It includes necessary data about the docs site. This method requests techdocs-backend
* which retrieves the TechDocs metadata.
*
* @param {EntityName} entityId Object containing entity data like name, namespace, etc.
*/
async getTechDocsMetadata(entityId: EntityName): Promise<TechDocsMetadata> {
const { kind, namespace, name } = entityId;
const apiOrigin = await this.getApiOrigin();
const requestUrl = `${apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`;
const token = await this.identityApi.getIdToken();
const request = await fetch(`${requestUrl}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const res = await request.json();
return res;
}
/**
* Retrieve metadata about an entity.
*
* This method requests techdocs-backend which uses the catalog APIs to respond with filtered
* information required here.
*
* @param {EntityName} entityId Object containing entity data like name, namespace, etc.
*/
async getEntityMetadata(
entityId: EntityName,
): Promise<TechDocsEntityMetadata> {
const { kind, namespace, name } = entityId;
const apiOrigin = await this.getApiOrigin();
const requestUrl = `${apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`;
const token = await this.identityApi.getIdToken();
const request = await fetch(`${requestUrl}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const res = await request.json();
return res;
}
}
/**
* API which talks to TechDocs storage to fetch files to render.
*
* @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API
*/
export class TechDocsStorageClient implements TechDocsStorageApi {
public configApi: Config;
public discoveryApi: DiscoveryApi;
public identityApi: IdentityApi;
constructor({
configApi,
discoveryApi,
identityApi,
}: {
configApi: Config;
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}) {
this.configApi = configApi;
this.discoveryApi = discoveryApi;
this.identityApi = identityApi;
}
async getApiOrigin(): Promise<string> {
return (
this.configApi.getOptionalString('techdocs.requestUrl') ??
(await this.discoveryApi.getBaseUrl('techdocs'))
);
}
async getStorageUrl(): Promise<string> {
return (
this.configApi.getOptionalString('techdocs.storageUrl') ??
`${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs`
);
}
async getBuilder(): Promise<string> {
return this.configApi.getString('techdocs.builder');
}
/**
* Fetch HTML content as text for an individual docs page in an entity's docs site.
*
* @param {EntityName} entityId Object containing entity data like name, namespace, etc.
* @param {string} path The unique path to an individual docs page e.g. overview/what-is-new
* @returns {string} HTML content of the docs page as string
* @throws {Error} Throws error when the page is not found.
*/
async getEntityDocs(entityId: EntityName, path: string): Promise<string> {
const { kind, namespace, name } = entityId;
const storageUrl = await this.getStorageUrl();
const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`;
const token = await this.identityApi.getIdToken();
const request = await fetch(
`${url.endsWith('/') ? url : `${url}/`}index.html`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
},
);
let errorMessage = '';
switch (request.status) {
case 404:
errorMessage = 'Page not found. ';
// path is empty for the home page of an entity's docs site
if (!path) {
errorMessage +=
'This could be because there is no index.md file in the root of the docs directory of this repository.';
}
throw new NotFoundError(errorMessage);
case 500:
errorMessage =
'Could not generate documentation or an error in the TechDocs backend. ';
throw new Error(errorMessage);
default:
// Do nothing
break;
}
return request.text();
}
/**
* Check if docs are on the latest version and trigger rebuild if not
*
* @param {EntityName} entityId Object containing entity data like name, namespace, etc.
* @returns {boolean} Whether documents are currently synchronized to newest version
* @throws {Error} Throws error on error from sync endpoint in Techdocs Backend
*/
async syncEntityDocs(entityId: EntityName): Promise<boolean> {
const { kind, namespace, name } = entityId;
const apiOrigin = await this.getApiOrigin();
const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`;
const token = await this.identityApi.getIdToken();
let request;
let attempts: number = 0;
// retry if request times out, up to 5 times
// can happen due to docs taking too long to generate
while (!request || (request.status === 408 && attempts < 5)) {
attempts++;
request = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
}
switch (request.status) {
case 404:
throw new NotFoundError((await request.json()).error);
case 200:
case 201:
case 304:
return true;
// for timeout and misc errors, handle without error to allow viewing older docs
// if older docs not available,
// Reader will show 404 error coming from getEntityDocs
case 408:
default:
return false;
}
}
async getBaseUrl(
oldBaseUrl: string,
entityId: EntityName,
path: string,
): Promise<string> {
const { kind, namespace, name } = entityId;
const apiOrigin = await this.getApiOrigin();
return new URL(
oldBaseUrl,
`${apiOrigin}/static/docs/${namespace}/${kind}/${name}/${path}`,
).toString();
}
}
+10 -7
View File
@@ -14,17 +14,20 @@
* limitations under the License.
*/
export * from './api';
export { techdocsApiRef, techdocsStorageApiRef } from './api';
export type { TechDocsApi, TechDocsStorageApi } from './api';
export { TechDocsClient, TechDocsStorageClient } from './client';
export type { PanelType } from './home/components/TechDocsCustomHome';
export {
techdocsPlugin,
techdocsPlugin as plugin,
TechdocsPage,
EntityTechdocsContent,
DocsCardGrid,
DocsTable,
EntityTechdocsContent,
TechDocsCustomHome,
TechdocsPage,
techdocsPlugin as plugin,
techdocsPlugin,
TechDocsReaderPage,
} from './plugin';
export { Router, EmbeddedDocsRouter } from './Router';
export * from './reader';
export * from './api';
export type { PanelType } from './home/components/TechDocsCustomHome';
export { EmbeddedDocsRouter, Router } from './Router';
+9 -13
View File
@@ -30,21 +30,17 @@
*/
import {
createPlugin,
createRouteRef,
createApiFactory,
configApiRef,
createApiFactory,
createComponentExtension,
createPlugin,
createRoutableExtension,
createRouteRef,
discoveryApiRef,
identityApiRef,
createRoutableExtension,
createComponentExtension,
} from '@backstage/core';
import {
techdocsStorageApiRef,
TechDocsStorageApi,
techdocsApiRef,
TechDocsApi,
} from './api';
import { techdocsApiRef, techdocsStorageApiRef } from './api';
import { TechDocsClient, TechDocsStorageClient } from './client';
export const rootRouteRef = createRouteRef({
path: '',
@@ -72,7 +68,7 @@ export const techdocsPlugin = createPlugin({
identityApi: identityApiRef,
},
factory: ({ configApi, discoveryApi, identityApi }) =>
new TechDocsStorageApi({
new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
@@ -86,7 +82,7 @@ export const techdocsPlugin = createPlugin({
identityApi: identityApiRef,
},
factory: ({ configApi, discoveryApi, identityApi }) =>
new TechDocsApi({
new TechDocsClient({
configApi,
discoveryApi,
identityApi,
+4
View File
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { Entity, Location } from '@backstage/catalog-model';
export type TechDocsMetadata = {
site_name: string;
site_description: string;
};
export type TechDocsEntityMetadata = Entity & { locationMetadata?: Location };