feat(openapi-tooling): Add support for request validation and create router stubs.

Signed-off-by: Aramis Sennyey <sennyeyaramis@gmail.com>
Signed-off-by: Aramis <sennyeyaramis@gmail.com>
This commit is contained in:
Aramis Sennyey
2023-05-11 14:09:59 -04:00
committed by Aramis
parent f8be6ff22f
commit ebeb775869
35 changed files with 801 additions and 282 deletions
@@ -0,0 +1,120 @@
/*
* 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 default {
openapi: '3.0.2',
info: {
title: 'Swagger Petstore - OpenAPI 3.0',
description:
"This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)",
termsOfService: 'http://swagger.io/terms/',
contact: {
email: 'apiteam@swagger.io',
},
license: {
name: 'Apache 2.0',
url: 'http://www.apache.org/licenses/LICENSE-2.0.html',
},
version: '1.0.17',
},
externalDocs: {
description: 'Find out more about Swagger',
url: 'http://swagger.io',
},
servers: [
{
url: '/',
},
],
paths: {
'/pet/{petId}': {
get: {
summary: 'Find pet by ID',
description: 'Returns a single pet',
operationId: 'getPetById',
parameters: [
{
name: 'petId',
in: 'path',
description: 'ID of pet to return',
required: true,
schema: {
type: 'integer',
format: 'int64',
},
},
],
responses: {
'200': {
description: 'successful operation',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Pet',
},
},
},
},
'400': {
description: 'Invalid ID supplied',
},
'404': {
description: 'Pet not found',
},
},
},
},
},
components: {
schemas: {
Pet: {
required: ['name', 'photoUrls'],
type: 'object',
properties: {
id: {
type: 'integer',
format: 'int64',
example: 10,
},
name: {
type: 'string',
example: 'doggie',
},
photoUrls: {
type: 'array',
xml: {
wrapped: true,
},
items: {
type: 'string',
xml: {
name: 'photoUrl',
},
},
},
status: {
type: 'string',
description: 'pet status in the store',
enum: ['available', 'pending', 'sold'],
},
},
xml: {
name: 'pet',
},
},
},
},
} as const;
@@ -23,3 +23,4 @@ import * as internal from './types';
export { internal };
export type { ApiRouter } from './router';
export { createValidatedOpenApiRouter } from './stub';
@@ -0,0 +1,71 @@
/*
* 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 { createValidatedOpenApiRouter } from './stub';
import express from 'express';
import request from 'supertest';
import singlePathSpec from './___fixtures__/single-path';
describe('createRouter', () => {
it('does NOT override originalUrl and basePath after execution', async () => {
expect.assertions(2);
const router = createValidatedOpenApiRouter(singlePathSpec);
router.get('/pet/:petId', (req, res) => {
expect(req.baseUrl).toBe('/pet-store');
expect(req.originalUrl).toBe(`/pet-store/pet/${req.params.petId}`);
res.send('');
});
const appRouter = express();
appRouter.use('/pet-store', router);
await request(appRouter).get('/pet-store/pet/1');
});
it('handles nested routes correctly (by treating plugin specs as full paths)', async () => {
expect.assertions(1);
const router = createValidatedOpenApiRouter(singlePathSpec);
const routerGetFn = jest.fn();
router.get('/pet/:petId', (_, res) => {
routerGetFn();
res.send('');
});
const apiRouter = express.Router();
apiRouter.use('/pet-store', router);
const appRouter = express();
appRouter.use('/api', apiRouter);
await request(appRouter).get('/api/pet-store/pet/1');
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.send('');
});
const apiRouter = express.Router();
apiRouter.use('/pet-store', router);
const appRouter = express();
appRouter.use('/api', apiRouter);
await request(appRouter).get('/api/pet-store/pet/1');
});
});
+120
View File
@@ -0,0 +1,120 @@
/*
* 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 PromiseRouter from 'express-promise-router';
import { ApiRouter } from './router';
import { RequiredDoc } from './types';
import {
ErrorRequestHandler,
RequestHandler,
NextFunction,
Request,
Response,
json,
} from 'express';
import { InputError } from '@backstage/errors';
import { middleware as OpenApiValidator } from 'express-openapi-validator';
type PropertyOverrideRequest = Request & {
[key: symbol]: string;
};
const baseUrlSymbol = Symbol();
const originalUrlSymbol = Symbol();
function validatorErrorTransformer(): ErrorRequestHandler {
return (error: Error, _: Request, _2: Response, next: NextFunction) => {
next(new InputError(error.message));
};
}
export function getDefaultRouterMiddleware() {
return [json()];
}
/**
* Create a new OpenAPI router with some default middleware.
* @param spec - Your OpenAPI spec imported as a JSON object.
* @param validatorOptions - `openapi-express-validator` options to override the defaults.
* @returns A new express router with validation middleware.
* @public
*/
export function createValidatedOpenApiRouter<T extends RequiredDoc>(
spec: T,
options?: {
validatorOptions?: Partial<Parameters<typeof OpenApiValidator>['0']>;
middleware?: RequestHandler[];
},
) {
const router = PromiseRouter() as ApiRouter<typeof spec>;
router.use(options?.middleware || getDefaultRouterMiddleware());
/**
* Middleware to setup the routing for OpenApiValidator. OpenApiValidator expects `req.originalUrl`
* and `req.baseUrl` to be the full path. We adjust them here to basically be nothing and then
* revive the old values in the last function in this method. We could instead update `req.path`
* but that might affect the routing and I'd rather not.
*
* TODO: I opened https://github.com/cdimascio/express-openapi-validator/issues/843
* to track this on the middleware side, but there was a similar ticket, https://github.com/cdimascio/express-openapi-validator/issues/113
* that has had minimal activity. If that changes, update this to use a new option on their side.
*/
router.use((req: Request, _, next) => {
/**
* Express typings are weird. They don't recognize PropertyOverrideRequest as a valid
* Request child and try to overload as PathParams. Just cast it here, since we know
* what we're doing.
*/
const customRequest = req as PropertyOverrideRequest;
customRequest[baseUrlSymbol] = customRequest.baseUrl;
customRequest.baseUrl = '';
customRequest[originalUrlSymbol] = customRequest.originalUrl;
customRequest.originalUrl = customRequest.url;
next();
});
// TODO: Handle errors by converting from OpenApiValidator errors to known @backstage/errors errors.
router.use(
OpenApiValidator({
validateRequests: {
coerceTypes: false,
allowUnknownQueryParameters: false,
},
ignoreUndocumented: true,
validateResponses: false,
...options?.validatorOptions,
apiSpec: spec as any,
}),
);
/**
* Revert `req.baseUrl` and `req.originalUrl` changes. This ensures that any further usage
* of these variables will be unchanged.
*/
router.use((req: Request, _, next) => {
const customRequest = req as PropertyOverrideRequest;
customRequest.baseUrl = customRequest[baseUrlSymbol];
customRequest.originalUrl = customRequest[originalUrlSymbol];
delete customRequest[baseUrlSymbol];
delete customRequest[originalUrlSymbol];
next();
});
// Any errors from the middleware get through here.
router.use(validatorErrorTransformer());
return router;
}
@@ -35,6 +35,7 @@ import {
PathTemplate,
RequiredDoc,
SchemaRef,
ValueOf,
} from './common';
import { FromSchema, JSONSchema7 } from 'json-schema-to-ts';
@@ -60,6 +61,14 @@ export type DocParameter<
: never
: DocOperation<Doc, Path, Method>['parameters'][Parameter];
/**
* Helper to convert from string to number, used to index arrays and pull out just the indices in the array.
* @public
*/
export type FromNumberStringToNumber<
NumberString extends string | number | symbol,
> = NumberString extends `${infer R extends number}` ? R : never;
/**
* @public
*/
@@ -67,15 +76,18 @@ export type DocParameters<
Doc extends RequiredDoc,
Path extends Extract<keyof Doc['paths'], string>,
Method extends keyof Doc['paths'][Path],
> = DocOperation<Doc, Path, Method>['parameters'] extends ReadonlyArray<any>
? {
[Index in keyof DocOperation<
Doc,
Path,
Method
>['parameters']]: DocParameter<Doc, Path, Method, Index>;
}
: never;
> = {
[Index in keyof DocOperation<
Doc,
Path,
Method
>['parameters'] as FromNumberStringToNumber<Index>]: DocParameter<
Doc,
Path,
Method,
Index
>;
};
/**
* @public
@@ -111,17 +123,15 @@ export type ParametersSchema<
Path extends Extract<keyof Doc['paths'], string>,
Method extends keyof Doc['paths'][Path],
FilterType extends ImmutableParameterObject,
> = number extends keyof DocParameters<Doc, Path, Method>
? MapToSchema<
Doc,
FullMap<
MapDiscriminatedUnion<
Filter<DocParameters<Doc, Path, Method>[number], FilterType>,
'name'
>
>
> = MapToSchema<
Doc,
FullMap<
MapDiscriminatedUnion<
Filter<ValueOf<DocParameters<Doc, Path, Method>>, FilterType>,
'name'
>
: never;
>
>;
/**
* @public