Add the ability to view the catalog's spec from the Backstage interface.
Signed-off-by: Aramis <sennyeyaramis@gmail.com> Signed-off-by: Aramis Sennyey <aramiss@spotify.com>
This commit is contained in:
committed by
Aramis Sennyey
parent
d8b27e89ba
commit
785fb1ea75
@@ -248,6 +248,9 @@ type FullMap<
|
||||
},
|
||||
> = RequiredMap<T> & OptionalMap<T>;
|
||||
|
||||
// @public
|
||||
export function getOpenApiSpecRoute(baseUrl: string): string;
|
||||
|
||||
// @public (undocumented)
|
||||
interface HeaderObject extends ParameterObject {
|
||||
// (undocumented)
|
||||
@@ -442,6 +445,9 @@ type ObjectWithContentSchema<
|
||||
? SchemaRef<Doc, Object['content']['application/json']['schema']>
|
||||
: never;
|
||||
|
||||
// @public
|
||||
export const OPENAPI_SPEC_ROUTE = '/openapi.json';
|
||||
|
||||
// @public (undocumented)
|
||||
type OptionalMap<
|
||||
T extends {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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<typeof singlePathSpec, '/pet/:petId', 'get'> = {
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T extends RequiredDoc>(
|
||||
middleware?: RequestHandler[];
|
||||
},
|
||||
) {
|
||||
const router = PromiseRouter() as ApiRouter<typeof spec>;
|
||||
const router = PromiseRouter();
|
||||
router.use(options?.middleware || getDefaultRouterMiddleware());
|
||||
|
||||
/**
|
||||
@@ -116,5 +128,30 @@ export function createValidatedOpenApiRouter<T extends RequiredDoc>(
|
||||
// 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<typeof spec>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user