Merge pull request #17535 from sennyeya/api-docs-new-backend
[PRFC] API Docs for the whole backend
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
+250
@@ -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 {};
|
||||
Reference in New Issue
Block a user