diff --git a/.changeset/dull-taxis-carry.md b/.changeset/dull-taxis-carry.md new file mode 100644 index 0000000000..1466f1246a --- /dev/null +++ b/.changeset/dull-taxis-carry.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-adr': minor +'@backstage/plugin-adr-backend': patch +--- + +The ADR plugin can now work with sites other than GitHub. Expanded the ADR backend plugin to provide endpoints to facilitate this. + +**BREAKING** The ADR plugin now uses UrlReaders. You will have to [configure integrations](https://backstage.io/docs/integrations/index#configuration) for all sites you want to get ADRs from. If you would like to create your own implementation that has different behavior, you can override the AdrApi [just like you can with other apis.](https://backstage.io/docs/api/utility-apis#app-apis) The previously used Octokit implementation has been completely removed. diff --git a/packages/backend/package.json b/packages/backend/package.json index b38fafa8f2..f3508bfb18 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -32,6 +32,7 @@ "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", "@backstage/integration": "workspace:^", + "@backstage/plugin-adr-backend": "workspace:^", "@backstage/plugin-app-backend": "workspace:^", "@backstage/plugin-auth-backend": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 371ce40358..a721c443d6 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2022 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -61,6 +61,7 @@ import badges from './plugins/badges'; import jenkins from './plugins/jenkins'; import permission from './plugins/permission'; import playlist from './plugins/playlist'; +import adr from './plugins/adr'; import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; @@ -139,6 +140,7 @@ async function main() { const appEnv = useHotMemoize(module, () => createEnv('app')); const badgesEnv = useHotMemoize(module, () => createEnv('badges')); const jenkinsEnv = useHotMemoize(module, () => createEnv('jenkins')); + const adrEnv = useHotMemoize(module, () => createEnv('adr')); const techInsightsEnv = useHotMemoize(module, () => createEnv('tech-insights'), ); @@ -175,6 +177,7 @@ async function main() { apiRouter.use('/permission', await permission(permissionEnv)); apiRouter.use('/playlist', await playlist(playlistEnv)); apiRouter.use('/explore', await explore(exploreEnv)); + apiRouter.use('/adr', await adr(adrEnv)); apiRouter.use(notFoundHandler()); const service = createServiceBuilder(module) diff --git a/packages/backend/src/plugins/adr.ts b/packages/backend/src/plugins/adr.ts new file mode 100644 index 0000000000..6fa0d72969 --- /dev/null +++ b/packages/backend/src/plugins/adr.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { createRouter } from '@backstage/plugin-adr-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + reader: env.reader, + cacheClient: env.cache.getClient(), + logger: env.logger, + }); +} diff --git a/plugins/adr-backend/README.md b/plugins/adr-backend/README.md index 79b5fe13ed..83c87530ff 100644 --- a/plugins/adr-backend/README.md +++ b/plugins/adr-backend/README.md @@ -4,6 +4,8 @@ This ADR backend plugin is primarily responsible for the following: - Provides a `DefaultAdrCollatorFactory`, which can be used in the search backend to index ADR documents associated with entities to your Backstage Search. +- Provides endpoints that use UrlReaders for getting ADR documents (used in the [ADR frontend plugin](../adr/README.md)). + ## Indexing ADR documents for search Before you are able to start indexing ADR documents to search, you need to go through the [search getting started guide](https://backstage.io/docs/features/search/getting-started). diff --git a/plugins/adr-backend/api-report.md b/plugins/adr-backend/api-report.md index 5a671e9c8f..d5645ad3a7 100644 --- a/plugins/adr-backend/api-report.md +++ b/plugins/adr-backend/api-report.md @@ -7,10 +7,12 @@ import { AdrDocument } from '@backstage/plugin-adr-common'; import { AdrFilePathFilterFn } from '@backstage/plugin-adr-common'; +import { CacheClient } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { DocumentCollatorFactory } from '@backstage/plugin-search-common'; import { Entity } from '@backstage/catalog-model'; +import express from 'express'; import { Logger } from 'winston'; import { PluginCacheManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -41,9 +43,21 @@ export type AdrParserContext = { path: string; }; +// @public (undocumented) +export type AdrRouterOptions = { + reader: UrlReader; + cacheClient: CacheClient; + logger: Logger; +}; + // @public export const createMadrParser: (options?: MadrParserOptions) => AdrParser; +// @public (undocumented) +export function createRouter( + options: AdrRouterOptions, +): Promise; + // @public export class DefaultAdrCollatorFactory implements DocumentCollatorFactory { // (undocumented) diff --git a/plugins/adr-backend/package.json b/plugins/adr-backend/package.json index 73f9309972..033441372a 100644 --- a/plugins/adr-backend/package.json +++ b/plugins/adr-backend/package.json @@ -36,6 +36,9 @@ "@backstage/integration": "workspace:^", "@backstage/plugin-adr-common": "workspace:^", "@backstage/plugin-search-common": "workspace:^", + "@types/express": "^4.17.15", + "express": "^4.18.2", + "express-promise-router": "^4.1.1", "luxon": "^3.0.0", "marked": "^4.0.14", "node-fetch": "^2.6.5", diff --git a/plugins/adr-backend/src/index.ts b/plugins/adr-backend/src/index.ts index 86e07943df..5cc8f50b72 100644 --- a/plugins/adr-backend/src/index.ts +++ b/plugins/adr-backend/src/index.ts @@ -21,3 +21,4 @@ */ export * from './search'; +export * from './service'; diff --git a/plugins/adr/src/hooks/index.ts b/plugins/adr-backend/src/service/index.ts similarity index 86% rename from plugins/adr/src/hooks/index.ts rename to plugins/adr-backend/src/service/index.ts index d5aa102fb8..3736acb188 100644 --- a/plugins/adr/src/hooks/index.ts +++ b/plugins/adr-backend/src/service/index.ts @@ -13,4 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './useOctokitRequest'; + +export { createRouter } from './router'; +export type { AdrRouterOptions } from './router'; diff --git a/plugins/adr-backend/src/service/router.test.ts b/plugins/adr-backend/src/service/router.test.ts new file mode 100644 index 0000000000..fbbd6e8532 --- /dev/null +++ b/plugins/adr-backend/src/service/router.test.ts @@ -0,0 +1,229 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { + CacheClient, + ReadTreeResponse, + ReadTreeResponseFile, + ReadUrlResponse, + UrlReader, +} from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; +import { createRouter } from './router'; +import { Logger } from 'winston'; + +const listEndpointName = '/list'; +const fileEndpointName = '/file'; + +const makeBufferFromString = (string: string) => async () => + Buffer.from(string); + +const testingUrlFakeFileTree: ReadTreeResponseFile[] = [ + { + path: 'folder/testFile001.txt', + content: makeBufferFromString('folder/testFile001.txt content'), + }, + { + path: 'testFile001.txt', + content: makeBufferFromString('testFile002.txt content'), + }, + { + path: 'testFile002.txt', + content: makeBufferFromString('testFile001.txt content'), + }, +]; + +const makeFileContent = async (fileContent: string) => { + const result: ReadUrlResponse = { + buffer: makeBufferFromString(fileContent), + }; + return result; +}; + +const testFileOneContent = 'testFileOne content'; +const testFileTwoContent = 'testFileTwo content'; +const genericFileContent = 'file content'; + +const mockUrlReader: UrlReader = { + readUrl(url: string) { + switch (url) { + case 'testFileOne': + return makeFileContent(testFileOneContent); + case 'testFileTwo': + return makeFileContent(testFileTwoContent); + default: + return makeFileContent(genericFileContent); + } + }, + readTree() { + const result: ReadTreeResponse = { + files: async () => testingUrlFakeFileTree, + archive() { + throw new Error('Function not implemented.'); + }, + dir() { + throw new Error('Function not implemented.'); + }, + etag: '', + }; + + const resultPromise = async () => result; + return resultPromise(); + }, + search() { + throw new Error('search not implemented.'); + }, +}; + +class MockCacheClient implements CacheClient { + private itemRegistry: { [key: string]: any }; + + constructor() { + this.itemRegistry = {}; + } + + async get(key: string) { + return this.itemRegistry[key]; + } + + async set(key: string, value: any) { + this.itemRegistry[key] = value; + } + + async delete(key: string) { + delete this.itemRegistry[key]; + } +} + +describe('createRouter', () => { + let app: express.Express; + + beforeEach(async () => { + jest.resetAllMocks(); + + const router = await createRouter({ + reader: mockUrlReader, + cacheClient: new MockCacheClient(), + logger: { + error: (message: any) => message, + } as Logger, + }); + app = express().use(router); + }); + + describe(`GET ${listEndpointName}`, () => { + it('returns bad request (400) when no url is provided', async () => { + const urlNotSpecifiedRequest = await request(app).get(listEndpointName); + const urlNotSpecifiedStatus = urlNotSpecifiedRequest.status; + const urlNotSpecifiedMessage = urlNotSpecifiedRequest.body.message; + + const urlNotFilledRequest = await request(app).get( + `${listEndpointName}?url=`, + ); + const urlNotFilledStatus = urlNotFilledRequest.status; + const urlNotFilledMessage = urlNotFilledRequest.body.message; + + const expectedStatusCode = 400; + const expectedErrorMessage = 'No URL provided'; + + expect(urlNotSpecifiedStatus).toBe(expectedStatusCode); + expect(urlNotSpecifiedMessage).toBe(expectedErrorMessage); + + expect(urlNotFilledStatus).toBe(expectedStatusCode); + expect(urlNotFilledMessage).toBe(expectedErrorMessage); + }); + + it('returns the correct listing when reading a url', async () => { + const result = await request(app).get(`${listEndpointName}?url=testing`); + const { status, body, error } = result; + + const expectedStatusCode = 200; + const expectedBody = { + data: [ + { + type: 'file', + name: 'testFile001.txt', + path: 'folder/testFile001.txt', + }, + { + type: 'file', + name: 'testFile001.txt', + path: 'testFile001.txt', + }, + { + type: 'file', + name: 'testFile002.txt', + path: 'testFile002.txt', + }, + ], + }; + + expect(error).toBeFalsy(); + expect(status).toBe(expectedStatusCode); + expect(body).toEqual(expectedBody); + }); + }); + + describe(`GET ${fileEndpointName}`, () => { + it('returns bad request (400) when no url is provided', async () => { + const urlNotSpecifiedRequest = await request(app).get(fileEndpointName); + const urlNotSpecifiedStatus = urlNotSpecifiedRequest.status; + const urlNotSpecifiedMessage = urlNotSpecifiedRequest.body.message; + + const urlNotFilledRequest = await request(app).get( + `${fileEndpointName}?url=`, + ); + const urlNotFilledStatus = urlNotFilledRequest.status; + const urlNotFilledMessage = urlNotFilledRequest.body.message; + + const expectedStatusCode = 400; + const expectedErrorMessage = 'No URL provided'; + + expect(urlNotSpecifiedStatus).toBe(expectedStatusCode); + expect(urlNotSpecifiedMessage).toBe(expectedErrorMessage); + + expect(urlNotFilledStatus).toBe(expectedStatusCode); + expect(urlNotFilledMessage).toBe(expectedErrorMessage); + }); + + it('returns the correct file contents when reading a url', async () => { + const fileOneResponse = await request(app).get( + `${fileEndpointName}?url=testFileOne`, + ); + const fileOneStatus = fileOneResponse.status; + const fileOneBody = fileOneResponse.body; + const fileOneError = fileOneResponse.error; + + const fileTwoResponse = await request(app).get( + `${fileEndpointName}?url=testFileTwo`, + ); + const fileTwoStatus = fileTwoResponse.status; + const fileTwoBody = fileTwoResponse.body; + const fileTwoError = fileTwoResponse.error; + + const expectedStatusCode = 200; + + expect(fileOneError).toBeFalsy(); + expect(fileOneStatus).toBe(expectedStatusCode); + expect(fileOneBody.data).toBe(testFileOneContent); + + expect(fileTwoError).toBeFalsy(); + expect(fileTwoStatus).toBe(expectedStatusCode); + expect(fileTwoBody.data).toBe(testFileTwoContent); + }); + }); +}); diff --git a/plugins/adr-backend/src/service/router.ts b/plugins/adr-backend/src/service/router.ts new file mode 100644 index 0000000000..e6603ab346 --- /dev/null +++ b/plugins/adr-backend/src/service/router.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { CacheClient, UrlReader } from '@backstage/backend-common'; +import { NotModifiedError, stringifyError } from '@backstage/errors'; +import { Logger } from 'winston'; +import express from 'express'; +import Router from 'express-promise-router'; + +/** @public */ +export type AdrRouterOptions = { + reader: UrlReader; + cacheClient: CacheClient; + logger: Logger; +}; + +/** @public */ +export async function createRouter( + options: AdrRouterOptions, +): Promise { + const { reader, cacheClient, logger } = options; + + const router = Router(); + router.use(express.json()); + + router.get('/list', async (req, res) => { + const urlToProcess = req.query.url as string; + if (!urlToProcess) { + res.statusCode = 400; + res.json({ message: 'No URL provided' }); + return; + } + + const cachedTree = (await cacheClient.get(urlToProcess)) as { + data: { + type: string; + name: string; + path: string; + }[]; + etag: string; + }; + const cachedData = cachedTree?.data; + + try { + const treeGetResponse = await reader.readTree(urlToProcess, { + etag: cachedTree?.etag, + }); + const files = await treeGetResponse.files(); + const data = files.map(file => { + return { + type: 'file', + name: file.path.substring(file.path.lastIndexOf('/') + 1), + path: file.path, + }; + }); + + await cacheClient.set(urlToProcess, { + data, + etag: treeGetResponse.etag, + }); + + res.json({ data }); + } catch (error: any) { + if (cachedData && error.name === NotModifiedError.name) { + res.json({ data: cachedData }); + return; + } + + const message = stringifyError(error); + logger.error(`Unable to fetch ADRs from ${urlToProcess}: ${message}`); + res.statusCode = 500; + res.json({ message }); + } + }); + + router.get('/file', async (req, res) => { + const urlToProcess = req.query.url as string; + if (!urlToProcess) { + res.statusCode = 400; + res.json({ message: 'No URL provided' }); + return; + } + + const cachedFileContent = (await cacheClient.get(urlToProcess)) as { + data: string; + etag: string; + }; + + try { + const fileGetResponse = await reader.readUrl(urlToProcess, { + etag: cachedFileContent?.etag, + }); + const fileBuffer = await fileGetResponse.buffer(); + const data = fileBuffer.toString(); + + await cacheClient.set(urlToProcess, { + data, + etag: fileGetResponse.etag, + }); + + res.json({ data }); + } catch (error) { + if (cachedFileContent && error.name === NotModifiedError.name) { + res.json({ data: cachedFileContent.data }); + return; + } + + const message = stringifyError(error); + logger.error(`Unable to fetch ADRs from ${urlToProcess}: ${message}`); + res.statusCode = 500; + res.json({ message }); + } + }); + + return router; +} diff --git a/plugins/adr/README.md b/plugins/adr/README.md index 2888ff2f07..dde270af11 100644 --- a/plugins/adr/README.md +++ b/plugins/adr/README.md @@ -4,17 +4,19 @@ Welcome to the ADR plugin! This plugin allows you to browse ADRs associated with your entities as well as a way to discover ADRs across others entities via Backstage Search. Use this to learn from the past experience of other projects to guide your own architecture decisions. -NOTE: This plugin currently only supports entities/ADRs registered via GitHub integration. - ## Setup -Install this plugin: +1. Install this plugin: ```bash # From your Backstage root directory yarn --cwd packages/app add @backstage/plugin-adr ``` +2. Make sure the [ADR backend plugin](../adr-backend/README.md) is installed. + +3. [Configure integrations](https://backstage.io/docs/integrations/) for all sites you would like to pull ADRs from. + ### Entity Pages 1. Add the plugin as a tab to your Entity pages: diff --git a/plugins/adr/api-report.md b/plugins/adr/api-report.md index ecec0c0dd5..d6480cd090 100644 --- a/plugins/adr/api-report.md +++ b/plugins/adr/api-report.md @@ -7,11 +7,37 @@ import { AdrDocument } from '@backstage/plugin-adr-common'; import { AdrFilePathFilterFn } from '@backstage/plugin-adr-common'; +import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; import { isAdrAvailable } from '@backstage/plugin-adr-common'; import { ResultHighlight } from '@backstage/plugin-search-common'; import { RouteRef } from '@backstage/core-plugin-api'; +// @public +export interface AdrApi { + listAdrs(url: string): Promise; + readAdr(url: string): Promise; +} + +// @public +export const adrApiRef: ApiRef; + +// @public +export class AdrClient implements AdrApi { + constructor(options: AdrClientOptions); + // (undocumented) + listAdrs(url: string): Promise; + // (undocumented) + readAdr(url: string): Promise; +} + +// @public +export interface AdrClientOptions { + // (undocumented) + discoveryApi: DiscoveryApi; +} + // @public export type AdrContentDecorator = (adrInfo: { baseUrl: string; @@ -20,6 +46,18 @@ export type AdrContentDecorator = (adrInfo: { content: string; }; +// @public +export type AdrFileInfo = { + type: string; + path: string; + name: string; +}; + +// @public +export type AdrListResult = { + data: AdrFileInfo[]; +}; + // @public export const adrPlugin: BackstagePlugin< { @@ -38,6 +76,11 @@ export const AdrReader: { }>; }; +// @public +export type AdrReadResult = { + data: string; +}; + // @public export function AdrSearchResultListItem(props: { lineClamp?: number; diff --git a/plugins/adr/package.json b/plugins/adr/package.json index 00cf052365..30d0b846a3 100644 --- a/plugins/adr/package.json +++ b/plugins/adr/package.json @@ -34,8 +34,6 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "git-url-parse": "^13.0.0", - "octokit": "^2.0.0", "react-markdown": "^8.0.0", "react-use": "^17.2.4", "remark-gfm": "^3.0.1" diff --git a/plugins/adr/src/api/AdrClient.ts b/plugins/adr/src/api/AdrClient.ts new file mode 100644 index 0000000000..da1adc04e4 --- /dev/null +++ b/plugins/adr/src/api/AdrClient.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { DiscoveryApi } from '@backstage/core-plugin-api'; +import { AdrApi, AdrListResult, AdrReadResult } from './types'; + +/** + * Options for creating an AdrClient. + * + * @public + */ +export interface AdrClientOptions { + discoveryApi: DiscoveryApi; +} + +const readEndpoint = 'file'; +const listEndpoint = 'list'; + +/** + * An implementation of the AdrApi that communicates with the ADR backend plugin. + * + * @public + */ +export class AdrClient implements AdrApi { + private readonly discoveryApi: DiscoveryApi; + + constructor(options: AdrClientOptions) { + this.discoveryApi = options.discoveryApi; + } + + private async fetchAdrApi(endpoint: string, fileUrl: string): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('adr'); + const targetUrl = `${baseUrl}/${endpoint}?url=${encodeURIComponent( + fileUrl, + )}`; + + const result = await fetch(targetUrl); + const data = await result.json(); + + if (!result.ok) { + throw new Error(`${data.message}`); + } + return data; + } + + async listAdrs(url: string): Promise { + return this.fetchAdrApi(listEndpoint, url); + } + + async readAdr(url: string): Promise { + return this.fetchAdrApi(readEndpoint, url); + } +} diff --git a/plugins/adr/src/api/index.ts b/plugins/adr/src/api/index.ts new file mode 100644 index 0000000000..095da3519d --- /dev/null +++ b/plugins/adr/src/api/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ + +export { AdrClient } from './AdrClient'; +export type { AdrClientOptions } from './AdrClient'; +export { adrApiRef } from './types'; +export type { + AdrApi, + AdrFileInfo, + AdrListResult, + AdrReadResult, +} from './types'; diff --git a/plugins/adr/src/api/types.ts b/plugins/adr/src/api/types.ts new file mode 100644 index 0000000000..e4632e171a --- /dev/null +++ b/plugins/adr/src/api/types.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { createApiRef } from '@backstage/core-plugin-api'; + +/** + * Contains information about an ADR file. + * + * @public + */ +export type AdrFileInfo = { + /** The file type. */ + type: string; + + /** The relative path of the ADR file. */ + path: string; + + /** The name of the ADR file. */ + name: string; +}; + +/** + * The result of listing ADRs. + * + * @public + */ +export type AdrListResult = { + data: AdrFileInfo[]; +}; + +/** + * The result of reading an ADR. + * + * @public + */ +export type AdrReadResult = { + /** The contents of the read ADR file. */ + data: string; +}; + +/** + * The API used by the adr plugin to list and read ADRs. + * + * @public + */ +export interface AdrApi { + /** Lists the ADRs at the provided url. */ + listAdrs(url: string): Promise; + + /** Reads the contents of the ADR at the provided url. */ + readAdr(url: string): Promise; +} + +/** + * ApiRef for the AdrApi. + * + * @public + */ +export const adrApiRef = createApiRef({ + id: 'plugin.adr.api', +}); diff --git a/plugins/adr/src/components/AdrReader/AdrReader.tsx b/plugins/adr/src/components/AdrReader/AdrReader.tsx index 971dae0949..ad99ce3a5a 100644 --- a/plugins/adr/src/components/AdrReader/AdrReader.tsx +++ b/plugins/adr/src/components/AdrReader/AdrReader.tsx @@ -26,9 +26,10 @@ import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { getAdrLocationUrl } from '@backstage/plugin-adr-common'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { useOctokitRequest } from '../../hooks'; import { adrDecoratorFactories } from './decorators'; import { AdrContentDecorator } from './types'; +import { adrApiRef } from '../../api'; +import useAsync from 'react-use/lib/useAsync'; /** * Component to fetch and render an ADR. @@ -42,10 +43,13 @@ export const AdrReader = (props: { const { adr, decorators } = props; const { entity } = useEntity(); const scmIntegrations = useApi(scmIntegrationsApiRef); + const adrApi = useApi(adrApiRef); const adrLocationUrl = getAdrLocationUrl(entity, scmIntegrations); - const { value, loading, error } = useOctokitRequest( - `${adrLocationUrl.replace(/\/$/, '')}/${adr}`, + const url = `${adrLocationUrl.replace(/\/$/, '')}/${adr}`; + const { value, loading, error } = useAsync( + async () => adrApi.readAdr(url), + [url], ); const adrContent = useMemo(() => { diff --git a/plugins/adr/src/components/EntityAdrContent/EntityAdrContent.tsx b/plugins/adr/src/components/EntityAdrContent/EntityAdrContent.tsx index 37344cecca..a698cffd26 100644 --- a/plugins/adr/src/components/EntityAdrContent/EntityAdrContent.tsx +++ b/plugins/adr/src/components/EntityAdrContent/EntityAdrContent.tsx @@ -44,9 +44,10 @@ import { Typography, } from '@material-ui/core'; -import { useOctokitRequest } from '../../hooks'; import { rootRouteRef } from '../../routes'; import { AdrContentDecorator, AdrReader } from '../AdrReader'; +import { adrApiRef } from '../../api'; +import useAsync from 'react-use/lib/useAsync'; const useStyles = makeStyles((theme: Theme) => ({ adrMenu: { @@ -69,10 +70,13 @@ export const EntityAdrContent = (props: { const [adrList, setAdrList] = useState([]); const [searchParams, setSearchParams] = useSearchParams(); const scmIntegrations = useApi(scmIntegrationsApiRef); + const adrApi = useApi(adrApiRef); const entityHasAdrs = isAdrAvailable(entity); - const { value, loading, error } = useOctokitRequest( - getAdrLocationUrl(entity, scmIntegrations), + const url = getAdrLocationUrl(entity, scmIntegrations); + const { value, loading, error } = useAsync( + async () => adrApi.listAdrs(url), + [url], ); const selectedAdr = diff --git a/plugins/adr/src/hooks/useOctokitRequest.ts b/plugins/adr/src/hooks/useOctokitRequest.ts deleted file mode 100644 index f160651169..0000000000 --- a/plugins/adr/src/hooks/useOctokitRequest.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * 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 parseGitUrl from 'git-url-parse'; -import useAsync from 'react-use/lib/useAsync'; -import { Octokit } from 'octokit'; -import { useApi } from '@backstage/core-plugin-api'; -import { - scmAuthApiRef, - scmIntegrationsApiRef, -} from '@backstage/integration-react'; - -/** - * Hook for triggering authenticated Octokit requests against the GitHub Content API - * @public - */ -export const useOctokitRequest = (request: string): any => { - const authApi = useApi(scmAuthApiRef); - const scmIntegrations = useApi(scmIntegrationsApiRef); - - const { owner, name, ref, filepath } = parseGitUrl(request); - const path = filepath.replace(/^\//, ''); - const baseUrl = scmIntegrations.github.byUrl(request)?.config.apiBaseUrl; - - return useAsync(async () => { - const { token } = await authApi.getCredentials({ - url: request, - additionalScope: { - customScopes: { - github: ['repo'], - }, - }, - }); - const octokit = new Octokit({ - auth: token, - baseUrl, - }); - - return octokit.request( - `GET /repos/${owner}/${name}/contents/${path}?ref=${ref}`, - { - headers: { Accept: 'application/vnd.github.v3.raw' }, - }, - ); - }, [request]); -}; diff --git a/plugins/adr/src/index.ts b/plugins/adr/src/index.ts index 84012ccb7a..2ec76b0fd6 100644 --- a/plugins/adr/src/index.ts +++ b/plugins/adr/src/index.ts @@ -13,11 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + /** * ADR frontend plugin * * @packageDocumentation */ + +export { adrApiRef, AdrClient } from './api'; +export type { + AdrApi, + AdrClientOptions, + AdrFileInfo, + AdrListResult, + AdrReadResult, +} from './api'; export { isAdrAvailable } from '@backstage/plugin-adr-common'; export * from './components/AdrReader'; export { adrPlugin, EntityAdrContent } from './plugin'; diff --git a/plugins/adr/src/plugin.ts b/plugins/adr/src/plugin.ts index 03437b1f0b..f23df826cd 100644 --- a/plugins/adr/src/plugin.ts +++ b/plugins/adr/src/plugin.ts @@ -14,11 +14,13 @@ * limitations under the License. */ +import { adrApiRef, AdrClient } from './api'; import { + createApiFactory, createPlugin, createRoutableExtension, + discoveryApiRef, } from '@backstage/core-plugin-api'; - import { rootRouteRef } from './routes'; /** @@ -27,6 +29,17 @@ import { rootRouteRef } from './routes'; */ export const adrPlugin = createPlugin({ id: 'adr', + apis: [ + createApiFactory({ + api: adrApiRef, + deps: { + discoveryApi: discoveryApiRef, + }, + factory({ discoveryApi }) { + return new AdrClient({ discoveryApi }); + }, + }), + ], routes: { root: rootRouteRef, }, diff --git a/yarn.lock b/yarn.lock index 41202619bc..3608fb101a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4238,7 +4238,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-adr-backend@workspace:plugins/adr-backend": +"@backstage/plugin-adr-backend@workspace:^, @backstage/plugin-adr-backend@workspace:plugins/adr-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-adr-backend@workspace:plugins/adr-backend" dependencies: @@ -4251,8 +4251,11 @@ __metadata: "@backstage/integration": "workspace:^" "@backstage/plugin-adr-common": "workspace:^" "@backstage/plugin-search-common": "workspace:^" + "@types/express": ^4.17.15 "@types/marked": ^4.0.0 "@types/supertest": ^2.0.8 + express: ^4.18.2 + express-promise-router: ^4.1.1 luxon: ^3.0.0 marked: ^4.0.14 msw: ^0.49.0 @@ -4300,9 +4303,7 @@ __metadata: "@types/git-url-parse": ^9.0.0 "@types/node": "*" cross-fetch: ^3.1.5 - git-url-parse: ^13.0.0 msw: ^0.49.0 - octokit: ^2.0.0 react-markdown: ^8.0.0 react-use: ^17.2.4 remark-gfm: ^3.0.1 @@ -14412,7 +14413,7 @@ __metadata: languageName: node linkType: hard -"@types/express-serve-static-core@npm:*, @types/express-serve-static-core@npm:^4.17.18, @types/express-serve-static-core@npm:^4.17.5": +"@types/express-serve-static-core@npm:*, @types/express-serve-static-core@npm:^4.17.18, @types/express-serve-static-core@npm:^4.17.31, @types/express-serve-static-core@npm:^4.17.5": version: 4.17.32 resolution: "@types/express-serve-static-core@npm:4.17.32" dependencies: @@ -14453,7 +14454,19 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:*, @types/express@npm:4.17.14, @types/express@npm:^4.17.13, @types/express@npm:^4.17.6": +"@types/express@npm:*, @types/express@npm:^4.17.13, @types/express@npm:^4.17.15, @types/express@npm:^4.17.6": + version: 4.17.15 + resolution: "@types/express@npm:4.17.15" + dependencies: + "@types/body-parser": "*" + "@types/express-serve-static-core": ^4.17.31 + "@types/qs": "*" + "@types/serve-static": "*" + checksum: b4acd8a836d4f6409cdf79b12d6e660485249b62500cccd61e7997d2f520093edf77d7f8498ca79d64a112c6434b6de5ca48039b8fde2c881679eced7e96979b + languageName: node + linkType: hard + +"@types/express@npm:4.17.14": version: 4.17.14 resolution: "@types/express@npm:4.17.14" dependencies: @@ -22437,6 +22450,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/integration": "workspace:^" + "@backstage/plugin-adr-backend": "workspace:^" "@backstage/plugin-app-backend": "workspace:^" "@backstage/plugin-auth-backend": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" @@ -22635,7 +22649,7 @@ __metadata: languageName: node linkType: hard -"express-promise-router@npm:^4.1.0": +"express-promise-router@npm:^4.1.0, express-promise-router@npm:^4.1.1": version: 4.1.1 resolution: "express-promise-router@npm:4.1.1" dependencies: @@ -22677,7 +22691,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1": +"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1, express@npm:^4.18.2": version: 4.18.2 resolution: "express@npm:4.18.2" dependencies: