Repository: cdimascio/express-openapi-validator
Pull request #857 (2023-08-14 20:15:18)

Allow optional use of `req.url`
Solves https://github.com/cdimascio/express-openapi-validator/issues/843 and https://github.com/cdimascio/express-openapi-validator/issues/113

@cdimascio I could not find where to update the docs wiki, but am happy to do so if you point me in the right direction.

cdimascio: Thanks for the change. I will review in next few days. Good question on the wiki....

Since migrating the doc to wiki, I do not currently have a good mechanism for updates.

Open to suggestions. I found this on SO https://stackoverflow.com/questions/10642928/how-can-i-make-a-pull-request-for-a-wiki-page-on-github

Perhaps post the doc content in the comments here and I will manually integrate into the wiki upon merge
sennyeya: @nikkegg Thanks for picking this up! Excited to use this!

Patch:
diff --git a/src/framework/openapi.context.ts b/src/framework/openapi.context.ts
index 254ed37..2df421f 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 6f7c58a..201e6fb 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 85f2e9c..2dae08a 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
         (<any>req.openapi)._responseSchema = (<any>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 a578ef5..ac65189 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


Test patch:
diff --git a/test/user-request-url.router.spec.ts b/test/user-request-url.router.spec.ts
new file mode 100644
index 0000000..4bba5de
--- /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 &quot;uuid&quot',
+    );
+  });
+
+  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<typeof createApp> {
+  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",
+      });
+    });
+}