Repository: glennjones/hapi-swagger
Pull request #645 (2020-01-20 08:25:15)

Add routesBasePath option so that routes can listen in different paths than swaggerUIPath
Hello, everyone.

This PR responds to a problem I've found recently when using hapi-swagger in some production environments:

At some clients I've worked at, it's not unusual to have a main domain and then have a bunch of APIs behind it in different paths /api1, /api2...

The problem that hapi-swagger has with this kind of setup is that the moment you configure the swaggerUIPath as /api1, the frontend assets point to the correct place, but the routes are created as /api1/ASSET, which, in reality makes the asset routes to be exposed in /api1/api1/ASSET.

This PR adds a new option, routesBasePath, which allows for splitting the routes and swaggerUI paths for avoiding this problem.

To avoid breaking existing behaviour due to adding something that seems is not an urgent need for everyone (I assume if that was the case it would have already be a feature), routesBasePath will take the value of swaggerUIPath when not defined.

A quick summary of the logic:

-   swaggerUIPath is not defined, routesBasePath is not defined
    -   Both take default value `/swaggerui/`
    -   Endpoints base path is default value
    -   Assets base path points to default value
    -   Preserves current behaviour
-   swaggerUIPath is defined, routesBasePath is not defined
    -   routesBasePath takes swaggerUIPath value
    -   Endpoints base path is swaggerUIPath value
    -   Assets base path points to swaggerUIPath value
    -   Preserves current behaviour
-   swaggerUIPath is not defined, routesBasePath is defined
    -   Endpoints base path is routesBasePath value
    -   Assets base path points to default value
    -   New behaviour
-   swaggerUIPath is defined, routesBasePath is defined
    -   Endpoints base path is routesBasePath value
    -   Assets base path points to swaggerUIPath value
    -   New behaviour

And that's it.


Patch:
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..38f11c6
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,1 @@
+registry=https://registry.npmjs.org
diff --git a/index.d.ts b/index.d.ts
index a73f67f..47d74f1 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -171,11 +171,17 @@ declare namespace hapiswagger {
     cors?: boolean;
 
     /**
-     * The path of JSON endpoint at describes the API
+     * The path of JSON endpoint that describes the API
      * @default '/swagger.json'
      */
     jsonPath?: string;
 
+    /**
+     * The path for the controller that serves the JSON that describes the API.If jsonPath is specified and this parameter is not, it will take jsonPath value.Useful when behind a reverse proxy
+     * @default '/swagger.json'
+     */
+    jsonRoutePath?: string;
+
     /**
      * The base path from where the API starts i.e. `/v2/` (note, needs to start with `/`)
      * @default '/'
@@ -310,6 +316,13 @@ declare namespace hapiswagger {
      */
     swaggerUIPath?: string;
 
+    /**
+     * The path to all the SwaggerUI assets endpoints.
+     * If swaggerUIPath is specified and this parameter is not, it will take swaggerUIPath value. Useful when behind a reverse proxy
+     * @default: '/swaggerui/'
+     */
+    routesBasePath?: string;
+
     /**
      * Add documentation page
      * @default true
diff --git a/lib/builder.js b/lib/builder.js
index 064374f..b4ce59c 100644
--- a/lib/builder.js
+++ b/lib/builder.js
@@ -20,7 +20,7 @@ builder.default = {
   swagger: '2.0',
   host: 'localhost',
   basePath: '/',
-  routeTag: 'api',
+  routeTag: 'api'
 };
 
 /**
@@ -221,8 +221,10 @@ internals.removeNoneSchemaOptions = function(options) {
     'documentationRouteTags',
     'documentationPage',
     'jsonPath',
+    'jsonRoutePath',
     'auth',
     'swaggerUIPath',
+    'routesBasePath',
     'swaggerUI',
     'pathPrefixSize',
     'payloadType',
@@ -243,7 +245,7 @@ internals.removeNoneSchemaOptions = function(options) {
     'pathReplacements',
     'log',
     'cors',
-    'routeTag',
+    'routeTag'
   ].forEach(element => {
     delete out[element];
   });
diff --git a/lib/defaults.js b/lib/defaults.js
index ee7e67b..b8d0ca3 100644
--- a/lib/defaults.js
+++ b/lib/defaults.js
@@ -3,11 +3,13 @@ const { resolve } = require('path');
 module.exports = {
   debug: false,
   jsonPath: '/swagger.json',
+  jsonRoutePath: '/swagger.json',
   documentationPath: '/documentation',
   documentationRouteTags: [],
   documentationRoutePlugins: {},
   templates: resolve(__dirname, '..', 'templates'),
   swaggerUIPath: '/swaggerui/',
+  routesBasePath: '/swaggerui/',
   auth: false,
   pathPrefixSize: 1,
   payloadType: 'json',
diff --git a/lib/index.js b/lib/index.js
index 3abb8a9..7ce847f 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -15,11 +15,13 @@ const Utilities = require('../lib/utilities');
 const schema = Joi.object({
   debug: Joi.boolean(),
   jsonPath: Joi.string(),
+  jsonRoutePath: Joi.string(),
   documentationPath: Joi.string(),
   documentationRouteTags: Joi.alternatives().try(Joi.string(), Joi.array().items(Joi.string())),
   documentationRoutePlugins: Joi.object().default({}),
   templates: Joi.string(),
   swaggerUIPath: Joi.string(),
+  routesBasePath: Joi.string(),
   auth: Joi.alternatives().try(Joi.boolean(), Joi.string(), Joi.object()),
   pathPrefixSize: Joi.number()
     .integer()
@@ -46,7 +48,7 @@ const schema = Joi.object({
       replacement: Joi.string().allow('')
     })
   ),
-  routeTag: Joi.string(),
+  routeTag: Joi.string()
 }).unknown();
 
 /**
@@ -65,6 +67,15 @@ exports.plugin = {
     let settings = Hoek.applyToDefaults(Defaults, options, { nullOverride: true });
     const publicDirPath = Path.resolve(__dirname, '..', 'public');
 
+    // avoid breaking behaviour with previous version
+    if (!options.routesBasePath && options.swaggerUIPath) {
+      settings.routesBasePath = options.swaggerUIPath;
+    }
+
+    if (!options.jsonRoutePath && options.jsonPath) {
+      settings.jsonRoutePath = options.jsonPath;
+    }
+
     settings.log = (tags, data) => {
       tags.unshift('hapi-swagger');
       if (settings.debug) {
@@ -88,7 +99,7 @@ exports.plugin = {
       // use the catbox getDecoratedValue option.
       const options = {
         cache: settings.cache,
-        generateKey: (settings, request) => 'hapi-swagger-' + request.path,
+        generateKey: (settings, request) => 'hapi-swagger-' + request.path
       };
       server.method('getSwaggerJSON', getSwaggerJSON, options);
     }
@@ -97,7 +108,7 @@ exports.plugin = {
     server.route([
       {
         method: 'GET',
-        path: settings.jsonPath,
+        path: settings.jsonRoutePath,
         options: {
           auth: settings.auth,
           cors: settings.cors,
@@ -170,7 +181,7 @@ exports.plugin = {
           filesToServe.forEach(filename => {
             server.route({
               method: 'GET',
-              path: `${settings.swaggerUIPath}${filename}`,
+              path: `${settings.routesBasePath}${filename}`,
               options: {
                 auth: settings.auth,
                 tags: settings.documentationRouteTags,
@@ -186,7 +197,7 @@ exports.plugin = {
 
           server.route({
             method: 'GET',
-            path: settings.swaggerUIPath + 'extend.js',
+            path: settings.routesBasePath + 'extend.js',
             options: {
               tags: settings.documentationRouteTags,
               auth: settings.auth,
diff --git a/optionsreference.md b/optionsreference.md
index 3603f8a..d342048 100644
--- a/optionsreference.md
+++ b/optionsreference.md
@@ -11,7 +11,8 @@
 
 #### JSON (JSON endpoint needed to create UI)
 
--   `jsonPath`: (string) The path of JSON endpoint at describes the API - default: `/swagger.json`
+-   `jsonPath`: (string) The path of the JSON endpoint that describes the API - default: `/swagger.json`
+-   `jsonRoutePath`: (string) The path for the controller that serves the JSON that describes the API. If jsonPath is specified and this parameter is not, it will take jsonPath value. Useful when behind a reverse proxy - default: `/swagger.json`
 -   `basePath`: (string) The base path from where the API starts i.e. `/v2/` (note, needs to start with `/`) - default: `/`
 -   `pathPrefixSize`: (number) Selects what segment of the URL path is used to group endpoints - default: `1`
 -   `pathReplacements` : (array) methods for modifying path and group names in documentation - default: `[]`
@@ -52,6 +53,7 @@
 
 -   `swaggerUI`: (boolean) Add files that support SwaggerUI. Only removes files if `documentationPage` is also set to false - default: `true`
 -   `swaggerUIPath`: (string) The path of to all the SwaggerUI resources - default: `/swaggerui/`
+-   `routesBasePath`: (string) The path to all the SwaggerUI assets endpoints. If swaggerUIPath is specified and this parameter is not, it will take swaggerUIPath value. Useful when behind a reverse proxy - default: `/swaggerui/`
 -   `documentationPage`: (boolean) Add documentation page - default: `true`
 -   `documentationPath`: (string) The path of the documentation page - default: `/documentation`
 -   `templates`: (string) The directory path used by `hapi-swagger` and `@hapi/vision` to resolve and load the templates to render `swagger-ui` interface. The directory must contain `index.html` and `debug.html` templates. Default is `templates` directory in this package.


Test patch:
diff --git a/test/Integration/plugin-test.js b/test/Integration/plugin-test.js
index e44b0ac..8181393 100644
--- a/test/Integration/plugin-test.js
+++ b/test/Integration/plugin-test.js
@@ -109,12 +109,95 @@ lab.experiment('plugin', () => {
     expect(response.statusCode).to.equal(200);
   });
 
+  lab.test('repathed jsonPath url and jsonRoutePath', async () => {
+    const jsonRoutePath = '/testRoute/test.json';
+
+    const server = await Helper.createServer(
+      {
+        ...swaggerOptions,
+        jsonRoutePath
+      },
+      routes
+    );
+
+    const notFoundResponse = await server.inject({ method: 'GET', url: '/test.json' });
+
+    expect(notFoundResponse.statusCode).to.equal(404);
+
+    const okResponse = await server.inject({ method: 'GET', url: jsonRoutePath });
+
+    expect(okResponse.statusCode).to.equal(200);
+  });
+
   lab.test('repathed documentationPath url', async () => {
     const server = await Helper.createServer(swaggerOptions, routes);
     const response = await server.inject({ method: 'GET', url: '/testdoc' });
     expect(response.statusCode).to.equal(200);
   });
 
+  lab.test('repathed assets url', async () => {
+    const server = await Helper.createServer(swaggerOptions, routes);
+    const response = await server.inject({ method: 'GET', url: '/testdoc' });
+
+    expect(response.statusCode).to.equal(200);
+
+    const assets = Helper.getAssetsPaths(response.result);
+
+    assets.forEach(asset => {
+      expect(asset).to.contain(swaggerOptions.swaggerUIPath);
+    });
+
+    const responses = await Promise.all(
+      assets.map(asset => {
+        return server.inject({ method: 'GET', url: asset });
+      })
+    );
+
+    responses.forEach(assetResponse => {
+      expect(assetResponse.statusCode).to.equal(200);
+    });
+  });
+
+  lab.test('repathed assets url and reoutes path', async () => {
+    const routesBasePath = '/testRoute/';
+    const server = await Helper.createServer(
+      {
+        ...swaggerOptions,
+        routesBasePath
+      },
+      routes
+    );
+    const response = await server.inject({ method: 'GET', url: '/testdoc' });
+
+    expect(response.statusCode).to.equal(200);
+
+    const assets = Helper.getAssetsPaths(response.result);
+
+    assets.forEach(asset => {
+      expect(asset).to.contain(swaggerOptions.swaggerUIPath);
+    });
+
+    const notFoundResponses = await Promise.all(
+      assets.map(asset => {
+        return server.inject({ method: 'GET', url: asset });
+      })
+    );
+
+    notFoundResponses.forEach(assetResponse => {
+      expect(assetResponse.statusCode).to.equal(404);
+    });
+
+    const okResponses = await Promise.all(
+      assets.map(asset => {
+        return server.inject({ method: 'GET', url: asset.replace(swaggerOptions.swaggerUIPath, routesBasePath) });
+      })
+    );
+
+    okResponses.forEach(assetResponse => {
+      expect(assetResponse.statusCode).to.equal(200);
+    });
+  });
+
   lab.test('disable documentation path', async () => {
     const swaggerOptions = {
       documentationPage: false
@@ -386,7 +469,7 @@ lab.experiment('plugin', () => {
     const response = await server.inject({ method: 'GET', url: '/swagger.json' });
     //console.log(JSON.stringify(response.result));
     expect(response.result.definitions).to.equal({
-      'Model1': {
+      Model1: {
         type: 'object',
         properties: {
           a: {
@@ -401,7 +484,6 @@ lab.experiment('plugin', () => {
   });
 });
 
-
 lab.experiment('multiple plugins', () => {
   const routes = [
     {
@@ -442,14 +524,14 @@ lab.experiment('multiple plugins', () => {
     let swaggerOptions1 = {
       routeTag: 'store-api',
       info: {
-        description: 'This is the store API docs',
-      },
+        description: 'This is the store API docs'
+      }
     };
     let swaggerOptions2 = {
       routeTag: 'shop-api',
       info: {
-        description: 'This is the shop API docs',
-      },
+        description: 'This is the shop API docs'
+      }
     };
     const server = await Helper.createServerMultiple(swaggerOptions1, swaggerOptions2, routes);
 
@@ -467,5 +549,4 @@ lab.experiment('multiple plugins', () => {
     expect(response2.result.info.description).to.equal('This is the shop API docs');
     expect(response2.result.paths['/shop/'].post.operationId).to.equal('postShop');
   });
-
 });
diff --git a/test/helper.js b/test/helper.js
index a0852d5..3425500 100644
--- a/test/helper.js
+++ b/test/helper.js
@@ -47,27 +47,27 @@ helper.createServer = async (swaggerOptions, routes, serverOptions = {}) => {
 helper.createServerMultiple = async (swaggerOptions1, swaggerOptions2, routes, serverOptions = {}) => {
   const server = new Hapi.Server(serverOptions);
 
-  await server.register([
-    Inert,
-    Vision,
-    H2o2,
-  ]);
+  await server.register([Inert, Vision, H2o2]);
 
-  await server.register({
-    plugin: HapiSwagger,
-    options: swaggerOptions1,
-  },
-  {
-    routes: { prefix: '/' + swaggerOptions1.routeTag || 'api1', }
-  });
+  await server.register(
+    {
+      plugin: HapiSwagger,
+      options: swaggerOptions1
+    },
+    {
+      routes: { prefix: '/' + swaggerOptions1.routeTag || 'api1' }
+    }
+  );
 
-  await server.register({
-    plugin: HapiSwagger,
-    options: swaggerOptions2,
-  },
-  {
-    routes: { prefix: '/' + swaggerOptions2.routeTag || 'api2', }
-  });
+  await server.register(
+    {
+      plugin: HapiSwagger,
+      options: swaggerOptions2
+    },
+    {
+      routes: { prefix: '/' + swaggerOptions2.routeTag || 'api2' }
+    }
+  );
 
   if (routes) {
     server.route(routes);
@@ -229,3 +229,25 @@ helper.objWithNoOwnProperty = () => {
   Triangle.prototype = sides;
   return new Triangle();
 };
+
+helper.getAssetsPaths = html => {
+  const linkTag = '<link';
+  const scriptTag = '<script src';
+
+  return html
+    .split('\n')
+    .filter(line => line.includes(linkTag) || line.includes(scriptTag))
+    .map(line => {
+      let firstSplit;
+
+      if (line.includes(linkTag)) {
+        [, firstSplit] = line.split('href="');
+      } else {
+        [, firstSplit] = line.split('src="');
+      }
+
+      const [assetPath] = firstSplit.split('"');
+
+      return assetPath;
+    });
+};