From 785fb1ea758f06558099be74894ae6d2277af646 Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Tue, 25 Apr 2023 16:52:49 -0400 Subject: [PATCH] Add the ability to view the catalog's spec from the Backstage interface. Signed-off-by: Aramis Signed-off-by: Aramis Sennyey --- .changeset/gentle-pears-camp.md | 5 + .changeset/thirty-stingrays-grin.md | 5 + app-config.yaml | 7 + packages/backend-next/package.json | 2 + packages/backend-next/src/index.ts | 1 + packages/backend-openapi-utils/api-report.md | 6 + packages/backend-openapi-utils/package.json | 3 + .../backend-openapi-utils/src/constants.ts | 21 ++ packages/backend-openapi-utils/src/index.ts | 3 +- .../backend-openapi-utils/src/stub.test.ts | 75 +++++- packages/backend-openapi-utils/src/stub.ts | 41 ++- .../.eslintrc.js | 1 + .../README.md | 30 +++ .../api-report.md | 20 ++ .../package.json | 54 ++++ .../InternalOpenApiDocumentationProvider.ts | 244 ++++++++++++++++++ .../src/index.ts | 62 +++++ .../src/setupTests.ts | 16 ++ yarn.lock | 66 ++++- 19 files changed, 644 insertions(+), 18 deletions(-) create mode 100644 .changeset/gentle-pears-camp.md create mode 100644 .changeset/thirty-stingrays-grin.md create mode 100644 packages/backend-openapi-utils/src/constants.ts create mode 100644 plugins/catalog-backend-module-openapi-spec/.eslintrc.js create mode 100644 plugins/catalog-backend-module-openapi-spec/README.md create mode 100644 plugins/catalog-backend-module-openapi-spec/api-report.md create mode 100644 plugins/catalog-backend-module-openapi-spec/package.json create mode 100644 plugins/catalog-backend-module-openapi-spec/src/InternalOpenApiDocumentationProvider.ts create mode 100644 plugins/catalog-backend-module-openapi-spec/src/index.ts create mode 100644 plugins/catalog-backend-module-openapi-spec/src/setupTests.ts diff --git a/.changeset/gentle-pears-camp.md b/.changeset/gentle-pears-camp.md new file mode 100644 index 0000000000..1a9f8768f8 --- /dev/null +++ b/.changeset/gentle-pears-camp.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-openapi-spec': patch +--- + +Adds a new catalog module for ingesting Backstage plugin OpenAPI specs into the catalog for display as an API entity. diff --git a/.changeset/thirty-stingrays-grin.md b/.changeset/thirty-stingrays-grin.md new file mode 100644 index 0000000000..43aaf2c606 --- /dev/null +++ b/.changeset/thirty-stingrays-grin.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-openapi-utils': patch +--- + +Adds a new route, `/openapi.json` to validated routers for displaying their full OpenAPI spec in a standard endpoint. diff --git a/app-config.yaml b/app-config.yaml index a2ee232516..e7b0a07483 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -50,6 +50,7 @@ backend: allow: - host: example.com - host: '*.mozilla.org' + - host: localhost:7007 # workingDirectory: /tmp # Use this to configure a working directory for the scaffolder, defaults to the OS temp-dir # See README.md in the proxy-backend plugin for information on the configuration format @@ -464,3 +465,9 @@ stackstorm: permission: enabled: true + +openapi: + plugins: + - catalog + - search + - todo diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json index 3e5b4f8057..38245de00e 100644 --- a/packages/backend-next/package.json +++ b/packages/backend-next/package.json @@ -34,6 +34,8 @@ "@backstage/plugin-azure-devops-backend": "workspace:^", "@backstage/plugin-badges-backend": "workspace:^", "@backstage/plugin-catalog-backend": "workspace:^", + "@backstage/plugin-catalog-backend-module-openapi": "workspace:^", + "@backstage/plugin-catalog-backend-module-openapi-spec": "workspace:^", "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "workspace:^", "@backstage/plugin-catalog-backend-module-unprocessed": "workspace:^", "@backstage/plugin-devtools-backend": "workspace:^", diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts index 1b4867c05e..d5793dc0fb 100644 --- a/packages/backend-next/src/index.ts +++ b/packages/backend-next/src/index.ts @@ -43,6 +43,7 @@ backend.add(import('@backstage/plugin-scaffolder-backend/alpha')); backend.add(import('@backstage/plugin-search-backend-module-catalog/alpha')); backend.add(import('@backstage/plugin-search-backend-module-explore/alpha')); backend.add(import('@backstage/plugin-search-backend-module-techdocs/alpha')); +backend.add(import('@backstage/plugin-catalog-backend-module-openapi-spec')); backend.add(import('@backstage/plugin-search-backend/alpha')); backend.add(import('@backstage/plugin-techdocs-backend/alpha')); backend.add(import('@backstage/plugin-todo-backend')); diff --git a/packages/backend-openapi-utils/api-report.md b/packages/backend-openapi-utils/api-report.md index 97ea0224d1..cc6d31ac71 100644 --- a/packages/backend-openapi-utils/api-report.md +++ b/packages/backend-openapi-utils/api-report.md @@ -248,6 +248,9 @@ type FullMap< }, > = RequiredMap & OptionalMap; +// @public +export function getOpenApiSpecRoute(baseUrl: string): string; + // @public (undocumented) interface HeaderObject extends ParameterObject { // (undocumented) @@ -442,6 +445,9 @@ type ObjectWithContentSchema< ? SchemaRef : never; +// @public +export const OPENAPI_SPEC_ROUTE = '/openapi.json'; + // @public (undocumented) type OptionalMap< T extends { diff --git a/packages/backend-openapi-utils/package.json b/packages/backend-openapi-utils/package.json index 8fa83bd584..15957dd4ac 100644 --- a/packages/backend-openapi-utils/package.json +++ b/packages/backend-openapi-utils/package.json @@ -37,6 +37,8 @@ "dist" ], "dependencies": { + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", @@ -45,6 +47,7 @@ "express-promise-router": "^4.1.0", "json-schema-to-ts": "^2.6.2", "lodash": "^4.17.21", + "openapi-merge": "^1.3.2", "openapi3-ts": "^3.1.2" } } diff --git a/packages/backend-openapi-utils/src/constants.ts b/packages/backend-openapi-utils/src/constants.ts new file mode 100644 index 0000000000..b55279f6b2 --- /dev/null +++ b/packages/backend-openapi-utils/src/constants.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2023 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. + */ + +/** + * The route that all OpenAPI specs should be served from. + * @public + */ +export const OPENAPI_SPEC_ROUTE = '/openapi.json'; diff --git a/packages/backend-openapi-utils/src/index.ts b/packages/backend-openapi-utils/src/index.ts index 2b612fd617..528358132c 100644 --- a/packages/backend-openapi-utils/src/index.ts +++ b/packages/backend-openapi-utils/src/index.ts @@ -31,4 +31,5 @@ export type { PathParameters, } from './utility'; export type { ApiRouter } from './router'; -export { createValidatedOpenApiRouter } from './stub'; +export { createValidatedOpenApiRouter, getOpenApiSpecRoute } from './stub'; +export * from './constants'; diff --git a/packages/backend-openapi-utils/src/stub.test.ts b/packages/backend-openapi-utils/src/stub.test.ts index c9ec955e95..d4a1c19153 100644 --- a/packages/backend-openapi-utils/src/stub.test.ts +++ b/packages/backend-openapi-utils/src/stub.test.ts @@ -14,11 +14,12 @@ * limitations under the License. */ -import { createValidatedOpenApiRouter } from './stub'; +import { createValidatedOpenApiRouter, getOpenApiSpecRoute } from './stub'; import express from 'express'; import request from 'supertest'; import singlePathSpec from './___fixtures__/single-path'; import { Response } from './utility'; +import { OPENAPI_SPEC_ROUTE } from './constants'; describe('createRouter', () => { const pet: Response = { @@ -28,6 +29,33 @@ describe('createRouter', () => { photoUrls: [], }; + const specs = [singlePathSpec]; + const ONCE_NESTED_ROUTER_PREFIX = '/pet-store'; + const TWICE_NESTED_ROUTER_PREFIX = `/api`; + + const routers = specs.flatMap(spec => { + const router = createValidatedOpenApiRouter(spec); + const unnestedApp = express(); + unnestedApp.use('/', router); + + const onceNestedRouter = express.Router(); + onceNestedRouter.use(`${ONCE_NESTED_ROUTER_PREFIX}`, router); + const onceNestedApp = express(); + onceNestedApp.use(`/`, onceNestedRouter); + + const twiceNestedApp = express(); + twiceNestedApp.use(`${TWICE_NESTED_ROUTER_PREFIX}`, onceNestedRouter); + return [ + ['', unnestedApp, router], + [ONCE_NESTED_ROUTER_PREFIX, onceNestedApp, router], + [ + `${TWICE_NESTED_ROUTER_PREFIX}${ONCE_NESTED_ROUTER_PREFIX}`, + twiceNestedApp, + router, + ], + ] as const; + }); + it('does NOT override originalUrl and basePath after execution', async () => { expect.assertions(2); const router = createValidatedOpenApiRouter(singlePathSpec); @@ -61,19 +89,40 @@ describe('createRouter', () => { expect(routerGetFn).toHaveBeenCalledTimes(1); }); - it('handles coercing parameters correctly', async () => { - expect.assertions(1); - const router = createValidatedOpenApiRouter(singlePathSpec); - router.get('/pet/:petId', (req, res) => { - expect(typeof req.params.petId).toBe('integer'); - res.json(pet); - }); + it.each(routers)( + '%s handles coercing parameters correctly', + async (prefix, app, router) => { + expect.assertions(1); + router.get('/pet/:petId', (req, res) => { + expect(typeof req.params.petId).toBe('integer'); + res.send(pet); + }); - const apiRouter = express.Router(); - apiRouter.use('/pet-store', router); - const appRouter = express(); - appRouter.use('/api', apiRouter); + await request(app).get(`${prefix}/pet/1`); + }, + ); - await request(appRouter).get('/api/pet-store/pet/1'); + it.each(routers)( + '%s adds the openapi spec to the router', + async (prefix, app) => { + const response = await request(app) + .get(`${prefix}${OPENAPI_SPEC_ROUTE}`) + .expect(200); + expect(response.body).toHaveProperty('paths'); + Object.keys(response.body.paths).forEach(key => { + const specKey = key.replace(prefix, ''); + expect(singlePathSpec.paths).toHaveProperty(specKey); + expect(response.body.paths[key]).toEqual( + (singlePathSpec.paths as any)[specKey], + ); + }); + }, + ); +}); + +describe('getOpenApiSpecRoute', () => { + it('handles expected values', () => { + expect(getOpenApiSpecRoute('/api/test')).toEqual('/api/test/openapi.json'); + expect(getOpenApiSpecRoute('api/test')).toEqual('api/test/openapi.json'); }); }); diff --git a/packages/backend-openapi-utils/src/stub.ts b/packages/backend-openapi-utils/src/stub.ts index 6ec0e1db3c..0f62bf348c 100644 --- a/packages/backend-openapi-utils/src/stub.ts +++ b/packages/backend-openapi-utils/src/stub.ts @@ -27,6 +27,8 @@ import { } from 'express'; import { InputError } from '@backstage/errors'; import { middleware as OpenApiValidator } from 'express-openapi-validator'; +import { OPENAPI_SPEC_ROUTE } from './constants'; +import { isErrorResult, merge } from 'openapi-merge'; type PropertyOverrideRequest = Request & { [key: symbol]: string; @@ -45,6 +47,16 @@ export function getDefaultRouterMiddleware() { return [json()]; } +/** + * Given a base url for a plugin, find the given OpenAPI spec for that plugin. + * @param baseUrl - Plugin base url. + * @returns OpenAPI spec route for the base url. + * @public + */ +export function getOpenApiSpecRoute(baseUrl: string) { + return `${baseUrl}${OPENAPI_SPEC_ROUTE}`; +} + /** * Create a new OpenAPI router with some default middleware. * @param spec - Your OpenAPI spec imported as a JSON object. @@ -59,7 +71,7 @@ export function createValidatedOpenApiRouter( middleware?: RequestHandler[]; }, ) { - const router = PromiseRouter() as ApiRouter; + const router = PromiseRouter(); router.use(options?.middleware || getDefaultRouterMiddleware()); /** @@ -116,5 +128,30 @@ export function createValidatedOpenApiRouter( // Any errors from the middleware get through here. router.use(validatorErrorTransformer()); - return router; + router.get(OPENAPI_SPEC_ROUTE, async (req, res) => { + const mergeOutput = merge([ + { + oas: spec as any, + pathModification: { + /** + * Get the route that this OpenAPI spec is hosted on. The other + * approach of using the discovery API increases the router constructor + * significantly and since we're just looking for path and not full URL, + * this works. + * + * If we wanted to add a list of servers, there may be a case for adding + * discovery API to get an exhaustive list of upstream servers, but that's + * also not currently supported. + */ + prepend: req.originalUrl.replace(OPENAPI_SPEC_ROUTE, ''), + }, + }, + ]); + if (isErrorResult(mergeOutput)) { + throw new InputError('Invalid spec defined'); + } + res.json(mergeOutput.output); + }); + + return router as ApiRouter; } diff --git a/plugins/catalog-backend-module-openapi-spec/.eslintrc.js b/plugins/catalog-backend-module-openapi-spec/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/catalog-backend-module-openapi-spec/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/catalog-backend-module-openapi-spec/README.md b/plugins/catalog-backend-module-openapi-spec/README.md new file mode 100644 index 0000000000..6aebb037f2 --- /dev/null +++ b/plugins/catalog-backend-module-openapi-spec/README.md @@ -0,0 +1,30 @@ +# catalog-backend-module-openapi-spec + +## Summary + +This module installs an entity provider that exports a single entity, your Backstage instance documentation, which merges as many backend plugins as you have defined in the config value `openapi.plugins`. + +## Notes + +- This only works with the new backend system. +- This requires populating a new config value `openapi.plugins` with an array of plugin IDs. The entity processor expo + +## Installation + +To your new backend file, add + +```ts title="packages/backend/src/index.ts" +backend.add(import('@backstage/plugin-catalog-backend-module-openapi-spec')); +``` + +Add a list of plugins to your config like, + +```yaml title="app-config.yaml" +openapi: + plugins: + - catalog + - todo + - search +``` + +We will attempt to load each plugin's OpenAPI spec hosted at `${pluginRoute}/openapi.json`. These are automatically added if you are using `@backstage/backend-openapi-utils`'s `createValidatedOpenApiRouter`. diff --git a/plugins/catalog-backend-module-openapi-spec/api-report.md b/plugins/catalog-backend-module-openapi-spec/api-report.md new file mode 100644 index 0000000000..a3d1933b78 --- /dev/null +++ b/plugins/catalog-backend-module-openapi-spec/api-report.md @@ -0,0 +1,20 @@ +## API Report File for "@backstage/plugin-catalog-backend-module-openapi-spec" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +export const catalogModuleInternalOpenApiSpec: () => BackendFeature; + +// @public (undocumented) +export type MetaApiDocsPluginOptions = { + exampleOption: boolean; +}; + +// @public (undocumented) +export const metaOpenApiDocsPluginId = 'meta-api-docs'; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/catalog-backend-module-openapi-spec/package.json b/plugins/catalog-backend-module-openapi-spec/package.json new file mode 100644 index 0000000000..ce6bbec95f --- /dev/null +++ b/plugins/catalog-backend-module-openapi-spec/package.json @@ -0,0 +1,54 @@ +{ + "name": "@backstage/plugin-catalog-backend-module-openapi-spec", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin-module" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-openapi-utils": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/backend-tasks": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-catalog-node": "workspace:^", + "@types/express": "*", + "cross-fetch": "^3.1.5", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "lodash": "^4.17.21", + "openapi-merge": "^1.3.2", + "uuid": "^9.0.0", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/catalog-model": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@types/supertest": "^2.0.8", + "msw": "^1.0.0", + "openapi3-ts": "^3.1.2", + "supertest": "^6.2.4" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/catalog-backend-module-openapi-spec/src/InternalOpenApiDocumentationProvider.ts b/plugins/catalog-backend-module-openapi-spec/src/InternalOpenApiDocumentationProvider.ts new file mode 100644 index 0000000000..c5b989dbc7 --- /dev/null +++ b/plugins/catalog-backend-module-openapi-spec/src/InternalOpenApiDocumentationProvider.ts @@ -0,0 +1,244 @@ +/* + * Copyright 2023 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 type { ApiEntity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { ForwardedError } from '@backstage/errors'; +import { + EntityProvider, + EntityProviderConnection, +} from '@backstage/plugin-catalog-node'; +import { merge, isErrorResult } from 'openapi-merge'; +import { getOpenApiSpecRoute } from '@backstage/backend-openapi-utils'; +import type { + OpenAPIObject, + OperationObject, + PathItemObject, +} from 'openapi3-ts'; +import fetch from 'cross-fetch'; +import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; +import * as uuid from 'uuid'; +import { PluginTaskScheduler, TaskRunner } from '@backstage/backend-tasks'; + +const HTTP_VERBS: (keyof PathItemObject)[] = [ + 'get', + 'post', + 'put', + 'delete', + 'patch', + 'trace', + 'options', + 'head', +]; + +const addTagsToSpec = (spec: OpenAPIObject, tag: string) => { + Object.values(spec?.paths).forEach((path: PathItemObject) => { + HTTP_VERBS.forEach(verb => { + if (verb in path) { + if (!('tags' in path[verb])) { + (path[verb] as OperationObject).tags = []; + } + if (!(path[verb] as OperationObject).tags?.includes(tag)) { + (path[verb] as OperationObject).tags?.push(tag); + } + } + }); + }); +}; + +const mergeSpecs = async ({ + baseUrl, + specs, +}: { + baseUrl: string; + specs: OpenAPIObject[]; +}) => { + const mergeResult = merge([ + // Add the full API information as the first item for other items to merge against it with. + { + oas: { + openapi: '3.0.3', + info: { + title: 'Backstage API', + version: '1', + }, + servers: [{ url: baseUrl }], + paths: {}, + }, + }, + // For each plugin, load its spec and the known endpoint that it sits under. + ...specs.map( + spec => + ({ + oas: spec, + // Weird typing differences between this package and the client package's openapi 3. + } as any), + ), + ]); + + if (isErrorResult(mergeResult)) { + throw new ForwardedError( + `${mergeResult.message} (${mergeResult.type})`, + mergeResult, + ); + } else { + return mergeResult.output; + } +}; + +const loadSpecs = async ({ + baseUrl, + discovery, + plugins, + logger, +}: { + baseUrl: string; + plugins: string[]; + discovery: DiscoveryService; + logger: LoggerService; +}) => { + const specs: OpenAPIObject[] = []; + for (const pluginId of plugins) { + const url = await discovery.getExternalBaseUrl(pluginId); + const openApiUrl = getOpenApiSpecRoute(url); + const response = await fetch(openApiUrl); + if (response.ok) { + const spec = await response.json(); + addTagsToSpec(spec, pluginId); + specs.push(spec); + } else if (response.status === 404) { + logger.error( + `Plugin=${pluginId} does not have an OpenAPI spec at '${openApiUrl}'.`, + ); + } else { + logger.error( + `Failed to load spec for plugin=${pluginId} at ${openApiUrl}. Error (${ + response.status + }): ${response.body ? await response.text() : response.statusText}`, + ); + } + } + return mergeSpecs({ baseUrl, specs }); +}; + +export class InternalOpenApiDocumentationProvider implements EntityProvider { + private connection?: EntityProviderConnection; + private readonly scheduleFn: () => Promise; + constructor( + public readonly config: Config, + public readonly discovery: DiscoveryService, + public readonly logger: LoggerService, + taskRunner: TaskRunner, + ) { + this.scheduleFn = this.createScheduleFn(taskRunner); + } + + static fromConfig( + config: Config, + options: { + discovery: DiscoveryService; + logger: LoggerService; + schedule: PluginTaskScheduler; + }, + ) { + const taskRunner = options.schedule.createScheduledTaskRunner({ + frequency: { + minutes: 1, + }, + timeout: { + minutes: 1, + }, + }); + return new InternalOpenApiDocumentationProvider( + config, + options.discovery, + options.logger, + taskRunner, + ); + } + /** {@inheritdoc @backstage/plugin-catalog-backend#EntityProvider.getProviderName} */ + getProviderName() { + return `InternalOpenApiDocumentationProvider`; + } + + /** {@inheritdoc @backstage/plugin-catalog-backend#EntityProvider.connect} */ + async connect(connection: EntityProviderConnection) { + this.connection = connection; + return await this.scheduleFn(); + } + + private createScheduleFn(taskRunner: TaskRunner): () => Promise { + return async () => { + const taskId = `${this.getProviderName()}:refresh`; + return taskRunner.run({ + id: taskId, + fn: async () => { + const logger = this.logger.child({ + class: + InternalOpenApiDocumentationProvider.prototype.constructor.name, + taskId, + taskInstanceId: uuid.v4(), + }); + try { + await this.refresh(logger); + } catch (error) { + logger.error(`${this.getProviderName()} refresh failed`, error); + } + }, + }); + }; + } + + async refresh(logger: LoggerService) { + const pluginsToMerge = this.config.getStringArray('openapi.plugins'); + logger.info(`Loading specs from from ${pluginsToMerge}.`); + const documentationEntity: ApiEntity = { + apiVersion: 'backstage.io/v1beta1', + kind: 'API', + metadata: { + name: 'INTERNAL_instance_openapi_doc', + title: 'Your Backstage Instance documentation', + annotations: { + 'backstage.io/managed-by-location': + 'internal-package:@backstage/plugin-catalog-backend-module-openapi-spec', + 'backstage.io/managed-by-origin-location': + 'internal-package:@backstage/plugin-catalog-backend-module-openapi-spec', + }, + }, + spec: { + type: 'openapi', + lifecycle: 'production', + owner: 'backstage', + definition: JSON.stringify( + await loadSpecs({ + baseUrl: this.config.getString('backend.baseUrl'), + discovery: this.discovery, + plugins: pluginsToMerge, + logger, + }), + ), + }, + }; + await this.connection?.applyMutation({ + type: 'full', + entities: [ + { + entity: documentationEntity, + locationKey: 'internal-api-doc', + }, + ], + }); + } +} diff --git a/plugins/catalog-backend-module-openapi-spec/src/index.ts b/plugins/catalog-backend-module-openapi-spec/src/index.ts new file mode 100644 index 0000000000..9cc0cab6d3 --- /dev/null +++ b/plugins/catalog-backend-module-openapi-spec/src/index.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2023 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 { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; +import { InternalOpenApiDocumentationProvider } from './InternalOpenApiDocumentationProvider'; + +/** + * @public + */ +export type MetaApiDocsPluginOptions = { exampleOption: boolean }; + +/** + * @public + */ +export const metaOpenApiDocsPluginId = 'meta-api-docs'; + +/** + * @public + */ +export const catalogModuleInternalOpenApiSpec = createBackendModule({ + moduleId: metaOpenApiDocsPluginId, + pluginId: 'catalog', + register(env) { + env.registerInit({ + deps: { + catalog: catalogProcessingExtensionPoint, + config: coreServices.rootConfig, + discovery: coreServices.discovery, + scheduler: coreServices.scheduler, + logger: coreServices.logger, + }, + async init({ catalog, config, discovery, scheduler, logger }) { + console.log('testing'); + catalog.addEntityProvider( + InternalOpenApiDocumentationProvider.fromConfig(config, { + discovery, + schedule: scheduler, + logger, + }), + ); + }, + }); + }, +}); + +export default catalogModuleInternalOpenApiSpec; diff --git a/plugins/catalog-backend-module-openapi-spec/src/setupTests.ts b/plugins/catalog-backend-module-openapi-spec/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/catalog-backend-module-openapi-spec/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 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 {}; diff --git a/yarn.lock b/yarn.lock index 013950f540..11ab2b969f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3544,7 +3544,9 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/backend-openapi-utils@workspace:packages/backend-openapi-utils" dependencies: + "@backstage/backend-plugin-api": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" "@types/express": ^4.17.6 "@types/express-serve-static-core": ^4.17.5 @@ -3553,6 +3555,7 @@ __metadata: express-promise-router: ^4.1.0 json-schema-to-ts: ^2.6.2 lodash: ^4.17.21 + openapi-merge: ^1.3.2 openapi3-ts: ^3.1.2 supertest: ^6.1.3 languageName: unknown @@ -5633,7 +5636,36 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-catalog-backend-module-openapi@workspace:plugins/catalog-backend-module-openapi": +"@backstage/plugin-catalog-backend-module-openapi-spec@workspace:^, @backstage/plugin-catalog-backend-module-openapi-spec@workspace:plugins/catalog-backend-module-openapi-spec": + version: 0.0.0-use.local + resolution: "@backstage/plugin-catalog-backend-module-openapi-spec@workspace:plugins/catalog-backend-module-openapi-spec" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-openapi-utils": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-tasks": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-catalog-node": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@types/express": "*" + "@types/supertest": ^2.0.8 + cross-fetch: ^3.1.5 + express: ^4.17.1 + express-promise-router: ^4.1.0 + lodash: ^4.17.21 + msw: ^1.0.0 + openapi-merge: ^1.3.2 + openapi3-ts: ^3.1.2 + supertest: ^6.2.4 + uuid: ^9.0.0 + yn: ^4.0.0 + languageName: unknown + linkType: soft + +"@backstage/plugin-catalog-backend-module-openapi@workspace:^, @backstage/plugin-catalog-backend-module-openapi@workspace:plugins/catalog-backend-module-openapi": version: 0.0.0-use.local resolution: "@backstage/plugin-catalog-backend-module-openapi@workspace:plugins/catalog-backend-module-openapi" dependencies: @@ -20154,6 +20186,16 @@ __metadata: languageName: node linkType: hard +"atlassian-openapi@npm:^1.0.8": + version: 1.0.17 + resolution: "atlassian-openapi@npm:1.0.17" + dependencies: + jsonpointer: ^5.0.0 + urijs: ^1.19.10 + checksum: 372a454e8f5e000e016e261b2151019f0b3dabfad908593c71aae23ebd5b9998c374ae3c0bf0d85dd55dbcca94d5d93e38edf02346d0bd2cb34d3fd20968ddaf + languageName: node + linkType: hard + "atomic-sleep@npm:^1.0.0": version: 1.0.0 resolution: "atomic-sleep@npm:1.0.0" @@ -25645,6 +25687,8 @@ __metadata: "@backstage/plugin-azure-devops-backend": "workspace:^" "@backstage/plugin-badges-backend": "workspace:^" "@backstage/plugin-catalog-backend": "workspace:^" + "@backstage/plugin-catalog-backend-module-openapi": "workspace:^" + "@backstage/plugin-catalog-backend-module-openapi-spec": "workspace:^" "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "workspace:^" "@backstage/plugin-catalog-backend-module-unprocessed": "workspace:^" "@backstage/plugin-devtools-backend": "workspace:^" @@ -34240,6 +34284,17 @@ __metadata: languageName: node linkType: hard +"openapi-merge@npm:^1.3.2": + version: 1.3.2 + resolution: "openapi-merge@npm:1.3.2" + dependencies: + atlassian-openapi: ^1.0.8 + lodash: ^4.17.15 + ts-is-present: ^1.1.1 + checksum: 53284a563270177422db8c7536544913c133dfc5cc7058a1043f3092b5aa997b8224a83c59569d18620f94ccf0a014fcb735e22941a9259b2c60861002f01638 + languageName: node + linkType: hard + "openapi-sampler@npm:^1.2.1": version: 1.2.1 resolution: "openapi-sampler@npm:1.2.1" @@ -40922,6 +40977,13 @@ __metadata: languageName: node linkType: hard +"ts-is-present@npm:^1.1.1": + version: 1.2.2 + resolution: "ts-is-present@npm:1.2.2" + checksum: 3620ecf48219d0dd108e493260a207f4733d8e39a18dffec23c7ed2b1ef2aba7158d0dfafe36f3f27d0092472535a5e474ce04ade54e972e64b2b6329d20ab0b + languageName: node + linkType: hard + "ts-log@npm:^2.2.3": version: 2.2.5 resolution: "ts-log@npm:2.2.5" @@ -41674,7 +41736,7 @@ __metadata: languageName: node linkType: hard -"urijs@npm:^1.19.11": +"urijs@npm:^1.19.10, urijs@npm:^1.19.11": version: 1.19.11 resolution: "urijs@npm:1.19.11" checksum: f9b95004560754d30fd7dbee44b47414d662dc9863f1cf5632a7c7983648df11d23c0be73b9b4f9554463b61d5b0a520b70df9e1ee963ebb4af02e6da2cc80f3