Merge pull request #14548 from thake/feat/resolve-refs-in-yaml

Adds support for generic $ref resolving in yaml documents.
This commit is contained in:
Johan Haals
2022-11-23 10:54:31 +01:00
committed by GitHub
12 changed files with 127 additions and 92 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-openapi': patch
---
Enabled support of resolving `$refs` in all kind of yaml documents, not only OpenAPI. This implicitly adds `$ref` resolving support for AsyncAPI specs. Thus, the `openApiPlaceholderResolver` has been renamed to `jsonSchemaRefPlaceholderResolver`.
@@ -1,8 +1,10 @@
# Catalog Backend Module for OpenAPI specifications
# Catalog Backend Module to resolve $refs in yaml documents
This is an extension module to the plugin-catalog-backend plugin, providing extensions targeted at OpenAPI specifications.
This is an extension module to the Catalog backend, providing extensions to resolve $refs in yaml documents.
With this you can split your OpenAPI definition into multiple files and reference them. They will be bundled, using an UrlReader, during processing and stored as a single specification.
With this you can split your yaml documents into multiple files and reference them. They will be bundled, using an UrlReader, during processing and stored as a single specification.
This is useful for OpenAPI and AsyncAPI specifications.
## Installation
@@ -15,15 +17,18 @@ yarn add --cwd packages/backend @backstage/plugin-catalog-backend-module-openapi
### Adding the plugin to your `packages/backend`
#### **openApiPlaceholderResolver**
#### **jsonSchemaRefPlaceholderResolver**
The placeholder resolver can be added by importing `openApiPlaceholderResolver` in `src/plugins/catalog.ts` in your `backend` package and adding the following.
The placeholder resolver can be added by importing `jsonSchemaRefPlaceholderResolver` in `src/plugins/catalog.ts` in your `backend` package and adding the following.
```ts
builder.setPlaceholderResolver('openapi', openApiPlaceholderResolver);
builder.setPlaceholderResolver('openapi', jsonSchemaRefPlaceholderResolver);
builder.setPlaceholderResolver('asyncapi', jsonSchemaRefPlaceholderResolver);
```
This allows you to use the `$openapi` placeholder when referencing your OpenAPI specification. This will then resolve all `$ref` instances in your specification.
This allows you to use the `$openapi` placeholder when referencing your OpenAPI specification and `$asyncapi` when referencing your AsyncAPI specifications. This will then resolve all `$ref` instances in your specification.
You can also use this resolver for other kind of yaml files to resolve $ref pointer.
```yaml
apiVersion: backstage.io/v1alpha1
@@ -14,10 +14,13 @@ import { ScmIntegrations } from '@backstage/integration';
import { UrlReader } from '@backstage/backend-common';
// @public (undocumented)
export function openApiPlaceholderResolver(
export function jsonSchemaRefPlaceholderResolver(
params: PlaceholderResolverParams,
): Promise<JsonValue>;
// @public @deprecated (undocumented)
export const openApiPlaceholderResolver: typeof jsonSchemaRefPlaceholderResolver;
// @public @deprecated (undocumented)
export class OpenApiRefProcessor implements CatalogProcessor {
constructor(options: {
@@ -32,7 +32,7 @@
"start": "backstage-cli package start"
},
"dependencies": {
"@apidevtools/swagger-parser": "^10.1.0",
"@apidevtools/json-schema-ref-parser": "^9.0.6",
"@backstage/backend-common": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
@@ -17,13 +17,13 @@ import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { LocationSpec } from '@backstage/plugin-catalog-backend';
import { OpenApiRefProcessor } from './OpenApiRefProcessor';
import { bundleOpenApiSpecification } from './lib';
import { bundleFileWithRefs } from './lib';
jest.mock('./lib', () => ({
bundleOpenApiSpecification: jest.fn(),
bundleFileWithRefs: jest.fn(),
}));
const bundledSpecification = '<bundled-specification>';
const bundled = '<bundled-specification>';
describe('OpenApiRefProcessor', () => {
const mockLocation = (): LocationSpec => ({
@@ -32,7 +32,7 @@ describe('OpenApiRefProcessor', () => {
});
beforeEach(() => {
(bundleOpenApiSpecification as any).mockResolvedValue(bundledSpecification);
(bundleFileWithRefs as any).mockResolvedValue(bundled);
});
afterEach(() => {
@@ -71,7 +71,7 @@ describe('OpenApiRefProcessor', () => {
mockLocation(),
);
expect(result.spec?.definition).toEqual(bundledSpecification);
expect(result.spec?.definition).toEqual(bundled);
});
it('should ignore other kinds', async () => {
@@ -21,7 +21,7 @@ import {
CatalogProcessor,
LocationSpec,
} from '@backstage/plugin-catalog-backend';
import { bundleOpenApiSpecification } from './lib';
import { bundleFileWithRefs } from './lib';
import { Logger } from 'winston';
/**
@@ -84,7 +84,7 @@ export class OpenApiRefProcessor implements CatalogProcessor {
this.logger.debug(`Bundling OpenAPI specification from ${location.target}`);
try {
const bundledSpec = await bundleOpenApiSpecification(
const bundledSpec = await bundleFileWithRefs(
definition.toString(),
location.target,
this.reader.read,
@@ -13,5 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { jsonSchemaRefPlaceholderResolver } from './jsonSchemaRefPlaceholderResolver';
export { OpenApiRefProcessor } from './OpenApiRefProcessor';
export { openApiPlaceholderResolver } from './openApiPlaceholderResolver';
export { jsonSchemaRefPlaceholderResolver } from './jsonSchemaRefPlaceholderResolver';
/**
* @public
* @deprecated replaced by jsonSchemaRefPlaceholderResolver
*/
export const openApiPlaceholderResolver = jsonSchemaRefPlaceholderResolver;
@@ -14,16 +14,16 @@
* limitations under the License.
*/
import { PlaceholderResolverParams } from '@backstage/plugin-catalog-backend';
import { openApiPlaceholderResolver } from './openApiPlaceholderResolver';
import { bundleOpenApiSpecification } from './lib';
import { jsonSchemaRefPlaceholderResolver } from './jsonSchemaRefPlaceholderResolver';
import { bundleFileWithRefs } from './lib';
jest.mock('./lib', () => ({
bundleOpenApiSpecification: jest.fn(),
bundleFileWithRefs: jest.fn(),
}));
const bundledSpecification = '<bundled-specification>';
const bundled = '<bundled-specification>';
describe('openApiPlaceholderResolver', () => {
describe('jsonSchemaRefPlaceholderResolver', () => {
const mockResolveUrl = jest.fn();
mockResolveUrl.mockReturnValue('mockUrl');
@@ -40,7 +40,7 @@ describe('openApiPlaceholderResolver', () => {
};
beforeEach(() => {
(bundleOpenApiSpecification as any).mockResolvedValue(bundledSpecification);
(bundleFileWithRefs as any).mockResolvedValue(bundled);
});
afterEach(() => {
@@ -48,16 +48,14 @@ describe('openApiPlaceholderResolver', () => {
});
it('should throw error if unable to bundle the OpenAPI specification', async () => {
(bundleOpenApiSpecification as any).mockRejectedValue(new Error('TEST'));
(bundleFileWithRefs as any).mockRejectedValue(new Error('TEST'));
await expect(openApiPlaceholderResolver(params)).rejects.toThrow(
'Placeholder $openapi unable to bundle OpenAPI specification',
);
await expect(jsonSchemaRefPlaceholderResolver(params)).rejects.toThrow();
});
it('should bundle the OpenAPI specification', async () => {
const result = await openApiPlaceholderResolver(params);
const result = await jsonSchemaRefPlaceholderResolver(params);
expect(result).toEqual(bundledSpecification);
expect(result).toEqual(bundled);
});
});
@@ -16,10 +16,10 @@
import { PlaceholderResolverParams } from '@backstage/plugin-catalog-backend';
import { JsonValue } from '@backstage/types';
import { processingResult } from '@backstage/plugin-catalog-node';
import { bundleOpenApiSpecification } from './lib';
import { bundleFileWithRefs } from './lib';
/** @public */
export async function openApiPlaceholderResolver(
export async function jsonSchemaRefPlaceholderResolver(
params: PlaceholderResolverParams,
): Promise<JsonValue> {
const { content, url } = await readTextLocation(params);
@@ -27,7 +27,7 @@ export async function openApiPlaceholderResolver(
params.emit(processingResult.refresh(`url:${url}`));
try {
return await bundleOpenApiSpecification(
return await bundleFileWithRefs(
content,
url,
params.read,
@@ -35,7 +35,7 @@ export async function openApiPlaceholderResolver(
);
} catch (error) {
throw new Error(
`Placeholder \$${params.key} unable to bundle OpenAPI specification at ${params.value}, ${error}`,
`Placeholder \$${params.key} unable to bundle the file at ${params.value}, ${error}`,
);
}
}
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { bundleOpenApiSpecification } from './bundle';
import { bundleFileWithRefs } from './bundle';
const specification = `
openapi: "3.0.0"
@@ -70,7 +70,7 @@ paths:
type: string
`;
describe('bundleOpenApiSpecification', () => {
describe('bundleFileWithRefs', () => {
const read = jest.fn();
const resolveUrl = jest.fn();
@@ -81,7 +81,7 @@ describe('bundleOpenApiSpecification', () => {
it('should return the bundled specification', async () => {
read.mockResolvedValue(list);
const result = await bundleOpenApiSpecification(
const result = await bundleFileWithRefs(
specification,
'https://github.com/owner/repo/blob/main/catalog-info.yaml',
read,
@@ -108,7 +108,7 @@ describe('bundleOpenApiSpecification', () => {
read.mockResolvedValue(list);
const result = await bundleOpenApiSpecification(
const result = await bundleFileWithRefs(
spec,
'https://github.com/owner/repo/blob/main/catalog-info.yaml',
read,
@@ -117,4 +117,64 @@ describe('bundleOpenApiSpecification', () => {
expect(result).toEqual(expectedResult.trimStart());
});
it('should return the bundled asyncapi specification', async () => {
const spec = `
asyncapi: 2.5.0
info:
version: 1.0.0
title: Sample API
description: A sample API to illustrate OpenAPI concepts
channels:
my-topic:
subscribe:
message:
schemaFormat: "application/schema+json;version=draft-07"
payload:
$ref : "./asyncapi.schema.json"
`;
const jsonSchema = `
{
"type": "object",
"description": "ExampleSchema",
"properties": {
"name" : {
"type": "string"
},
"age" : {
"type" : "integer"
}
}
}
`;
const expectedSchema = `
asyncapi: 2.5.0
info:
version: 1.0.0
title: Sample API
description: A sample API to illustrate OpenAPI concepts
channels:
my-topic:
subscribe:
message:
schemaFormat: application/schema+json;version=draft-07
payload:
type: object
description: ExampleSchema
properties:
name:
type: string
age:
type: integer
`;
read.mockResolvedValue(jsonSchema);
const result = await bundleFileWithRefs(
spec,
'https://github.com/owner/repo/blob/main/catalog-info.yaml',
read,
resolveUrl,
);
expect(result).toEqual(expectedSchema.trimStart());
});
});
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import SwaggerParser from '@apidevtools/swagger-parser';
import $RefParser from '@apidevtools/json-schema-ref-parser';
import { parse, stringify } from 'yaml';
import * as path from 'path';
@@ -30,13 +30,13 @@ export type BundlerRead = (url: string) => Promise<Buffer>;
export type BundlerResolveUrl = (url: string, base: string) => string;
export async function bundleOpenApiSpecification(
specification: string,
export async function bundleFileWithRefs(
fileWithRefs: string,
baseUrl: string,
read: BundlerRead,
resolveUrl: BundlerResolveUrl,
): Promise<string> {
const fileUrlReaderResolver: SwaggerParser.ResolverOptions = {
const fileUrlReaderResolver: $RefParser.ResolverOptions = {
canRead: file => {
const protocol = getProtocol(file.url);
return protocol === undefined || protocol === 'file';
@@ -47,7 +47,7 @@ export async function bundleOpenApiSpecification(
return await read(url);
},
};
const httpUrlReaderResolver: SwaggerParser.ResolverOptions = {
const httpUrlReaderResolver: $RefParser.ResolverOptions = {
canRead: ref => {
const protocol = getProtocol(ref.url);
return protocol === 'http' || protocol === 'https';
@@ -58,13 +58,13 @@ export async function bundleOpenApiSpecification(
},
};
const options: SwaggerParser.Options = {
const options: $RefParser.Options = {
resolve: {
file: fileUrlReaderResolver,
http: httpUrlReaderResolver,
},
};
const specObject = parse(specification);
const bundledSpec = await SwaggerParser.bundle(specObject, options);
return stringify(bundledSpec);
const fileObject = parse(fileWithRefs);
const bundledObject = await $RefParser.bundle(fileObject, options);
return stringify(bundledObject);
}
+3 -46
View File
@@ -21,7 +21,7 @@ __metadata:
languageName: node
linkType: hard
"@apidevtools/json-schema-ref-parser@npm:9.0.6, @apidevtools/json-schema-ref-parser@npm:^9.0.6":
"@apidevtools/json-schema-ref-parser@npm:^9.0.6":
version: 9.0.6
resolution: "@apidevtools/json-schema-ref-parser@npm:9.0.6"
dependencies:
@@ -32,37 +32,6 @@ __metadata:
languageName: node
linkType: hard
"@apidevtools/openapi-schemas@npm:^2.1.0":
version: 2.1.0
resolution: "@apidevtools/openapi-schemas@npm:2.1.0"
checksum: 4a8f64935b9049ef21e41fa4b188f39f6bc3f5291cebd451701db1115451ccb246a739e46cc5ce9ecdec781671431db40db7851acdac84a990a45756e0f32de3
languageName: node
linkType: hard
"@apidevtools/swagger-methods@npm:^3.0.2":
version: 3.0.2
resolution: "@apidevtools/swagger-methods@npm:3.0.2"
checksum: d06b1ac5c1956613c4c6be695612ef860cd4e962b93a509ca551735a328a856cae1e33399cac1dcbf8333ba22b231746f3586074769ef0e172cf549ec9e7eaae
languageName: node
linkType: hard
"@apidevtools/swagger-parser@npm:^10.1.0":
version: 10.1.0
resolution: "@apidevtools/swagger-parser@npm:10.1.0"
dependencies:
"@apidevtools/json-schema-ref-parser": 9.0.6
"@apidevtools/openapi-schemas": ^2.1.0
"@apidevtools/swagger-methods": ^3.0.2
"@jsdevtools/ono": ^7.1.3
ajv: ^8.6.3
ajv-draft-04: ^1.0.0
call-me-maybe: ^1.0.1
peerDependencies:
openapi-types: ">=7"
checksum: c7c923755bd025ee2cae97e1cfd525538523ba74c341a0ac814c023ffe5e63fc2d997539a8ccf9a0fcec41a2d6337d40cc5735acb991ddcbb415853a241908d1
languageName: node
linkType: hard
"@apollo/explorer@npm:^1.1.1":
version: 1.2.0
resolution: "@apollo/explorer@npm:1.2.0"
@@ -4701,7 +4670,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@backstage/plugin-catalog-backend-module-openapi@workspace:plugins/catalog-backend-module-openapi"
dependencies:
"@apidevtools/swagger-parser": ^10.1.0
"@apidevtools/json-schema-ref-parser": ^9.0.6
"@backstage/backend-common": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/catalog-model": "workspace:^"
@@ -15434,18 +15403,6 @@ __metadata:
languageName: node
linkType: hard
"ajv-draft-04@npm:^1.0.0":
version: 1.0.0
resolution: "ajv-draft-04@npm:1.0.0"
peerDependencies:
ajv: ^8.5.0
peerDependenciesMeta:
ajv:
optional: true
checksum: 3f11fa0e7f7359bef6608657f02ab78e9cc62b1fb7bdd860db0d00351b3863a1189c1a23b72466d2d82726cab4eb20725c76f5e7c134a89865e2bfd0e6828137
languageName: node
linkType: hard
"ajv-formats@npm:^2.1.1":
version: 2.1.1
resolution: "ajv-formats@npm:2.1.1"
@@ -15480,7 +15437,7 @@ __metadata:
languageName: node
linkType: hard
"ajv8@npm:ajv@^8.11.0, ajv@npm:^8.0.0, ajv@npm:^8.10.0, ajv@npm:^8.6.3, ajv@npm:^8.8.0":
"ajv8@npm:ajv@^8.11.0, ajv@npm:^8.0.0, ajv@npm:^8.10.0, ajv@npm:^8.8.0":
version: 8.11.2
resolution: "ajv@npm:8.11.2"
dependencies: