Repository: nestjs/swagger
Pull request #2487 (2023-06-17 19:59:59)

fix: issue where custom assets are not injected in html
## PR Checklist
Please check if your PR fulfills the following requirements:

- [x] The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md
- [x] Tests for the changes have been added (for bug fixes / features)
- [x] Docs have been added / updated (for bug fixes / features)


## PR Type
What kind of change does this PR introduce?

<!-- Please check the one that applies to this PR using "x". -->
- [x] Bugfix
- [ ] Feature
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] CI related changes
- [ ] Other... Please describe:

## What is the current behavior?

Options passed to `SwaggerModule.setup` are ignored. The `customOptions` object passed to `buildSwaggerHTML` is malformatted. Custom parameters that should be contained within the base of `customOptions` are actually contained within `swaggerOptions`, which itself contains another object `swaggerOptions`. For example, this is the object passed to `buildSwaggerHTML` when you run `npm run start:dev`:

```js
{
  jsonDocumentUrl: '/api-docs-json',
  yamlDocumentUrl: '/api-docs-yaml',
  swaggerOptions: {
    customSiteTitle: 'Demo API - Swagger UI 1',
    swaggerOptions: {
      persistAuthorization: true,
      defaultModelsExpandDepth: -1,
      syntaxHighlight: [Object],
      tryItOutEnabled: true
    },
    customfavIcon: '/public/favicon.ico',
    customCssUrl: '/public/theme.css'
  }
}
```
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->

I changed how the options are parsed from the `customOptions` object in `buildSwaggerHTML`.

Issue Number: #2481

## What is the new behavior?

I implemented a quick fix to resolve the bug. Now, extracted the options from a merger of `customOptions` and `customOptions.swaggerOptions`. This is not an ideal solution, but it seems this object structure is assumed in several other places in the code base, and applying a more systematic fix was non-trivial, as someone who is unfamiliar with the project.

## Does this PR introduce a breaking change?
- [ ] Yes
- [x] No

## Other information

I added tests for all of the custom assets so this will not inadvertently break again in the future.


Patch:
diff --git a/lib/swagger-ui/swagger-ui.ts b/lib/swagger-ui/swagger-ui.ts
index 1e239ff8..e22f56b3 100644
--- a/lib/swagger-ui/swagger-ui.ts
+++ b/lib/swagger-ui/swagger-ui.ts
@@ -77,7 +77,7 @@ export function buildSwaggerHTML(
     customSiteTitle = 'Swagger UI',
     customCssUrl = '',
     explorer = false
-  } = customOptions;
+  } = { ...customOptions, ...customOptions.swaggerOptions };
 
   const favIconString = customfavIcon
     ? `<link rel='icon' href='${customfavIcon}' />`


Test patch:
diff --git a/e2e/express.e2e-spec.ts b/e2e/express.e2e-spec.ts
index 1ffe4d79..ca205e23 100644
--- a/e2e/express.e2e-spec.ts
+++ b/e2e/express.e2e-spec.ts
@@ -188,4 +188,69 @@ describe('Express Swagger', () => {
       expect(response.text.length).toBeGreaterThan(0);
     });
   });
+
+  describe('custom swagger options', () => {
+    const CUSTOM_CSS = 'body { background-color: hotpink !important }';
+    const CUSTOM_JS = '/foo.js';
+    const CUSTOM_JS_STR = 'console.log("foo")';
+    const CUSTOM_FAVICON = '/foo.ico';
+    const CUSTOM_SITE_TITLE = 'Foo';
+    const CUSTOM_CSS_URL = '/foo.css';
+
+    beforeEach(async () => {
+      const swaggerDocument = SwaggerModule.createDocument(
+        app,
+        builder.build()
+      );
+
+      SwaggerModule.setup('/', app, swaggerDocument, {
+        customCss: CUSTOM_CSS,
+        customJs: CUSTOM_JS,
+        customJsStr: CUSTOM_JS_STR,
+        customfavIcon: CUSTOM_FAVICON,
+        customSiteTitle: CUSTOM_SITE_TITLE,
+        customCssUrl: CUSTOM_CSS_URL
+      });
+
+      await app.init();
+    });
+
+    it('should contain the custom css string', async () => {
+      const response: Response = await request(app.getHttpServer()).get('/');
+      expect(response.text).toContain(CUSTOM_CSS);
+    });
+
+    it('should source the custom js url', async () => {
+      const response: Response = await request(app.getHttpServer()).get('/');
+      expect(response.text).toContain(`script src='${CUSTOM_JS}'></script>`);
+    });
+
+    it('should contain the custom js string', async () => {
+      const response: Response = await request(app.getHttpServer()).get('/');
+      expect(response.text).toContain(CUSTOM_JS_STR);
+    });
+
+    it('should contain the custom favicon', async () => {
+      const response: Response = await request(app.getHttpServer()).get('/');
+      expect(response.text).toContain(
+        `<link rel='icon' href='${CUSTOM_FAVICON}' />`
+      );
+    });
+
+    it('should contain the custom site title', async () => {
+      const response: Response = await request(app.getHttpServer()).get('/');
+      expect(response.text).toContain(`<title>${CUSTOM_SITE_TITLE}</title>`);
+    });
+
+    it('should include the custom stylesheet', async () => {
+      const response: Response = await request(app.getHttpServer()).get('/');
+      expect(response.text).toContain(
+        `<link href='${CUSTOM_CSS_URL}' rel='stylesheet'>`
+      );
+    });
+
+    afterEach(async () => {
+      await app.close();
+    });
+  });
 });
diff --git a/e2e/fastify.e2e-spec.ts b/e2e/fastify.e2e-spec.ts
index 01c8eba1..b40c8eca 100644
--- a/e2e/fastify.e2e-spec.ts
+++ b/e2e/fastify.e2e-spec.ts
@@ -193,4 +193,82 @@ describe('Fastify Swagger', () => {
       expect(response.text.length).toBeGreaterThan(0);
     });
   });
+
+  describe('custom swagger options', () => {
+    const CUSTOM_CSS = 'body { background-color: hotpink !important }';
+    const CUSTOM_JS = '/foo.js';
+    const CUSTOM_JS_STR = 'console.log("foo")';
+    const CUSTOM_FAVICON = '/foo.ico';
+    const CUSTOM_SITE_TITLE = 'Foo';
+    const CUSTOM_CSS_URL = '/foo.css';
+
+    beforeEach(async () => {
+      const swaggerDocument = SwaggerModule.createDocument(
+        app,
+        builder.build()
+      );
+
+      SwaggerModule.setup('/custom', app, swaggerDocument, {
+        customCss: CUSTOM_CSS,
+        customJs: CUSTOM_JS,
+        customJsStr: CUSTOM_JS_STR,
+        customfavIcon: CUSTOM_FAVICON,
+        customSiteTitle: CUSTOM_SITE_TITLE,
+        customCssUrl: CUSTOM_CSS_URL
+      });
+
+      await app.init();
+      await app.getHttpAdapter().getInstance().ready();
+    });
+
+    it('should contain the custom css string', async () => {
+      const response: Response = await request(app.getHttpServer()).get(
+        '/custom'
+      );
+      expect(response.text).toContain(CUSTOM_CSS);
+    });
+
+    it('should source the custom js url', async () => {
+      const response: Response = await request(app.getHttpServer()).get(
+        '/custom'
+      );
+      expect(response.text).toContain(`script src='${CUSTOM_JS}'></script>`);
+    });
+
+    it('should contain the custom js string', async () => {
+      const response: Response = await request(app.getHttpServer()).get(
+        '/custom'
+      );
+      expect(response.text).toContain(CUSTOM_JS_STR);
+    });
+
+    it('should contain the custom favicon', async () => {
+      const response: Response = await request(app.getHttpServer()).get(
+        '/custom'
+      );
+      expect(response.text).toContain(
+        `<link rel='icon' href='${CUSTOM_FAVICON}' />`
+      );
+    });
+
+    it('should contain the custom site title', async () => {
+      const response: Response = await request(app.getHttpServer()).get(
+        '/custom'
+      );
+      expect(response.text).toContain(`<title>${CUSTOM_SITE_TITLE}</title>`);
+    });
+
+    it('should include the custom stylesheet', async () => {
+      const response: Response = await request(app.getHttpServer()).get(
+        '/custom'
+      );
+      expect(response.text).toContain(
+        `<link href='${CUSTOM_CSS_URL}' rel='stylesheet'>`
+      );
+    });
+
+    afterEach(async () => {
+      await app.close();
+    });
+  });
 });