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
JavaScript
;
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;