Repository: glennjones/hapi-swagger
Pull request #747 (2022-05-11 23:03:09)

Add option to filter routes added to documentation via function


Patch:
diff --git a/examples/group-ordered.js b/examples/group-ordered.js
index de12a9b..8a950e2 100644
--- a/examples/group-ordered.js
+++ b/examples/group-ordered.js
@@ -9,14 +9,14 @@ const Vision = require('@hapi/vision');
 const HapiSwagger = require('../');
 const Pack = require('../package');
 
+const handler = (request, reply) => { reply({ ok: true }) }
+
 const Routes = [
   {
     method: 'POST',
     path: '/petstore',
     options: {
-      handler: (request, reply) => {
-        reply({ ok: true });
-      },
+      handler,
       description: 'Array properties',
       tags: ['api', 'petstore'],
       plugins: {
@@ -30,9 +30,7 @@ const Routes = [
     method: 'GET',
     path: '/petstore',
     options: {
-      handler: (request, reply) => {
-        reply({ ok: true });
-      },
+      handler,
       description: 'Array properties',
       tags: ['api', 'petstore'],
       plugins: {
@@ -46,9 +44,7 @@ const Routes = [
     method: 'GET',
     path: '/petstore/{id}',
     options: {
-      handler: () => {
-        return { ok: true };
-      },
+      handler,
       description: 'Array properties',
       tags: ['api', 'petstore'],
       plugins: {
@@ -62,9 +58,7 @@ const Routes = [
     method: 'POST',
     path: '/petstore/{id}/pet',
     options: {
-      handler: () => {
-        return { ok: true };
-      },
+      handler,
       description: 'Array properties',
       tags: ['api', 'pet'],
       plugins: {
@@ -78,9 +72,7 @@ const Routes = [
     method: 'GET',
     path: '/pet/{id}',
     options: {
-      handler: () => {
-        return { ok: true };
-      },
+      handler,
       description: 'Array properties',
       tags: ['api', 'pet', 'extra'],
       plugins: {
@@ -94,9 +86,7 @@ const Routes = [
     method: 'GET',
     path: '/petstore/{id}/pet',
     options: {
-      handler: () => {
-        return { ok: true };
-      },
+      handler,
       description: 'Array properties',
       tags: ['api', 'pet'],
       plugins: {
diff --git a/examples/routes-filtering.js b/examples/routes-filtering.js
new file mode 100644
index 0000000..082c4fd
--- /dev/null
+++ b/examples/routes-filtering.js
@@ -0,0 +1,104 @@
+// `group.js` - how to use tag based grouping
+'use strict';
+
+const Blipp = require('blipp');
+const Hapi = require('@hapi/hapi');
+const Inert = require('@hapi/inert');
+const Vision = require('@hapi/vision');
+
+const HapiSwagger = require('../');
+const Pack = require('../package');
+
+const handler = (request, reply) => { reply({ ok: true }) }
+
+const Routes = [
+  {
+    method: 'POST',
+    path: '/petstore',
+    options: {
+      handler,
+      tags: ['api', 'petstore', 'auth'],
+    }
+  },
+  {
+    method: 'GET',
+    path: '/petstore',
+    options: {
+      handler,
+      tags: ['api', 'petstore'],
+    }
+  },
+  {
+    method: 'GET',
+    path: '/petstore/{id}',
+    options: {
+      handler,
+      tags: ['api', 'petstore']
+    }
+  },
+  {
+    method: 'POST',
+    path: '/petstore/{id}/pet',
+    options: {
+      handler,
+      tags: ['api', 'petstore', 'auth']
+    }
+  },
+  {
+    method: 'GET',
+    path: '/pet/{id}',
+    options: {
+      handler,
+      tags: ['api', 'pet', 'extra']
+    }
+  },
+  {
+    method: 'GET',
+    path: '/petstore/{id}/pet',
+    options: {
+      handler,
+      tags: ['api', 'pet']
+    }
+  }
+];
+
+const swaggerOptions = {
+  info: {
+    title: 'Test API Documentation',
+    version: Pack.version
+  },
+  routeTag: (tags) => tags.includes('pet') && !tags.includes('auth')
+};
+
+const ser = async () => {
+  const server = Hapi.Server({
+    host: 'localhost',
+    port: 3000
+  });
+
+  // Blipp and Good - Needs updating for Hapi v17.x
+  await server.register([
+    Inert,
+    Vision,
+    Blipp,
+    {
+      plugin: HapiSwagger,
+      options: swaggerOptions
+    }
+  ]);
+
+  server.route(Routes);
+
+  await server.start();
+
+  return server;
+};
+
+ser()
+  .then((server) => {
+    console.log(`Server listening on ${server.info.uri}`);
+  })
+  .catch((err) => {
+    console.error(err);
+    process.exit(1);
+  });
diff --git a/index.d.ts b/index.d.ts
index fef5501..4e8506e 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -272,6 +272,16 @@ declare namespace hapiswagger {
      */
     tags?: TagOptions[];
 
+    /** Allows filtering the routes included into Swagger docs by tag or function
+     * @default 'api'
+     *
+     * @example
+     * ```
+     * (tags) => !tags.includes('private')
+     * ```
+     */
+    routeTag?: string | ((tags: string[]) => boolean)
+
     /**
      * How to create grouping of endpoints value either `path` or `tags`
      * @default 'path'
@@ -361,6 +371,7 @@ declare namespace hapiswagger {
      */
     debug?: boolean;
 
+    // TODO: remove below line after adding all option typings to this file
     /**
      * Any property or object with a key starting with 'x-*' is included as such in the `info` section of the object returned by the JSON endpoint. This allows custom properties to be defined as options and copied as such.
      */
diff --git a/lib/builder.js b/lib/builder.js
index dc6b310..3f30265 100644
--- a/lib/builder.js
+++ b/lib/builder.js
@@ -88,15 +88,20 @@ builder.getSwaggerJSON = async (settings, request) => {
 
   let routes = request.server.table();
 
-  routes = Filter.byTags([settings.routeTag], routes);
-  Sort.paths(settings.sortPaths, routes);
-
   // filter routes displayed based on tags passed in query string
   if (request.query.tags) {
-    const filterTags = request.query.tags.split(',');
-    routes = Filter.byTags(filterTags, routes);
+    const queryTags = request.query.tags.split(',');
+    routes = Filter.byTags(queryTags, routes);
+  }
+
+  if (typeof settings.routeTag === 'function') {
+    routes = Filter.byFunction(settings.routeTag, routes);
+  } else {
+    routes = Filter.byTags([settings.routeTag], routes);
   }
 
+  Sort.paths(settings.sortPaths, routes);
+
   // append group property - by path
   Group.appendGroupByPath(settings.pathPrefixSize, settings.basePath, routes, settings.pathReplacements);
 
@@ -117,9 +122,9 @@ builder.getSwaggerJSON = async (settings, request) => {
   if (settings.deReference === true) {
     return builder.dereference(out);
   }
- 
+
     return out;
-  
+
 };
 
 /**
diff --git a/lib/filter.js b/lib/filter.js
index 3993381..13ef7cc 100644
--- a/lib/filter.js
+++ b/lib/filter.js
@@ -19,45 +19,58 @@ const filter = (module.exports = {});
 /**
  * filters routes based on tags
  *
- * @param  {Array} tags
+ * @param  {Array<String>} tags
  * @param  {Array} routes
  * @return {Array}
  */
 filter.byTags = function (tags, routes) {
-  let tag;
-
   return routes.filter((route) => {
-    let exit;
+    if (!route.settings.tags) {
+      return false;
+    }
+
+    const noPrefixTags = [];
+
     for (let i = 0; i < tags.length; ++i) {
+      let tag;
+
       switch (tags[i].substring(0, 1)) {
         case '-': // exclude tags that match this case
-          tag = tags[i].substring(1, tags[i].length);
-          if (Hoek.intersect(route.settings.tags, [tag]).length > 0) {
-            exit = true;
+          tag = tags[i].substring(1);
+          if (route.settings.tags.includes(tag)) {
+            return false;
           }
 
           break;
-        case '+': // (+) filter out tagged paths that do not have this tag!
-          tag = tags[i].substring(1, tags[i].length);
-          if (Hoek.intersect(route.settings.tags, [tag]).length === 0) {
-            exit = true;
+        case '+': // (+) filter out tagged paths that do not have this tag
+          tag = tags[i].substring(1);
+          if (!route.settings.tags.includes(tag)) {
+            return false;
           }
 
           break;
+        default: // no prefix
+          noPrefixTags.push(tags[i]);
       }
     }
 
-    // if we have reason to exit, then do so!
-    if (exit === true) {
-      return false;
-    }
+    return Hoek.intersect(route.settings.tags, noPrefixTags).length > 0;
+  });
+};
 
-    // default behavior for tags is additive
-    if (Hoek.intersect(route.settings.tags, tags).length > 0) {
-      return true;
+/**
+ * filters routes based on function
+ *
+ * @param  {Function} filterFn
+ * @param  {Array} routes
+ * @return {Array}
+ */
+filter.byFunction = function (filterFn, routes) {
+  return routes.filter((route) => {
+    if (route.path === '/documentation' || route.path.startsWith('/swagger')) {
+      return false;
     }
 
-    // fallback or no tag defined
-    return false;
+    return filterFn(route.settings.tags || []);
   });
 };
diff --git a/lib/index.js b/lib/index.js
index f76d80d..0a495cd 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -60,7 +60,7 @@ const schema = Joi.object({
       replacement: Joi.string().allow('')
     })
   ),
-  routeTag: Joi.string(),
+  routeTag: Joi.alternatives(Joi.string(), Joi.function()),
   // validate as declared in @hapi/hapi
   // https://github.com/hapijs/hapi/blob/5c0850989f2b7270fe7a7b6a7d4ebdc9a7fecd79/lib/config.js#L220
   validate: Joi.object({
@@ -170,10 +170,10 @@ exports.plugin = {
               const lastModified = cached ? new Date(cached.stored) : new Date();
               return h.response(value).header('last-modified', lastModified.toUTCString());
             }
- 
+
               const json = await Builder.getSwaggerJSON(settings, request);
               return json;
-            
+
           },
           plugins: {
             'hapi-swagger': false
diff --git a/optionsreference.md b/optionsreference.md
index abb5520..5f95122 100644
--- a/optionsreference.md
+++ b/optionsreference.md
@@ -31,7 +31,7 @@
         -   `url` (string) The URL to the license used by the API. MUST be formatted as a URL
     -   `x-*` (any): any property or object with a key starting with `x-*` is included as such in the `info` section of the object returned by the JSON endpoint. This allows custom properties to be defined as options and copied as such.
 -   `tags`: (array) containing array of [Tag Object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#tagObject) used to group endpoints in UI. No defaults are provided.
--   `routeTag`: (string) Tag used by `hapi-swagger` to collect all the route definitions that should be included in the OpenAPI swagger defintion - default: `api`
+-   `routeTag`: (string | function) Tag used by `hapi-swagger` to collect the routes that should be included in the OpenAPI swagger definition. Can also take a function receiving route tags and returning boolean, for example `(tags) => !tags.includes('private')` - default: `api`
 -   `grouping`: (string) how to create grouping of endpoints value either `path` or `tags` - default: `path`
 -   `tagsGroupingFilter`: (function) A function used to determine which tags should be used for grouping (when `grouping` is set to `tags`) - default: `(tag) => tag !== 'api'`
 -   `securityDefinitions:`: (object) Containing [Security Definitions Object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#securityDefinitionsObject). No defaults are provided.


Test patch:
diff --git a/test/unit/filter-test.js b/test/unit/filter-test.js
new file mode 100644
index 0000000..751f8be
--- /dev/null
+++ b/test/unit/filter-test.js
@@ -0,0 +1,129 @@
+const Code = require('@hapi/code');
+const Lab = require('@hapi/lab');
+const Helper = require('../helper.js');
+
+const expect = Code.expect;
+const lab = (exports.lab = Lab.script());
+
+lab.experiment('filter', () => {
+  const routes = [
+    {
+      method: 'GET',
+      path: '/no-tags',
+      options: {
+        handler: Helper.defaultHandler
+      }
+    },
+    {
+      method: 'GET',
+      path: '/api-alpha-tag',
+      options: {
+        tags: ['api', 'alpha'],
+        handler: Helper.defaultHandler
+      }
+    },
+    {
+      method: 'GET',
+      path: '/api-beta-tag',
+      options: {
+        tags: ['api', 'beta'],
+        handler: Helper.defaultHandler
+      }
+    },
+    {
+      method: 'GET',
+      path: '/beta-tag',
+      options: {
+        tags: ['beta'],
+        handler: Helper.defaultHandler
+      }
+    },
+    {
+      method: 'GET',
+      path: '/api-alpha-gamma-tag',
+      options: {
+        tags: ['api', 'alpha', 'gamma'],
+        handler: Helper.defaultHandler
+      }
+    }
+  ];
+
+  lab.test('by `api` tag by default', async () => {
+    const server = await Helper.createServer({}, routes);
+    const response = await server.inject({ method: 'GET', url: '/swagger.json' });
+    expect(Object.keys(response.result.paths).length).to.equal(3);
+    expect(response.result.paths['/api-beta-tag']).to.exist();
+    expect(response.result.paths['/api-alpha-tag']).to.exist();
+    expect(response.result.paths['/api-alpha-gamma-tag']).to.exist();
+  });
+
+  lab.test('Query string params (alpha OR gamma), then by `routeTag` (api)', async () => {
+    const server = await Helper.createServer({}, routes);
+    const response = await server.inject({ method: 'GET', url: '/swagger.json?tags=alpha,gamma' });
+    expect(Object.keys(response.result.paths).length).to.equal(2);
+    expect(response.result.paths['/api-alpha-tag']).to.exist();
+    expect(response.result.paths['/api-alpha-gamma-tag']).to.exist();
+  });
+
+  lab.test('Query string params (beta), then by `routeTag` (function)', async () => {
+    const server = await Helper.createServer({ routeTag: (tags) => !tags.includes('api') }, routes);
+    const response = await server.inject({ method: 'GET', url: '/swagger.json?tags=beta' });
+    expect(Object.keys(response.result.paths).length).to.equal(1);
+    expect(response.result.paths['/beta-tag']).to.exist();
+  });
+
+  lab.test('Query string params (alpha OR beta) AND api, then by `routeTag` (api)', async () => {
+    const server = await Helper.createServer({}, routes);
+    const response = await server.inject({
+      method: 'GET',
+      url: `/swagger.json?tags=alpha,beta,${encodeURIComponent('+')}api`
+    });
+    expect(Object.keys(response.result.paths).length).to.equal(3);
+    expect(response.result.paths['/api-alpha-tag']).to.exist();
+    expect(response.result.paths['/api-beta-tag']).to.exist();
+    expect(response.result.paths['/api-alpha-gamma-tag']).to.exist();
+  });
+
+  lab.test('Query string params (alpha AND gamma), then by `routeTag` (api)', async () => {
+    const server = await Helper.createServer({}, routes);
+    const response = await server.inject({
+      method: 'GET',
+      url: `/swagger.json?tags=alpha,${encodeURIComponent('+')}gamma`
+    });
+    expect(Object.keys(response.result.paths).length).to.equal(1);
+    expect(response.result.paths['/api-alpha-gamma-tag']).to.exist();
+  });
+
+  lab.test('Query string params (alpha AND NOT gamma), then by `routeTag` (api)', async () => {
+    const server = await Helper.createServer({}, routes);
+    const response = await server.inject({ method: 'GET', url: '/swagger.json?tags=alpha,-gamma' });
+    expect(Object.keys(response.result.paths).length).to.equal(1);
+    expect(response.result.paths['/api-alpha-tag']).to.exist();
+  });
+
+  lab.test('by `routeTag` string if set', async () => {
+    const server = await Helper.createServer({ routeTag: 'beta' }, routes);
+    const response = await server.inject({ method: 'GET', url: '/swagger.json' });
+    expect(Object.keys(response.result.paths).length).to.equal(2);
+    expect(response.result.paths['/api-beta-tag']).to.exist();
+    expect(response.result.paths['/beta-tag']).to.exist();
+  });
+
+  lab.test('by `routeTag` function if set', async () => {
+    const server = await Helper.createServer(
+      { routeTag: (tags) => tags.includes('api') && tags.includes('beta') },
+      routes
+    );
+    const response = await server.inject({ method: 'GET', url: '/swagger.json' });
+    expect(response.statusCode).to.equal(200);
+    expect(Object.keys(response.result.paths).length).to.equal(1);
+    expect(response.result.paths['/api-beta-tag']).to.exist();
+  });
+
+  lab.test('picks all routes if `routeTag` is set to () => true', async () => {
+    const server = await Helper.createServer({ routeTag: () => true }, routes);
+    const response = await server.inject({ method: 'GET', url: '/swagger.json' });
+    expect(response.statusCode).to.equal(200);
+    expect(Object.keys(response.result.paths).length).to.equal(routes.length);
+  });
+});