Merge pull request #17535 from sennyeya/api-docs-new-backend

[PRFC] API Docs for the whole backend
This commit is contained in:
Patrik Oldsberg
2023-10-24 11:56:56 +02:00
committed by GitHub
22 changed files with 694 additions and 18 deletions
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,33 @@
# catalog-backend-module-backstage-openapi
## 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 `catalog.providers.openapi.plugins`.
## Notes
- **This only works with the new backend system.**
## Installation
To your new backend file, add
```ts title="packages/backend/src/index.ts"
backend.add(
import('@backstage/plugin-catalog-backend-module-backstage-openapi'),
);
```
Add a list of plugins to your config like,
```yaml title="app-config.yaml"
catalog:
providers:
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`.
@@ -0,0 +1,22 @@
## API Report File for "@backstage/plugin-catalog-backend-module-backstage-openapi"
> 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)
const catalogModuleInternalOpenApiSpec: () => BackendFeature;
export { catalogModuleInternalOpenApiSpec };
export default catalogModuleInternalOpenApiSpec;
// @public (undocumented)
export type MetaApiDocsPluginOptions = {
exampleOption: boolean;
};
// @public (undocumented)
export const metaOpenApiDocsPluginId = 'meta-api-docs';
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-catalog-backend-module-backstage-openapi
title: '@backstage/plugin-catalog-backend-module-backstage-openapi'
spec:
lifecycle: experimental
type: backstage-backend-plugin-module
owner: openapi-tooling-maintainers
@@ -0,0 +1,31 @@
/*
* 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 interface Config {
catalog?: {
providers?: {
/**
* BackstageOpenApiEntityProvider configuration
*/
backstageOpenapi?: {
/**
* A list of plugins, whose OpenAPI specs you want to collate in `InternalOpenApiDocumentationProvider`.
*/
plugins: string[];
};
};
};
}
@@ -0,0 +1,55 @@
{
"name": "@backstage/plugin-catalog-backend-module-backstage-openapi",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"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/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"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/cli": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@types/express": "*",
"@types/supertest": "^2.0.8",
"msw": "^1.0.0",
"openapi3-ts": "^3.1.2",
"supertest": "^6.2.4"
},
"configSchema": "config.d.ts",
"files": [
"dist",
"config.d.ts"
]
}
@@ -0,0 +1,250 @@
/*
* 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 {
ANNOTATION_LOCATION,
ANNOTATION_ORIGIN_LOCATION,
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<void>;
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<void> {
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(
'catalog.providers.backstageOpenapi.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: {
[ANNOTATION_LOCATION]:
'internal-package:@backstage/plugin-catalog-backend-module-backstage-openapi',
[ANNOTATION_ORIGIN_LOCATION]:
'internal-package:@backstage/plugin-catalog-backend-module-backstage-openapi',
},
},
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',
},
],
});
}
}
@@ -0,0 +1,61 @@
/*
* 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 }) {
catalog.addEntityProvider(
InternalOpenApiDocumentationProvider.fromConfig(config, {
discovery,
schedule: scheduler,
logger,
}),
);
},
});
},
});
export default catalogModuleInternalOpenApiSpec;
@@ -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 {};