UNPKG

nestjs-redox

Version:

This NestJS module enables to auto-generate beautiful API docs using Swagger and Redoc. It supports NestJS 10, ExpressJS and Fastify.

371 lines (370 loc) 18.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.NestjsRedoxModule = void 0; const tslib_1 = require("tslib"); const basic_auth_1 = require("@fastify/basic-auth"); const common_1 = require("@nestjs/common"); const get_global_prefix_1 = require("@nestjs/swagger/dist/utils/get-global-prefix"); const normalize_rel_path_1 = require("@nestjs/swagger/dist/utils/normalize-rel-path"); const validate_global_prefix_util_1 = require("@nestjs/swagger/dist/utils/validate-global-prefix.util"); const validate_path_util_1 = require("@nestjs/swagger/dist/utils/validate-path.util"); const crypto_1 = require("crypto"); const express_basic_auth_1 = tslib_1.__importDefault(require("express-basic-auth")); const fs_1 = require("fs"); const handlebars = tslib_1.__importStar(require("handlebars")); const path_1 = require("path"); const types_1 = require("./types"); const index_hbs_1 = require("./views/index.hbs"); const NestJSRedoxStaticAPIDocExpress = (request, response, next, options) => tslib_1.__awaiter(void 0, void 0, void 0, function* () { if (request.url === options.url) { response.setHeader('Content-Type', 'application/json'); response.write(JSON.stringify(options.document, null, 2)); response.statusCode = 200; response.end(); return; } else { next(); } }); const NestJSRedoxStaticAPIDocFastify = (eq, res, next, options) => tslib_1.__awaiter(void 0, void 0, void 0, function* () { if (eq.url === options.url) { res.setHeader('Content-Type', 'application/json'); res.write(JSON.stringify(options.document, null, 2)); res.statusCode = 200; res.end(); return; } else { next(); } }); const NestJSRedoxStaticMiddlewareExpress = (request, response, next, options) => tslib_1.__awaiter(void 0, void 0, void 0, function* () { if (request.url === `${options.baseUrlForRedocUI}/redoc.standalone.js`) { response.setHeader('Content-Type', 'application/javascript'); response.send(options.preparedRedocJS).status(200); return; } else { next(); } }); const NestJSRedoxStaticMiddlewareFastify = (eq, res, next, options) => tslib_1.__awaiter(void 0, void 0, void 0, function* () { if (eq.url === `${options.baseUrlForRedocUI}/redoc.standalone.js`) { res.setHeader('Content-Type', 'application/javascript'); res.write(options.preparedRedocJS); res.statusCode = 200; res.end(); return; } else { next(); } }); function stringTob64(text) { return Buffer.from(text).toString('base64'); } const buildRedocHTML = (baseUrlForRedocUI, document, documentURL, redoxOptions, redocOptions = {}, nonce) => { const template = handlebars.compile(index_hbs_1.REDOC_HANDLEBAR); handlebars.registerHelper('json', (context) => { return JSON.stringify(context !== null && context !== void 0 ? context : {}, null, 2); }); let base64Document = stringTob64(JSON.stringify(document)); // add line breaks for (let i = 200; i < base64Document.length; i += 200) { base64Document = base64Document.slice(0, i) + '\n' + base64Document.slice(i); } redocOptions.nonce = nonce; return template({ baseUrlForRedocUI, document, base64Document, documentURL, redoxOptions, nonce, redocOptions, }); }; /** ------ * Disclaimer: some functions were extracted from https://github.com/nestjs/swagger/blob/master/lib/swagger-module.ts * and changed in that way it renders redoc instead of swagger ui. */ class NestjsRedoxModule { static register(options, redocOptions) { this.options = new types_1.NestJSRedoxOptions(options !== null && options !== void 0 ? options : {}); this.redocOptions = redocOptions; return { module: NestjsRedoxModule, providers: [ { provide: 'REDOX_OPTIONS', useValue: this.options, }, { provide: 'REDOC_OPTIONS', useValue: redocOptions, }, ], exports: [], }; } /** * setups RedoxMdoule with generating and serving redoc html page using expressjs or fastify. * @param path URI path to the redoc page * @param app The nest application that is currently serving * @param documentOrURL The OpenAPI Object or a function that creates the object or a static URL * @param options NestJSRedoxOptions. If undefined default options are used. * @param redocOptions Officical options by redoc. */ static setup(path, app, documentOrURL, options = new types_1.NestJSRedoxOptions(), redocOptions) { var _a, _b, _c, _d; options = new types_1.NestJSRedoxOptions(options); const globalPrefix = (0, get_global_prefix_1.getGlobalPrefix)(app); const finalPath = (0, validate_path_util_1.validatePath)((options === null || options === void 0 ? void 0 : options.useGlobalPrefix) && (0, validate_global_prefix_util_1.validateGlobalPrefix)(globalPrefix) ? `${globalPrefix}${(0, validate_path_util_1.validatePath)(path)}` : path); const urlLastSubdirectory = finalPath.split('/').slice(-1).pop() || ''; const httpAdapter = app.getHttpAdapter(); if ((_a = options.serveAPIDoc) === null || _a === void 0 ? void 0 : _a.enabled) { const filename = (_c = (_b = options === null || options === void 0 ? void 0 : options.serveAPIDoc) === null || _b === void 0 ? void 0 : _b.filename) !== null && _c !== void 0 ? _c : 'swagger.json'; redocOptions = Object.assign(Object.assign({}, (redocOptions !== null && redocOptions !== void 0 ? redocOptions : {})), { downloadUrls: [ { title: 'Download', url: `${path}/${filename}`, }, ...((_d = redocOptions.downloadUrls) !== null && _d !== void 0 ? _d : []) ] }); NestjsRedoxModule.serveAPIDocument(`${finalPath}/${filename}`, app, documentOrURL); } NestjsRedoxModule.serveDocuments(finalPath, urlLastSubdirectory, httpAdapter, documentOrURL, { redocOptions: redocOptions || {}, redoxOptions: options, }); if (options.standalone) { NestjsRedoxModule.serveStatic(finalPath, app, options); /** * 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) { NestjsRedoxModule.serveStatic(serveStaticSlashEndingPath, app, options); } } } static serveStatic(finalPath, app, options) { var _a; const httpAdapter = app.getHttpAdapter(); const redocAssetsPath = (_a = options.redocBundlesDir) !== null && _a !== void 0 ? _a : (0, path_1.resolve)('node_modules/redoc/bundles'); if (!this.preparedRedocJS) { if ((0, fs_1.existsSync)((0, path_1.join)(redocAssetsPath, 'redoc.standalone.js'))) { this.preparedRedocJS = (0, fs_1.readFileSync)((0, path_1.join)(redocAssetsPath, 'redoc.standalone.js'), { encoding: 'utf-8' }).replace(/"(https?:\/\/cdn[^"]*(?:svg))"/gm, "\"data:image/svg+xml, %3Csvg width='300' height='300' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%230044d4' d='M249.488 231.49a123.68 123.68 0 0 0-22.86-12.66 111.55 111.55 0 0 0-42.12-214.75h-171.8a8.18 8.18 0 0 0-5.78 14 7.48 7.48 0 0 0 5.78 2.48 95 95 0 1 1 0 190c-.83 0-1.38.27-2.21.27a8 8 0 0 0-6.33 7.71v1.11a7.69 7.69 0 0 0 7.71 7.7h5.52a92.93 92.93 0 0 1 50.66 17.62 95.33 95.33 0 0 1 34.14 45.43 8.27 8.27 0 0 0 7.7 5.52h171.8a7.76 7.76 0 0 0 6.61-3.58 8 8 0 0 0 1.09-7.42 109.06 109.06 0 0 0-39.91-53.43zm-158-37.16a111.62 111.62 0 0 0 32.76-78.75 110 110 0 0 0-32.76-78.74 105.91 105.91 0 0 0-20.37-16.24h113.39a95 95 0 1 1 0 190 3.55 3.55 0 0 0-1.65.27H70.798a109.06 109.06 0 0 0 20.65-16.54h.04zm-13.77 37.16c-1.92-1.37-4.13-2.75-6.33-4.13h117.8a94.79 94.79 0 0 1 80.12 52h-153.63a112 112 0 0 0-38-47.87h.04z' class='cls-1'/%3E%3Cpath fill='%230044d4' d='M158.398 115.58a8.11 8.11 0 0 0 8.26 8.26h82.6a8.26 8.26 0 0 0 0-16.52h-82.6a8.29 8.29 0 0 0-8.26 8.26zM152.298 156.92h82.59a8.26 8.26 0 0 0 0-16.52h-82.59a8.11 8.11 0 0 0-8.26 8.26 8.28 8.28 0 0 0 8.26 8.26zM152.298 90.8h82.59a8.26 8.26 0 0 0 0-16.52h-82.59a8.11 8.11 0 0 0-8.26 8.26 8.46 8.46 0 0 0 8.26 8.26z' class='cls-1'/%3E%3C/svg%3E\""); } else { throw new Error("NestJSRedox: Can't find redoc bundle. Please install redoc."); } } if (httpAdapter && httpAdapter.getType() === 'fastify') { app.use((request, response, next) => { return NestJSRedoxStaticMiddlewareFastify(request, response, next, { preparedRedocJS: this.preparedRedocJS, baseUrlForRedocUI: finalPath, }); }); } else { app.use((request, response, next) => { return NestJSRedoxStaticMiddlewareExpress(request, response, next, { preparedRedocJS: this.preparedRedocJS, baseUrlForRedocUI: finalPath, }); }); } } static serveAPIDocument(finalPath, app, documentOrURL) { const httpAdapter = app.getHttpAdapter(); let document; const lazyBuildDocument = () => { if (typeof documentOrURL === 'string') { throw new Error('documentFactory is a string.'); } return typeof documentOrURL === 'function' ? documentOrURL() : documentOrURL; }; if (httpAdapter && httpAdapter.getType() === 'fastify') { app.use((request, response, next) => { if (!document) { document = lazyBuildDocument(); } return NestJSRedoxStaticAPIDocFastify(request, response, next, { document, url: finalPath, }); }); } else { app.use((request, response, next) => { if (!document) { document = lazyBuildDocument(); } return NestJSRedoxStaticAPIDocExpress(request, response, next, { document, url: finalPath, }); }); } } static serveDocuments(finalPath, urlLastSubdirectory, httpAdapter, documentOrURL, options) { var _a; let document; const documentURL = typeof documentOrURL === 'string' ? documentOrURL : undefined; const lazyBuildDocument = () => { if (typeof documentOrURL === 'string') { throw new Error('documentFactory is a string.'); } return typeof documentOrURL === 'function' ? this.applyRedocExtensions(options.redocOptions, documentOrURL()) : this.applyRedocExtensions(options.redocOptions, documentOrURL); }; const baseUrlForRedocUI = (0, normalize_rel_path_1.normalizeRelPath)(`./${urlLastSubdirectory}/`); if (httpAdapter.getType() === 'fastify') { const instance = httpAdapter.getInstance(); if ((_a = options.redoxOptions.auth) === null || _a === void 0 ? void 0 : _a.enabled) { if (!this.isFastifyBasicAuthRegistered) { this.isFastifyBasicAuthRegistered = true; instance.register(basic_auth_1.fastifyBasicAuth, { validate: (username, password, req, reply) => tslib_1.__awaiter(this, void 0, void 0, function* () { if (!Object.keys(options.redoxOptions.auth.users).includes(username) || options.redoxOptions.auth.users[username] !== password) { return new Error('Undefined!'); } }), authenticate: { realm: 'NestJSRedox', }, }); } } instance.setErrorHandler(function (err, req, reply) { if (err.statusCode === 401) { // this was unauthorized! Display the correct page/message. reply.code(401).send({ was: 'unauthorized' }); return; } reply.send(err); }); } httpAdapter.get(finalPath, (req, res, next) => { var _a; const sendPage = (error) => { if (error) { res.send(new common_1.UnauthorizedException()); return; } res.type('text/html'); if (!document && !documentURL) { document = lazyBuildDocument(); } const nonce = (0, crypto_1.randomBytes)(16).toString('hex'); const html = buildRedocHTML(baseUrlForRedocUI, document, documentURL, options.redoxOptions, options.redocOptions, nonce); this.setContentSecurityHeader(httpAdapter, res, nonce); if (options.redoxOptions.overwriteHeadersWith && typeof options.redoxOptions.overwriteHeadersWith === 'object') { this.overwriteHeadersWith(httpAdapter, res, options.redoxOptions.overwriteHeadersWith); } this.overwriteHeadersWith(httpAdapter, res, { 'Content-Type': 'text/html; charset=utf-8', }); res.send(html); }; if ((_a = options.redoxOptions.auth) === null || _a === void 0 ? void 0 : _a.enabled) { if (httpAdapter.getType() === 'express') { (0, express_basic_auth_1.default)({ users: options.redoxOptions.auth.users, challenge: true, })(req, res, () => { sendPage(); }); } else if (httpAdapter.getType() === 'fastify') { const instance = httpAdapter.getInstance(); if (instance.basicAuth) { instance.basicAuth(req, res, sendPage); } else { sendPage(); } } } else { sendPage(); } }); // fastify doesn't resolve 'routePath/' -> 'routePath', that's why we handle it manually try { httpAdapter.get((0, normalize_rel_path_1.normalizeRelPath)(`${finalPath}/`), (req, res) => { res.type('text/html'); if (!document && !documentURL) { document = lazyBuildDocument(); } const nonce = (0, crypto_1.randomBytes)(16).toString('hex'); const html = buildRedocHTML(baseUrlForRedocUI, document, documentURL, options.redoxOptions, options.redocOptions, nonce); this.setContentSecurityHeader(httpAdapter, res, nonce); res.send(html); }); } catch (err) { /** * When Fastify adapter is being used with the "ignoreTrailingSlash" configuration option set to "true", * declaration of the route "finalPath/" will throw an error because of the following conflict: * Method '${method}' already declared for route '${path}' with constraints '${JSON.stringify(constraints)}. * We can simply ignore that error here. */ } } /** * Fixes content security policy issue * see issue #2 and #17 * @param httpAdapter * @param res * @private */ static setContentSecurityHeader(httpAdapter, res, nonce) { const header = { name: 'Content-Security-Policy', value: `script-src 'self' 'nonce-${nonce}'; script-src-elem 'self' 'nonce-${nonce}' https://cdn.redoc.ly; child-src 'self' 'nonce-${nonce}' blob:;`, }; if (httpAdapter.getType() === 'express') { res.setHeader(header.name, header.value); } else if (httpAdapter.getType() === 'fastify') { res.header(header.name, header.value); } } /** * overwrites the HTTP header with that ones from RedoxOptions. * @param newHeaders foreach header use one attribute and value */ static overwriteHeadersWith(httpAdapter, res, newHeaders) { for (const key of Object.keys(newHeaders !== null && newHeaders !== void 0 ? newHeaders : {})) { if (httpAdapter.getType() === 'express') { res.setHeader(key, newHeaders[key]); } else if (httpAdapter.getType() === 'fastify') { res.header(key, newHeaders[key]); } } } static applyRedocExtensions(redocOptions, swaggerSpec) { var _a, _b, _c, _d, _e, _f; if (redocOptions === null || redocOptions === void 0 ? void 0 : redocOptions.logo) { const logo = (_a = swaggerSpec.info['x-logo']) !== null && _a !== void 0 ? _a : {}; swaggerSpec.info['x-logo'] = Object.assign(Object.assign({}, logo), { url: (_b = logo['url']) !== null && _b !== void 0 ? _b : redocOptions.logo.url, href: (_c = logo['href']) !== null && _c !== void 0 ? _c : redocOptions.logo.href, backgroundColor: (_d = logo['backgroundColor']) !== null && _d !== void 0 ? _d : redocOptions.logo.backgroundColor, altText: (_e = logo['altText']) !== null && _e !== void 0 ? _e : redocOptions.logo.altText }); } if (redocOptions === null || redocOptions === void 0 ? void 0 : redocOptions.tagGroups) { swaggerSpec['x-tagGroups'] = (_f = swaggerSpec['x-tagGroups']) !== null && _f !== void 0 ? _f : redocOptions.tagGroups; } return swaggerSpec; } } exports.NestjsRedoxModule = NestjsRedoxModule; NestjsRedoxModule.isFastifyBasicAuthRegistered = false;