From ff68299aa6c9d682dfa1504deb2e1f6bb661c94e Mon Sep 17 00:00:00 2001
From: Roberto Bianchi <roberto.bianchi@spendesk.com>
Date: Tue, 3 Dec 2024 15:46:41 +0100
Subject: [PATCH] feat: Add `indexPrefix` option (#189)

---
 README.md             |  27 +++----
 lib/index-html.js     |   7 +-
 test/prepare.test.js  |   4 +-
 test/route.test.js    | 161 +++++++++++++++++++++++++-----------------
 types/index.d.ts      |   4 ++
 types/types.test-d.ts |   3 +
 6 files changed, 125 insertions(+), 81 deletions(-)

diff --git a/README.md b/README.md
index aac0209..087a0e6 100644
--- a/README.md
+++ b/README.md
@@ -112,19 +112,20 @@ await fastify.ready()
 
 #### Options
 
- | Option             | Default          | Description                                                                                                               |
- | ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------- |
- | baseDir              | undefined        | Specify the directory where all spec files that are included in the main one using $ref will be located. By default, this is the directory where the main spec file is located. Provided value should be an absolute path without trailing slash.     |
- | initOAuth            | {}               | Configuration options for [Swagger UI initOAuth](https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/).     |
- | routePrefix          | '/documentation' | Overwrite the default Swagger UI route prefix.                                                                            |
- | staticCSP            | false            | Enable CSP header for static resources.                                                                                   |
- | transformStaticCSP   | undefined        | Synchronous function to transform CSP header for static resources if the header has been previously set.                  |
- | transformSpecification     | undefined        | Synchronous function to transform the swagger document.                                                                   |
- | transformSpecificationClone| true             | Provide a deepcloned swaggerObject to transformSpecification                                                                    |
- | uiConfig             | {}               | Configuration options for [Swagger UI](https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md).                                                                                                   |
- | uiHooks              | {}               | Additional hooks for the documentation's routes. You can provide the `onRequest` and `preHandler` hooks with the same [route's options](https://fastify.dev/docs/latest/Reference/Routes/#routes-options) interface.|
- | theme                | {}               | Add custom JavaScript and CSS to the Swagger UI web page |
- | logLevel             | info             | Allow to define route log level.                                                                                          |
+ | Option             | Default         | Description                                                                                                                                                                                                                                       |
+ | ------------------ | --------------- |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+ | baseDir              | undefined       | Specify the directory where all spec files that are included in the main one using $ref will be located. By default, this is the directory where the main spec file is located. Provided value should be an absolute path without trailing slash. |
+ | initOAuth            | {}              | Configuration options for [Swagger UI initOAuth](https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/).                                                                                                                             |
+ | routePrefix          | '/documentation' | Overwrite the default Swagger UI route prefix.                                                                                                                                                                                                    |
+ | indexPrefix          | '' | Add an additional prefix. This is for when the Fastify server is behind path based routing.  ex. NGINX                                                                                                                                            |
+ | staticCSP            | false           | Enable CSP header for static resources.                                                                                                                                                                                                           |
+ | transformStaticCSP   | undefined       | Synchronous function to transform CSP header for static resources if the header has been previously set.                                                                                                                                          |
+ | transformSpecification     | undefined       | Synchronous function to transform the swagger document.                                                                                                                                                                                           |
+ | transformSpecificationClone| true            | Provide a deepcloned swaggerObject to transformSpecification                                                                                                                                                                                      |
+ | uiConfig             | {}              | Configuration options for [Swagger UI](https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md).                                                                                                                        |
+ | uiHooks              | {}              | Additional hooks for the documentation's routes. You can provide the `onRequest` and `preHandler` hooks with the same [route's options](https://fastify.dev/docs/latest/Reference/Routes/#routes-options) interface.                              |
+ | theme                | {}              | Add custom JavaScript and CSS to the Swagger UI web page                                                                                                                                                                                          |
+ | logLevel             | info            | Allow to define route log level.                                                                                                                                                                                                                  |
 
 The plugin will expose the documentation with the following APIs:
 
diff --git a/lib/index-html.js b/lib/index-html.js
index 5484a76..baafc29 100644
--- a/lib/index-html.js
+++ b/lib/index-html.js
@@ -1,10 +1,13 @@
 'use strict'
 
 function indexHtml (opts) {
-  const hasLeadingSlash = /^\//.test(opts.prefix)
+  let routePrefix = opts.prefix
+  if (opts.indexPrefix) {
+    routePrefix = `${opts.indexPrefix.replace(/\/$/, '')}/${opts.prefix.replace(/^\//, '')}`
+  }
   return (url) => {
     const hasTrailingSlash = /\/$/.test(url)
-    const prefix = hasTrailingSlash ? `.${opts.staticPrefix}` : `${hasLeadingSlash ? '.' : ''}${opts.prefix}${opts.staticPrefix}`
+    const prefix = hasTrailingSlash ? `.${opts.staticPrefix}` : `${routePrefix}${opts.staticPrefix}`
     return `<!-- HTML for static distribution bundle build -->
       <!DOCTYPE html>
       <html lang="en">
diff --git a/test/prepare.test.js b/test/prepare.test.js
index 7af5eea..c4ed355 100644
--- a/test/prepare.test.js
+++ b/test/prepare.test.js
@@ -18,7 +18,7 @@ test('Swagger source does not contain sourceMaps', async (t) => {
 
   const includesSourceMap = res.payload.includes('sourceMappingURL')
   t.assert.deepStrictEqual(includesSourceMap, false)
-  t.assert.deepStrictEqual(res.headers['content-type'], 'application/javascript; charset=UTF-8')
+  t.assert.deepStrictEqual(res.headers['content-type'], 'application/javascript; charset=utf-8')
 })
 
 test('Swagger css does not contain sourceMaps', async (t) => {
@@ -34,5 +34,5 @@ test('Swagger css does not contain sourceMaps', async (t) => {
 
   const includesSourceMap = res.payload.includes('sourceMappingURL')
   t.assert.deepStrictEqual(includesSourceMap, false)
-  t.assert.deepStrictEqual(res.headers['content-type'], 'text/css; charset=UTF-8')
+  t.assert.deepStrictEqual(res.headers['content-type'], 'text/css; charset=utf-8')
 })
diff --git a/test/route.test.js b/test/route.test.js
index d3efbc8..69d4b1f 100644
--- a/test/route.test.js
+++ b/test/route.test.js
@@ -1,7 +1,7 @@
 'use strict'
 
-const t = require('node:test')
-const test = t.test
+const nodeTest = require('node:test')
+const test = nodeTest.test
 const Fastify = require('fastify')
 const Swagger = require('@apidevtools/swagger-parser')
 const yaml = require('yaml')
@@ -16,6 +16,7 @@ const {
 } = require('../examples/options')
 
 const resolve = require('node:path').resolve
+const join = require('node:path').join
 const readFileSync = require('node:fs').readFileSync
 
 const schemaParamsWithoutDesc = {
@@ -294,7 +295,7 @@ test('/documentation/static/:file should send back the correct file', async (t)
       url: '/documentation/static/'
     })
     t.assert.deepStrictEqual(typeof res.payload, 'string')
-    t.assert.deepStrictEqual(res.headers['content-type'], 'text/html; charset=UTF-8')
+    t.assert.deepStrictEqual(res.headers['content-type'], 'text/html; charset=utf-8')
     t.assert.deepStrictEqual(
       readFileSync(
         resolve(__dirname, '..', 'static', 'index.html'),
@@ -321,7 +322,7 @@ test('/documentation/static/:file should send back the correct file', async (t)
       url: '/documentation/static/oauth2-redirect.html'
     })
     t.assert.deepStrictEqual(typeof res.payload, 'string')
-    t.assert.deepStrictEqual(res.headers['content-type'], 'text/html; charset=UTF-8')
+    t.assert.deepStrictEqual(res.headers['content-type'], 'text/html; charset=utf-8')
     t.assert.deepStrictEqual(
       readFileSync(
         resolve(__dirname, '..', 'static', 'oauth2-redirect.html'),
@@ -337,7 +338,7 @@ test('/documentation/static/:file should send back the correct file', async (t)
       url: '/documentation/static/swagger-ui.css'
     })
     t.assert.deepStrictEqual(typeof res.payload, 'string')
-    t.assert.deepStrictEqual(res.headers['content-type'], 'text/css; charset=UTF-8')
+    t.assert.deepStrictEqual(res.headers['content-type'], 'text/css; charset=utf-8')
     t.assert.deepStrictEqual(
       readFileSync(
         resolve(__dirname, '..', 'static', 'swagger-ui.css'),
@@ -353,7 +354,7 @@ test('/documentation/static/:file should send back the correct file', async (t)
       url: '/documentation/static/swagger-ui-bundle.js'
     })
     t.assert.deepStrictEqual(typeof res.payload, 'string')
-    t.assert.deepStrictEqual(res.headers['content-type'], 'application/javascript; charset=UTF-8')
+    t.assert.deepStrictEqual(res.headers['content-type'], 'application/javascript; charset=utf-8')
     t.assert.deepStrictEqual(
       readFileSync(
         resolve(__dirname, '..', 'static', 'swagger-ui-bundle.js'),
@@ -369,7 +370,7 @@ test('/documentation/static/:file should send back the correct file', async (t)
       url: '/documentation/static/swagger-ui-standalone-preset.js'
     })
     t.assert.deepStrictEqual(typeof res.payload, 'string')
-    t.assert.deepStrictEqual(res.headers['content-type'], 'application/javascript; charset=UTF-8')
+    t.assert.deepStrictEqual(res.headers['content-type'], 'application/javascript; charset=utf-8')
     t.assert.deepStrictEqual(
       readFileSync(
         resolve(__dirname, '..', 'static', 'swagger-ui-standalone-preset.js'),
@@ -538,8 +539,35 @@ test('should return empty log level of route /documentation', async (t) => {
   t.assert.deepStrictEqual(res.headers['content-type'], 'text/html; charset=utf-8')
 })
 
+const assertIndexUrls = (t, indexHtml, prefix) => {
+  t.assert.deepStrictEqual(indexHtml.includes(`href="${prefix}/static/index.css"`), true)
+  t.assert.deepStrictEqual(indexHtml.includes(`src="${prefix}/static/theme/theme-js.js"`), true)
+  t.assert.deepStrictEqual(indexHtml.includes(`href="${prefix}/index.css"`), false)
+  t.assert.deepStrictEqual(indexHtml.includes(`src="${prefix}/theme/theme-js.js"`), false)
+}
+
+const validateIndexUrls = async (t, fastify, indexHtml, prefix = '') => {
+  const hrefs = indexHtml.matchAll(/href="([^"]*)"/g)
+  for (const [, path] of hrefs) {
+    const res = await fastify.inject({
+      method: 'GET',
+      url: join(prefix, path)
+    })
+
+    t.assert.equal(res.statusCode, 200)
+  }
+  const srcs = indexHtml.matchAll(/src="([^"]*)"/g)
+  for (const [, path] of srcs) {
+    const res = await fastify.inject({
+      method: 'GET',
+      url: join(prefix, path)
+    })
+    t.assert.equal(res.statusCode, 200)
+  }
+}
+
 test('/documentation should display index html with correct asset urls', async (t) => {
-  t.plan(6)
+  t.plan(13)
   const fastify = Fastify()
   await fastify.register(fastifySwagger, swaggerOption)
   await fastify.register(fastifySwaggerUi, { theme: { js: [{ filename: 'theme-js.js' }] } })
@@ -548,59 +576,66 @@ test('/documentation should display index html with correct asset urls', async (
     method: 'GET',
     url: '/documentation'
   })
+  t.assert.equal(res.statusCode, 200)
 
-  t.assert.deepStrictEqual(res.payload.includes('href="./documentation/static/index.css"'), true)
-  t.assert.deepStrictEqual(res.payload.includes('src="./documentation/static/theme/theme-js.js"'), true)
-  t.assert.deepStrictEqual(res.payload.includes('href="./documentation/index.css"'), false)
-  t.assert.deepStrictEqual(res.payload.includes('src="./documentation/theme/theme-js.js"'), false)
+  assertIndexUrls(t, res.payload, '/documentation')
+  await validateIndexUrls(t, fastify, res.payload)
+})
 
-  let cssRes = await fastify.inject({
-    method: 'GET',
-    url: '/documentation/static/index.css'
-  })
-  t.assert.equal(cssRes.statusCode, 200)
-  cssRes = await fastify.inject({
-    method: 'GET',
-    url: './documentation/static/index.css'
+/**
+ * This emulates when the server is inside an NGINX application that routes by path
+ */
+const testCases = [
+  ['/swagger-app', undefined],
+  ['/swagger-app/', undefined],
+  ['/swagger-app', 'documentation']
+]
+testCases.forEach(([prefix, pluginPrefix]) => {
+  test(`${prefix} ${pluginPrefix} should display index html with correct asset urls when nested`, async (t) => {
+    t.plan(13)
+    const fastify = Fastify()
+    await fastify.register(
+      async (childFastify) => {
+        await childFastify.register(fastifySwagger, swaggerOption)
+        await childFastify.register(fastifySwaggerUi, { indexPrefix: prefix, routePrefix: pluginPrefix, theme: { js: [{ filename: 'theme-js.js' }] } })
+      },
+      {
+        prefix: '/swagger-app'
+      }
+    )
+
+    const res = await fastify.inject({
+      method: 'GET',
+      url: '/swagger-app/documentation'
+    })
+    t.assert.equal(res.statusCode, 200)
+
+    assertIndexUrls(t, res.payload, '/swagger-app/documentation')
+
+    await validateIndexUrls(t, fastify, res.payload)
   })
-  t.assert.equal(cssRes.statusCode, 200)
 })
 
 /**
  * This emulates when the server is inside an NGINX application that routes by path
  */
-test('/documentation should display index html with correct asset urls when nested', async (t) => {
-  t.plan(5)
+test('/api/v1/docs should display index html with correct asset urls', async (t) => {
+  t.plan(13)
   const fastify = Fastify()
-  await fastify.register(
-    async () => {
-      await fastify.register(fastifySwagger, swaggerOption)
-      await fastify.register(fastifySwaggerUi, { theme: { js: [{ filename: 'theme-js.js' }] } })
-    },
-    {
-      prefix: '/swagger-app'
-    }
-  )
+  await fastify.register(fastifySwagger, swaggerOption)
+  await fastify.register(fastifySwaggerUi, { prefix: '/api/v1/docs', theme: { js: [{ filename: 'theme-js.js' }] } })
 
   const res = await fastify.inject({
     method: 'GET',
-    url: '/swagger-app/documentation'
+    url: '/api/v1/docs'
   })
-
-  t.assert.deepStrictEqual(res.payload.includes('href="./documentation/static/index.css"'), true)
-  t.assert.deepStrictEqual(res.payload.includes('src="./documentation/static/theme/theme-js.js"'), true)
-  t.assert.deepStrictEqual(res.payload.includes('href="./documentation/index.css"'), false)
-  t.assert.deepStrictEqual(res.payload.includes('src="./documentation/theme/theme-js.js"'), false)
-
-  const cssRes = await fastify.inject({
-    method: 'GET',
-    url: '/swagger-app/documentation/static/index.css'
-  })
-  t.assert.equal(cssRes.statusCode, 200)
+  t.assert.equal(res.statusCode, 200)
+  assertIndexUrls(t, res.payload, '/api/v1/docs')
+  await validateIndexUrls(t, fastify, res.payload)
 })
 
 test('/documentation/ should display index html with correct asset urls', async (t) => {
-  t.plan(4)
+  t.plan(13)
   const fastify = Fastify()
   await fastify.register(fastifySwagger, swaggerOption)
   await fastify.register(fastifySwaggerUi, { theme: { js: [{ filename: 'theme-js.js' }] } })
@@ -609,15 +644,14 @@ test('/documentation/ should display index html with correct asset urls', async
     method: 'GET',
     url: '/documentation/'
   })
+  t.assert.equal(res.statusCode, 200)
 
-  t.assert.strictEqual(res.payload.includes('href="./static/index.css"'), true)
-  t.assert.strictEqual(res.payload.includes('src="./static/theme/theme-js.js"'), true)
-  t.assert.strictEqual(res.payload.includes('href="./index.css"'), false)
-  t.assert.strictEqual(res.payload.includes('src="./theme/theme-js.js"'), false)
+  assertIndexUrls(t, res.payload, '.')
+  await validateIndexUrls(t, fastify, res.payload, '/documentation/')
 })
 
 test('/docs should display index html with correct asset urls when documentation prefix is set', async (t) => {
-  t.plan(4)
+  t.plan(13)
   const fastify = Fastify()
   await fastify.register(fastifySwagger, swaggerOption)
   await fastify.register(fastifySwaggerUi, { theme: { js: [{ filename: 'theme-js.js' }] }, routePrefix: '/docs' })
@@ -626,11 +660,10 @@ test('/docs should display index html with correct asset urls when documentation
     method: 'GET',
     url: '/docs'
   })
+  t.assert.equal(res.statusCode, 200)
 
-  t.assert.strictEqual(res.payload.includes('href="./docs/static/index.css"'), true)
-  t.assert.strictEqual(res.payload.includes('src="./docs/static/theme/theme-js.js"'), true)
-  t.assert.strictEqual(res.payload.includes('href="./docs/index.css"'), false)
-  t.assert.strictEqual(res.payload.includes('src="./docs/theme/theme-js.js"'), false)
+  assertIndexUrls(t, res.payload, '/docs')
+  await validateIndexUrls(t, fastify, res.payload)
 })
 
 test('/docs should display index html with correct asset urls when documentation prefix is set with no leading slash', async (t) => {
@@ -668,7 +701,7 @@ test('/docs/ should display index html with correct asset urls when documentatio
 })
 
 test('/documentation/ should display index html with correct asset urls', async (t) => {
-  t.plan(4)
+  t.plan(13)
   const fastify = Fastify()
   await fastify.register(fastifySwagger, swaggerOption)
   await fastify.register(fastifySwaggerUi, { theme: { js: [{ filename: 'theme-js.js' }] } })
@@ -677,15 +710,15 @@ test('/documentation/ should display index html with correct asset urls', async
     method: 'GET',
     url: '/documentation/'
   })
+  t.assert.equal(res.statusCode, 200)
 
-  t.assert.strictEqual(res.payload.includes('href="./static/index.css"'), true)
-  t.assert.strictEqual(res.payload.includes('src="./static/theme/theme-js.js"'), true)
-  t.assert.strictEqual(res.payload.includes('href="./index.css"'), false)
-  t.assert.strictEqual(res.payload.includes('src="./theme/theme-js.js"'), false)
+  assertIndexUrls(t, res.payload, '.')
+
+  await validateIndexUrls(t, fastify, res.payload, '/documentation')
 })
 
 test('/docs should display index html with correct asset urls when documentation prefix is set', async (t) => {
-  t.plan(4)
+  t.plan(13)
   const fastify = Fastify()
   await fastify.register(fastifySwagger, swaggerOption)
   await fastify.register(fastifySwaggerUi, { theme: { js: [{ filename: 'theme-js.js' }] }, routePrefix: '/docs' })
@@ -694,11 +727,11 @@ test('/docs should display index html with correct asset urls when documentation
     method: 'GET',
     url: '/docs'
   })
+  t.assert.equal(res.statusCode, 200)
+
+  assertIndexUrls(t, res.payload, '/docs')
 
-  t.assert.strictEqual(res.payload.includes('href="./docs/static/index.css"'), true)
-  t.assert.strictEqual(res.payload.includes('src="./docs/static/theme/theme-js.js"'), true)
-  t.assert.strictEqual(res.payload.includes('href="./docs/index.css"'), false)
-  t.assert.strictEqual(res.payload.includes('src="./docs/theme/theme-js.js"'), false)
+  await validateIndexUrls(t, fastify, res.payload)
 })
 
 test('/docs/ should display index html with correct asset urls when documentation prefix is set', async (t) => {
diff --git a/types/index.d.ts b/types/index.d.ts
index a3720ad..dd47358 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -45,6 +45,10 @@ declare namespace fastifySwaggerUi {
      * @default /documentation
      */
     routePrefix?: string;
+    /**
+     * Add an index prefix. This is for when the Fastify server is behind path based routing.  ex. NGINX
+     */
+    indexPrefix?: string;
     /**
      * Make it explicit that this plugin overrides the prefix value
      */
diff --git a/types/types.test-d.ts b/types/types.test-d.ts
index b9c11fd..e111889 100644
--- a/types/types.test-d.ts
+++ b/types/types.test-d.ts
@@ -29,10 +29,12 @@ app.register(fastifySwaggerUi);
 app.register(fastifySwaggerUi, {});
 app.register(fastifySwaggerUi, {
   routePrefix: '/documentation',
+  indexPrefix: '/custom-prefix'
 });
 
 const fastifySwaggerOptions: FastifySwaggerUiOptions = {
   routePrefix: '/documentation',
+  indexPrefix: '/custom-prefix'
 }
 app.register(fastifySwaggerUi, fastifySwaggerOptions);
 
@@ -91,6 +93,7 @@ app.get('/public/route', {
 app
   .register(fastifySwaggerUi, {
     routePrefix: '/documentation',
+    indexPrefix: '/custom-prefix'
   })
 
 app