diff --git a/e2e/express.e2e-spec.ts b/e2e/express.e2e-spec.ts index 5a3f11677..25610c17a 100644 --- a/e2e/express.e2e-spec.ts +++ b/e2e/express.e2e-spec.ts @@ -86,7 +86,7 @@ describe('Express Swagger', () => { ); SwaggerModule.setup(SWAGGER_RELATIVE_URL, app, swaggerDocument, { // to showcase that in new implementation u can use custom swagger-ui path. Useful when using e.g. webpack - customSwaggerUiPath: path.resolve(`./node_modules/swagger-ui-dist`), + customSwaggerUiPath: path.resolve(`./node_modules/swagger-ui-dist`) }); await app.init(); @@ -114,6 +114,55 @@ describe('Express Swagger', () => { }); }); + describe('disabled Swagger UI but served JSON/YAML definitions', () => { + const SWAGGER_RELATIVE_URL = '/apidoc'; + + beforeEach(async () => { + const swaggerDocument = SwaggerModule.createDocument( + app, + builder.build() + ); + SwaggerModule.setup(SWAGGER_RELATIVE_URL, app, swaggerDocument, { + swaggerUiEnabled: false + }); + + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('should serve the JSON definition file', async () => { + const response = await request(app.getHttpServer()).get( + `${SWAGGER_RELATIVE_URL}-json` + ); + + expect(response.status).toEqual(200); + expect(Object.keys(response.body).length).toBeGreaterThan(0); + }); + + it('should serve the YAML definition file', async () => { + const response = await request(app.getHttpServer()).get( + `${SWAGGER_RELATIVE_URL}-yaml` + ); + + expect(response.status).toEqual(200); + expect(response.text.length).toBeGreaterThan(0); + }); + + it.each([ + '/apidoc', + '/apidoc/', + '/apidoc/swagger-ui-bundle.js', + '/apidoc/swagger-ui-init.js' + ])('should not serve "%s"', async (file) => { + const response = await request(app.getHttpServer()).get(file); + + expect(response.status).toEqual(404); + }); + }); + describe('custom documents endpoints', () => { const JSON_CUSTOM_URL = '/apidoc-json'; const YAML_CUSTOM_URL = '/apidoc-yaml'; @@ -154,10 +203,10 @@ describe('Express Swagger', () => { `${JSON_CUSTOM_URL}?description=My%20custom%20description` ); - expect(response.body.info.description).toBe("My custom description"); + expect(response.body.info.description).toBe('My custom description'); }); - it('yaml document should be server in the custom url', async () => { + it('yaml document should be served in the custom url', async () => { const response = await request(app.getHttpServer()).get(YAML_CUSTOM_URL); expect(response.status).toEqual(200); @@ -168,7 +217,7 @@ describe('Express Swagger', () => { const response = await request(app.getHttpServer()).get( `${YAML_CUSTOM_URL}?description=My%20custom%20description` ); - expect(response.text).toContain("My custom description"); + expect(response.text).toContain('My custom description'); }); }); @@ -244,13 +293,17 @@ describe('Express Swagger', () => { customfavIcon: CUSTOM_FAVICON, customSiteTitle: CUSTOM_SITE_TITLE, customCssUrl: CUSTOM_CSS_URL, - patchDocumentOnRequest (req, res, document) { + patchDocumentOnRequest( + req, + res, + document + ) { return { ...document, info: { description: req.query.description } - } + }; } }); @@ -313,23 +366,29 @@ describe('Express Swagger', () => { ); SwaggerModule.setup('/:customer/', app, swaggerDocument, { - patchDocumentOnRequest (req, res, document) { + patchDocumentOnRequest( + req, + res, + document + ) { return { ...document, info: { description: `${req.params.customer}'s API documentation` } - } + }; } }); await app.init(); - const response: Response = await request(app.getHttpServer()).get('/customer-1/swagger-ui-init.js'); + const response: Response = await request(app.getHttpServer()).get( + '/customer-1/swagger-ui-init.js' + ); await app.close(); expect(response.text).toContain("customer-1's API documentation"); - }) + }); afterEach(async () => { await app.close(); diff --git a/lib/interfaces/swagger-custom-options.interface.ts b/lib/interfaces/swagger-custom-options.interface.ts index a28e6d3aa..aa3c15c49 100644 --- a/lib/interfaces/swagger-custom-options.interface.ts +++ b/lib/interfaces/swagger-custom-options.interface.ts @@ -1,23 +1,107 @@ import { SwaggerUiOptions } from './swagger-ui-options.interface'; -import { SwaggerDocumentOptions } from './swagger-document-options.interface'; import { OpenAPIObject } from './open-api-spec.interface'; export interface SwaggerCustomOptions { + /** + * If `true`, Swagger resources paths will be prefixed by the global prefix set through `setGlobalPrefix()`. + * Default: `false`. + * @see https://docs.nestjs.com/faq/global-prefix + */ useGlobalPrefix?: boolean; + + /** + * If `false`, only API definitions (JSON and YAML) will be served (on `/{path}-json` and `/{path}-yaml`). + * This is particularly useful if you are already hosting a Swagger UI somewhere else and just want to serve API definitions. + * Default: `true`. + */ + swaggerUiEnabled?: boolean; + + /** + * Url point the API definition to load in Swagger UI. + */ + swaggerUrl?: string; + + /** + * Path of the JSON API definition to serve. + * Default: `{{path}}-json`. + */ + jsonDocumentUrl?: string; + + /** + * Path of the YAML API definition to serve. + * Default: `{{path}}-json`. + */ + yamlDocumentUrl?: string; + + /** + * Hook allowing to alter the OpenAPI document before being served. + * It's called after the document is generated and before it is served as JSON & YAML. + */ + patchDocumentOnRequest?: ( + req: TRequest, + res: TResponse, + document: OpenAPIObject + ) => OpenAPIObject; + + /** + * If `true`, the selector of OpenAPI definitions is displayed in the Swagger UI interface. + * Default: `false`. + */ explorer?: boolean; + + /** + * Additional Swagger UI options + */ swaggerOptions?: SwaggerUiOptions; + + /** + * Custom CSS styles to inject in Swagger UI page. + */ customCss?: string; + + /** + * URL(s) of a custom CSS stylesheet to load in Swagger UI page. + */ customCssUrl?: string | string[]; + + /** + * URL(s) of custom JavaScript files to load in Swagger UI page. + */ customJs?: string | string[]; + + /** + * Custom JavaScript scripts to load in Swagger UI page. + */ customJsStr?: string | string[]; + + /** + * Custom favicon for Swagger UI page. + */ customfavIcon?: string; - customSwaggerUiPath?: string; - swaggerUrl?: string; + + /** + * Custom title for Swagger UI page. + */ customSiteTitle?: string; + + /** + * File system path (ex: ./node_modules/swagger-ui-dist) containing static Swagger UI assets. + */ + customSwaggerUiPath?: string; + + /** + * @deprecated This property has no effect. + */ validatorUrl?: string; + + /** + * @deprecated This property has no effect. + */ url?: string; + + /** + * @deprecated This property has no effect. + */ urls?: Record<'url' | 'name', string>[]; - jsonDocumentUrl?: string; - yamlDocumentUrl?: string; - patchDocumentOnRequest?: (req: TRequest, res: TResponse, document: OpenAPIObject) => OpenAPIObject; + } diff --git a/lib/swagger-module.ts b/lib/swagger-module.ts index 521534d7f..8f86df056 100644 --- a/lib/swagger-module.ts +++ b/lib/swagger-module.ts @@ -85,6 +85,7 @@ export class SwaggerModule { httpAdapter: HttpServer, documentOrFactory: OpenAPIObject | (() => OpenAPIObject), options: { + swaggerUiEnabled: boolean; jsonDocumentUrl: string; yamlDocumentUrl: string; swaggerOptions: SwaggerCustomOptions; @@ -92,41 +93,64 @@ export class SwaggerModule { ) { let document: OpenAPIObject; - const lazyBuildDocument = () => { - return typeof documentOrFactory === 'function' - ? documentOrFactory() - : documentOrFactory; + const getBuiltDocument = () => { + if (!document) { + document = + typeof documentOrFactory === 'function' + ? documentOrFactory() + : documentOrFactory; + } + return document; }; + if (options.swaggerUiEnabled) { + this.serveSwaggerUi( + finalPath, + urlLastSubdirectory, + httpAdapter, + getBuiltDocument, + options.swaggerOptions + ); + } + this.serveDefinitions(httpAdapter, getBuiltDocument, options); + } + + private static serveSwaggerUi( + finalPath: string, + urlLastSubdirectory: string, + httpAdapter: HttpServer, + getBuiltDocument: () => OpenAPIObject, + swaggerOptions: SwaggerCustomOptions + ) { const baseUrlForSwaggerUI = normalizeRelPath(`./${urlLastSubdirectory}/`); - let html: string; - let swaggerInitJS: string; + let swaggerUiHtml: string; + let swaggerUiInitJS: string; httpAdapter.get( normalizeRelPath(`${finalPath}/swagger-ui-init.js`), (req, res) => { res.type('application/javascript'); + const document = getBuiltDocument(); - if (!document) { - document = lazyBuildDocument(); - } - - if (options.swaggerOptions.patchDocumentOnRequest) { - const documentToSerialize = - options.swaggerOptions.patchDocumentOnRequest(req, res, document); + if (swaggerOptions.patchDocumentOnRequest) { + const documentToSerialize = swaggerOptions.patchDocumentOnRequest( + req, + res, + document + ); const swaggerInitJsPerRequest = buildSwaggerInitJS( documentToSerialize, - options.swaggerOptions + swaggerOptions ); return res.send(swaggerInitJsPerRequest); } - if (!swaggerInitJS) { - swaggerInitJS = buildSwaggerInitJS(document, options.swaggerOptions); + if (!swaggerUiInitJS) { + swaggerUiInitJS = buildSwaggerInitJS(document, swaggerOptions); } - res.send(swaggerInitJS); + res.send(swaggerUiInitJS); } ); @@ -141,29 +165,26 @@ export class SwaggerModule { ), (req, res) => { res.type('application/javascript'); + const document = getBuiltDocument(); - if (!document) { - document = lazyBuildDocument(); - } - - if (options.swaggerOptions.patchDocumentOnRequest) { - const documentToSerialize = - options.swaggerOptions.patchDocumentOnRequest(req, res, document); + if (swaggerOptions.patchDocumentOnRequest) { + const documentToSerialize = swaggerOptions.patchDocumentOnRequest( + req, + res, + document + ); const swaggerInitJsPerRequest = buildSwaggerInitJS( documentToSerialize, - options.swaggerOptions + swaggerOptions ); return res.send(swaggerInitJsPerRequest); } - if (!swaggerInitJS) { - swaggerInitJS = buildSwaggerInitJS( - document, - options.swaggerOptions - ); + if (!swaggerUiInitJS) { + swaggerUiInitJS = buildSwaggerInitJS(document, swaggerOptions); } - res.send(swaggerInitJS); + res.send(swaggerUiInitJS); } ); } catch (err) { @@ -173,63 +194,26 @@ export class SwaggerModule { */ } - httpAdapter.get(finalPath, (req, res) => { + httpAdapter.get(finalPath, (_, res) => { res.type('text/html'); - if (!document) { - document = lazyBuildDocument(); + if (!swaggerUiHtml) { + swaggerUiHtml = buildSwaggerHTML(baseUrlForSwaggerUI, swaggerOptions); } - if (options.swaggerOptions.patchDocumentOnRequest) { - const documentToSerialize = - options.swaggerOptions.patchDocumentOnRequest(req, res, document); - const htmlPerRequest = buildSwaggerHTML( - baseUrlForSwaggerUI, - documentToSerialize, - options.swaggerOptions - ); - return res.send(htmlPerRequest); - } - - if (!html) { - html = buildSwaggerHTML( - baseUrlForSwaggerUI, - document, - options.swaggerOptions - ); - } - - res.send(html); + res.send(swaggerUiHtml); }); // fastify doesn't resolve 'routePath/' -> 'routePath', that's why we handle it manually try { - httpAdapter.get(normalizeRelPath(`${finalPath}/`), (req, res) => { + httpAdapter.get(normalizeRelPath(`${finalPath}/`), (_, res) => { res.type('text/html'); - if (!document) { - document = lazyBuildDocument(); - } - - if (options.swaggerOptions.patchDocumentOnRequest) { - const documentToSerialize = - options.swaggerOptions.patchDocumentOnRequest(req, res, document); - const htmlPerRequest = buildSwaggerHTML( - baseUrlForSwaggerUI, - documentToSerialize, - options.swaggerOptions - ); - return res.send(htmlPerRequest); + if (!swaggerUiHtml) { + swaggerUiHtml = buildSwaggerHTML(baseUrlForSwaggerUI, swaggerOptions); } - if (!html) { - html = buildSwaggerHTML( - baseUrlForSwaggerUI, - document, - options.swaggerOptions - ); - } - res.send(html); + res.send(swaggerUiHtml); }); } catch (err) { /** @@ -239,13 +223,20 @@ export class SwaggerModule { * We can simply ignore that error here. */ } + } + private static serveDefinitions( + httpAdapter: HttpServer, + getBuiltDocument: () => OpenAPIObject, + options: { + jsonDocumentUrl: string; + yamlDocumentUrl: string; + swaggerOptions: SwaggerCustomOptions; + } + ) { httpAdapter.get(normalizeRelPath(options.jsonDocumentUrl), (req, res) => { res.type('application/json'); - - if (!document) { - document = lazyBuildDocument(); - } + const document = getBuiltDocument(); const documentToSerialize = options.swaggerOptions.patchDocumentOnRequest ? options.swaggerOptions.patchDocumentOnRequest(req, res, document) @@ -256,10 +247,7 @@ export class SwaggerModule { httpAdapter.get(normalizeRelPath(options.yamlDocumentUrl), (req, res) => { res.type('text/yaml'); - - if (!document) { - document = lazyBuildDocument(); - } + const document = getBuiltDocument(); const documentToSerialize = options.swaggerOptions.patchDocumentOnRequest ? options.swaggerOptions.patchDocumentOnRequest(req, res, document) @@ -299,6 +287,8 @@ export class SwaggerModule { ? `${validatedGlobalPrefix}${validatePath(options.yamlDocumentUrl)}` : `${finalPath}-yaml`; + const swaggerUiEnabled = options?.swaggerUiEnabled ?? true; + const httpAdapter = app.getHttpAdapter(); SwaggerModule.serveDocuments( @@ -307,24 +297,27 @@ export class SwaggerModule { httpAdapter, documentOrFactory, { + swaggerUiEnabled, jsonDocumentUrl: finalJSONDocumentPath, yamlDocumentUrl: finalYAMLDocumentPath, swaggerOptions: options || {} } ); - SwaggerModule.serveStatic(finalPath, app, options?.customSwaggerUiPath); - /** - * Covers assets fetched through a relative path when Swagger url ends with a slash '/'. - * @see https://github.com/nestjs/swagger/issues/1976 - */ - const serveStaticSlashEndingPath = `${finalPath}/${urlLastSubdirectory}`; - /** - * serveStaticSlashEndingPath === finalPath when path === '' || path === '/' - * in that case we don't need to serve swagger assets on extra sub path - */ - if (serveStaticSlashEndingPath !== finalPath) { - SwaggerModule.serveStatic(serveStaticSlashEndingPath, app); + if (swaggerUiEnabled) { + SwaggerModule.serveStatic(finalPath, app, options?.customSwaggerUiPath); + /** + * Covers assets fetched through a relative path when Swagger url ends with a slash '/'. + * @see https://github.com/nestjs/swagger/issues/1976 + */ + const serveStaticSlashEndingPath = `${finalPath}/${urlLastSubdirectory}`; + /** + * serveStaticSlashEndingPath === finalPath when path === '' || path === '/' + * in that case we don't need to serve swagger assets on extra sub path + */ + if (serveStaticSlashEndingPath !== finalPath) { + SwaggerModule.serveStatic(serveStaticSlashEndingPath, app); + } } } } diff --git a/lib/swagger-ui/swagger-ui.ts b/lib/swagger-ui/swagger-ui.ts index 01f8645df..7df0cca4f 100644 --- a/lib/swagger-ui/swagger-ui.ts +++ b/lib/swagger-ui/swagger-ui.ts @@ -66,7 +66,6 @@ function toTags( */ export function buildSwaggerHTML( baseUrl: string, - swaggerDoc: OpenAPIObject, customOptions: SwaggerCustomOptions = {} ) { const {