From f7323791a1783c57b256de39d776d19ca6504210 Mon Sep 17 00:00:00 2001 From: nikkegg <64557473+nikkegg@users.noreply.github.com> Date: Sun, 12 Nov 2023 18:49:13 +0000 Subject: [PATCH] Allow optional use of `req.url` (#857) * test: add test cases for new feature * feat: allow using req.url based on config --------- Co-authored-by: nikkegg --- src/framework/openapi.context.ts | 9 +- src/framework/types.ts | 1 + src/middlewares/openapi.metadata.ts | 16 +- src/openapi.validator.ts | 10 +- test/user-request-url.router.spec.ts | 272 +++++++++++++++++++++++++++ 5 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 test/user-request-url.router.spec.ts diff --git a/src/framework/openapi.context.ts b/src/framework/openapi.context.ts index 254ed37d..2df421f0 100644 --- a/src/framework/openapi.context.ts +++ b/src/framework/openapi.context.ts @@ -11,16 +11,23 @@ export class OpenApiContext { public readonly openApiRouteMap = {}; public readonly routes: RouteMetadata[] = []; public readonly ignoreUndocumented: boolean; + public readonly useRequestUrl: boolean; private readonly basePaths: string[]; private readonly ignorePaths: RegExp | Function; - constructor(spec: Spec, ignorePaths: RegExp | Function, ignoreUndocumented: boolean = false) { + constructor( + spec: Spec, + ignorePaths: RegExp | Function, + ignoreUndocumented: boolean = false, + useRequestUrl = false, + ) { this.apiDoc = spec.apiDoc; this.basePaths = spec.basePaths; this.routes = spec.routes; this.ignorePaths = ignorePaths; this.ignoreUndocumented = ignoreUndocumented; this.buildRouteMaps(spec.routes); + this.useRequestUrl = useRequestUrl; } public isManagedRoute(path: string): boolean { diff --git a/src/framework/types.ts b/src/framework/types.ts index 6f7c58a4..201e6fbf 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -118,6 +118,7 @@ export interface OpenApiValidatorOpts { ignoreUndocumented?: boolean; securityHandlers?: SecurityHandlers; coerceTypes?: boolean | 'array'; + useRequestUrl?: boolean; /** * @deprecated * Use `formats` + `validateFormats` to ignore specified formats diff --git a/src/middlewares/openapi.metadata.ts b/src/middlewares/openapi.metadata.ts index 85f2e9c1..2dae08a2 100644 --- a/src/middlewares/openapi.metadata.ts +++ b/src/middlewares/openapi.metadata.ts @@ -25,12 +25,12 @@ export function applyOpenApiMetadata( if (openApiContext.shouldIgnoreRoute(path)) { return next(); } - const matched = lookupRoute(req); + const matched = lookupRoute(req, openApiContext.useRequestUrl); if (matched) { const { expressRoute, openApiRoute, pathParams, schema } = matched; if (!schema) { // Prevents validation for routes which match on path but mismatch on method - if(openApiContext.ignoreUndocumented) { + if (openApiContext.ignoreUndocumented) { return next(); } throw new MethodNotAllowed({ @@ -54,7 +54,10 @@ export function applyOpenApiMetadata( // add the response schema if validating responses (req.openapi)._responseSchema = (matched)._responseSchema; } - } else if (openApiContext.isManagedRoute(path) && !openApiContext.ignoreUndocumented) { + } else if ( + openApiContext.isManagedRoute(path) && + !openApiContext.ignoreUndocumented + ) { throw new NotFound({ path: req.path, message: 'not found', @@ -63,8 +66,11 @@ export function applyOpenApiMetadata( next(); }; - function lookupRoute(req: OpenApiRequest): OpenApiRequestMetadata { - const path = req.originalUrl.split('?')[0]; + function lookupRoute( + req: OpenApiRequest, + useRequestUrl: boolean, + ): OpenApiRequestMetadata { + const path = useRequestUrl ? req.url : req.originalUrl.split('?')[0]; const method = req.method; const routeEntries = Object.entries(openApiContext.expressRouteMap); for (const [expressRoute, methods] of routeEntries) { diff --git a/src/openapi.validator.ts b/src/openapi.validator.ts index a578ef5c..ac651894 100644 --- a/src/openapi.validator.ts +++ b/src/openapi.validator.ts @@ -49,6 +49,7 @@ export class OpenApiValidator { if (options.$refParser == null) options.$refParser = { mode: 'bundle' }; if (options.validateFormats == null) options.validateFormats = true; if (options.formats == null) options.formats = {}; + if (options.useRequestUrl == null) options.useRequestUrl = false; if (typeof options.operationHandlers === 'string') { /** @@ -103,7 +104,12 @@ export class OpenApiValidator { resOpts, ).preProcess(); return { - context: new OpenApiContext(spec, this.options.ignorePaths, this.options.ignoreUndocumented), + context: new OpenApiContext( + spec, + this.options.ignorePaths, + this.options.ignoreUndocumented, + this.options.useRequestUrl, + ), responseApiDoc: sp.apiDocRes, error: null, }; @@ -201,7 +207,7 @@ export class OpenApiValidator { return resmw(req, res, next); }) .catch(next); - }) + }); } // op handler middleware diff --git a/test/user-request-url.router.spec.ts b/test/user-request-url.router.spec.ts new file mode 100644 index 00000000..4bba5dee --- /dev/null +++ b/test/user-request-url.router.spec.ts @@ -0,0 +1,272 @@ +import { expect } from 'chai'; +import type { + Express, + IRouter, + Response, + NextFunction, + Request, +} from 'express'; +import * as express from 'express'; +import { OpenAPIV3 } from '../src/framework/types'; +import * as request from 'supertest'; +import { createApp } from './common/app'; + +import * as OpenApiValidator from '../src'; +import { Server } from 'http'; + +interface HTTPError extends Error { + status: number; + text: string; + method: string; + path: string; +} + +describe('when useRequestUrl is set to "true" on the child router', async () => { + let app: Express & { server?: Server }; + + before(async () => { + const router = makeRouter({ useRequestUrl: true }); + app = await makeMainApp(); + app.use(router); + }); + + after(() => app?.server?.close()); + + it('should apply parent app schema to requests', async () => { + const result = await request(app).get('/api/pets/1'); + const error = result.error as HTTPError; + expect(result.statusCode).to.equal(400); + expect(error.path).to.equal('/api/pets/1'); + expect(error.text).to.contain( + 'Bad Request: request/params/petId must NOT have fewer than 3 characters', + ); + }); + + it('should apply child router schema to requests', async () => { + const result = await request(app).get('/api/pets/not-uuid'); + const error = result.error as HTTPError; + expect(result.statusCode).to.equal(400); + expect(error.path).to.equal('/api/pets/not-uuid'); + expect(error.text).to.contain( + 'Bad Request: request/params/petId must match format "uuid"', + ); + }); + + it('should return a reponse if request is valid', async () => { + const validId = 'f627f309-cae3-46d2-84f7-d03856c84b02'; + const result = await request(app).get(`/api/pets/${validId}`); + expect(result.statusCode).to.equal(200); + expect(result.body).to.deep.equal({ + id: 'f627f309-cae3-46d2-84f7-d03856c84b02', + name: 'Mr Sparky', + tag: "Ain't nobody tags me", + }); + }); +}); + +describe('when useRequestUrl is set to "false" on the child router', async () => { + let app: Express & { server?: Server }; + + before(async () => { + const router = makeRouter({ useRequestUrl: false }); + app = await makeMainApp(); + app.use(router); + }); + + after(() => app?.server?.close()); + + it('should throw not found', async () => { + const result = await request(app).get('/api/pets/valid-pet-id'); + const error = result.error as HTTPError; + expect(result.statusCode).to.equal(404); + expect(error.path).to.equal('/api/pets/valid-pet-id'); + expect(error.text).to.contain('Not Found'); + }); +}); + +function defaultResponse(): OpenAPIV3.ResponseObject { + return { + description: 'unexpected error', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['code', 'message'], + properties: { + code: { + type: 'integer', + format: 'int32', + }, + message: { + type: 'string', + }, + }, + }, + }, + }, + }; +} + +/* + represents spec of the "public" entrypoint to our application e.g gateway. The + type of id in path and id in the response here defined as simple string + with minLength + */ +const gatewaySpec: OpenAPIV3.Document = { + openapi: '3.0.0', + info: { version: '1.0.0', title: 'test bug OpenApiValidator' }, + servers: [{ url: 'http://localhost:3000/api' }], + paths: { + '/pets/{petId}': { + get: { + summary: 'Info for a specific pet', + operationId: 'showPetById', + tags: ['pets'], + parameters: [ + { + name: 'petId', + in: 'path', + required: true, + description: 'The id of the pet to retrieve', + schema: { + type: 'string', + minLength: 3, + }, + }, + ], + responses: { + '200': { + description: 'Expected response to a valid request', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['id', 'name'], + properties: { + id: { + type: 'string', + }, + name: { + type: 'string', + }, + tag: { + type: 'string', + }, + }, + }, + }, + }, + }, + default: defaultResponse(), + }, + }, + }, + }, +}; + +/* + represents spec of the child router. We route request from main app (gateway) to this router. + This router has its own schema, routes and validation formats. In particular, we force id in the path and id in the response to be uuid. + */ +const childRouterSpec: OpenAPIV3.Document = { + openapi: '3.0.0', + info: { version: '1.0.0', title: 'test bug OpenApiValidator' }, + servers: [{ url: 'http://localhost:3000/' }], + paths: { + '/internal/api/pets/{petId}': { + get: { + summary: 'Info for a specific pet', + operationId: 'showPetById', + tags: ['pets'], + parameters: [ + { + name: 'petId', + in: 'path', + required: true, + description: 'The id of the pet to retrieve', + schema: { + type: 'string', + format: 'uuid', + }, + }, + ], + responses: { + '200': { + description: 'Expected response to a valid request', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['id', 'name'], + properties: { + id: { + type: 'string', + format: 'uuid', + }, + name: { + type: 'string', + }, + tag: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}; + +function redirectToInternalService( + req: Request, + _res: Response, + next: NextFunction, +): void { + req.url = `/internal${req.originalUrl}`; + next(); +} + +function makeMainApp(): ReturnType { + return createApp( + { + apiSpec: gatewaySpec, + validateResponses: true, + validateRequests: true, + }, + 3000, + (app) => { + app + .get( + '/api/pets/:petId', + function (_req: Request, _res: Response, next: NextFunction) { + next(); + }, + ) + .use(redirectToInternalService); + }, + false, + ); +} + +function makeRouter({ useRequestUrl }: { useRequestUrl: boolean }): IRouter { + return express + .Router() + .use( + OpenApiValidator.middleware({ + apiSpec: childRouterSpec, + validateRequests: true, + validateResponses: true, + useRequestUrl, + }), + ) + .get('/internal/api/pets/:petId', function (req, res) { + res.json({ + id: req.params.petId, + name: 'Mr Sparky', + tag: "Ain't nobody tags me", + }); + }); +}