From f09f6a166bd1acd9b2026af18ef827d51a3ca893 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Thu, 22 Apr 2021 12:41:53 +0200 Subject: [PATCH] Use interface and not implementation in ApiRef Closes #4404 Signed-off-by: Oliver Sand --- .changeset/swift-waves-sleep.md | 12 + plugins/techdocs/dev/api.ts | 8 +- plugins/techdocs/src/api.ts | 236 +---------------- .../src/{api.test.ts => client.test.ts} | 8 +- plugins/techdocs/src/client.ts | 244 ++++++++++++++++++ plugins/techdocs/src/index.ts | 17 +- plugins/techdocs/src/plugin.ts | 22 +- plugins/techdocs/src/types.ts | 4 + 8 files changed, 296 insertions(+), 255 deletions(-) create mode 100644 .changeset/swift-waves-sleep.md rename plugins/techdocs/src/{api.test.ts => client.test.ts} (87%) create mode 100644 plugins/techdocs/src/client.ts diff --git a/.changeset/swift-waves-sleep.md b/.changeset/swift-waves-sleep.md new file mode 100644 index 0000000000..831ecc0001 --- /dev/null +++ b/.changeset/swift-waves-sleep.md @@ -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. diff --git a/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts index 7acf3c7032..e764bf82bb 100644 --- a/plugins/techdocs/dev/api.ts +++ b/plugins/techdocs/dev/api.ts @@ -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; diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 0be530cc08..204cc1b5a8 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -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({ id: 'plugin.techdocs.storageservice', @@ -30,7 +28,10 @@ export const techdocsApiRef = createApiRef({ description: 'Used to make requests towards techdocs API', }); -export interface TechDocsStorage { +export interface TechDocsStorageApi { + getApiOrigin(): Promise; + getStorageUrl(): Promise; + getBuilder(): Promise; getEntityDocs(entityId: EntityName, path: string): Promise; syncEntityDocs(entityId: EntityName): Promise; getBaseUrl( @@ -40,227 +41,8 @@ export interface TechDocsStorage { ): Promise; } -export interface TechDocs { +export interface TechDocsApi { + getApiOrigin(): Promise; getTechDocsMetadata(entityId: EntityName): Promise; - getEntityMetadata(entityId: EntityName): Promise; -} - -/** - * 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 { - 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; } diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/client.test.ts similarity index 87% rename from plugins/techdocs/src/api.test.ts rename to plugins/techdocs/src/client.test.ts index 2260cc7d39..e8fc8f042f 100644 --- a/plugins/techdocs/src/api.test.ts +++ b/plugins/techdocs/src/client.test.ts @@ -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 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 not assignable to Config. - const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); + const storageApi = new TechDocsStorageClient({ configApi, discoveryApi }); await expect( storageApi.getBaseUrl('test/', mockEntity, ''), diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts new file mode 100644 index 0000000000..245cfb0154 --- /dev/null +++ b/plugins/techdocs/src/client.ts @@ -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 { + 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 { + 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 { + 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 { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); + } + + async getStorageUrl(): Promise { + return ( + this.configApi.getOptionalString('techdocs.storageUrl') ?? + `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` + ); + } + + async getBuilder(): Promise { + 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 { + 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 { + 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 { + const { kind, namespace, name } = entityId; + + const apiOrigin = await this.getApiOrigin(); + return new URL( + oldBaseUrl, + `${apiOrigin}/static/docs/${namespace}/${kind}/${name}/${path}`, + ).toString(); + } +} diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts index b33b91e2c5..3bdf8bfec1 100644 --- a/plugins/techdocs/src/index.ts +++ b/plugins/techdocs/src/index.ts @@ -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'; diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index d9b38a2c76..97dd6f2150 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -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, diff --git a/plugins/techdocs/src/types.ts b/plugins/techdocs/src/types.ts index 36cc4e187b..fb068bee09 100644 --- a/plugins/techdocs/src/types.ts +++ b/plugins/techdocs/src/types.ts @@ -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 };