UNPKG

hapi-swagger

Version:

A swagger documentation UI generator plugin for hapi

398 lines (358 loc) 13.6 kB
const Hoek = require('@hapi/hoek'); const Joi = require('joi'); const Path = require('path'); const { join, sep } = require('path'); const swaggerUiAssetPath = require('swagger-ui-dist').getAbsoluteFSPath(); const Pack = require('../package.json'); const Defaults = require('../lib/defaults'); const Builder = require('../lib/builder'); const Utilities = require('../lib/utilities'); // schema for plug-in properties const schema = Joi.object({ debug: Joi.boolean(), jsonPath: Joi.string(), jsonRoutePath: Joi.string(), documentationPath: Joi.string(), documentationRouteTags: Joi.alternatives().try(Joi.string(), Joi.array().items(Joi.string())), documentationRoutePlugins: Joi.object().default({}), templates: Joi.string(), swaggerUIPath: Joi.string(), routesBasePath: Joi.string(), auth: Joi.alternatives().try(Joi.boolean(), Joi.string(), Joi.object()), pathPrefixSize: Joi.number().integer().positive(), payloadType: Joi.string().valid('form', 'json'), documentationPage: Joi.boolean(), swaggerUI: Joi.boolean(), expanded: Joi.string().valid('none', 'list', 'full'), sortTags: Joi.string().valid('alpha', 'unsorted'), sortEndpoints: Joi.string().valid('alpha', 'method', 'ordered', 'unsorted'), sortPaths: Joi.string().valid('unsorted', 'path-method'), authAccessFormatter: Joi.function(), // patch: uiCompleteScript -- Define validation scope // to have another ability, you may use plain js script as "string" // or use external file by describe it as { src: 'URL' } // you may provide external js file as URL from static route, // eg: '/assets/js/doc-patch.js' uiCompleteScript: Joi.alternatives( Joi.string(), Joi.object().keys({ src: Joi.string().required() }) ).allow(null), uiOptions: Joi.object().default({}), xProperties: Joi.boolean(), reuseDefinitions: Joi.boolean(), wildcardMethods: Joi.array().items(Joi.string().not('HEAD', 'OPTIONS')), // OPTIONS not supported by Swagger and HEAD not support by Hapi OAS: Joi.string().valid('v2', 'v3.0'), definitionPrefix: Joi.string(), deReference: Joi.boolean(), validatorUrl: Joi.string().allow(null), acceptToProduce: Joi.boolean(), cors: Joi.boolean(), pathReplacements: Joi.array().items( Joi.object({ replaceIn: Joi.string().valid('groups', 'endpoints', 'all'), pattern: Joi.object().instance(RegExp), replacement: Joi.string().allow('') }) ), routeTag: Joi.alternatives(Joi.string(), Joi.function()), // validate as declared in @hapi/hapi // https://github.com/hapijs/hapi/blob/5c0850989f2b7270fe7a7b6a7d4ebdc9a7fecd79/lib/config.js#L220 validate: Joi.object({ headers: Joi.alternatives(Joi.object(), Joi.array(), Joi.function()).allow(null, true), params: Joi.alternatives(Joi.object(), Joi.array(), Joi.function()).allow(null, true), query: Joi.alternatives(Joi.object(), Joi.array(), Joi.function()).allow(null, false, true), payload: Joi.alternatives(Joi.object(), Joi.array(), Joi.function()).allow(null, false, true), state: Joi.alternatives(Joi.object(), Joi.array(), Joi.function()).allow(null, false, true), failAction: Joi.alternatives([Joi.valid('error', 'log', 'ignore'), Joi.function()]), errorFields: Joi.object(), options: Joi.object(), validator: Joi.object() }), tryItOutEnabled: Joi.boolean() }).unknown(); /** * register the plug-in with the Hapi framework * * @param {Object} plugin * @param {Object} options * @param {Function} next */ exports.plugin = { name: Pack.name, version: Pack.version, multiple: true, register: (server, options) => { // `options.validate` might not be present but it should not be set in the // `Defaults` since it would always override the server-level defaults. // It should be overwritten only if explicitly passed to the plugin options. const validateOption = { validate: options.validate }; const settings = Hoek.applyToDefaults(Defaults, options, { nullOverride: true }); const publicDirPath = Path.resolve(__dirname, '..', 'public'); // avoid breaking behaviour with previous version if (!options.routesBasePath && options.swaggerUIPath) { settings.routesBasePath = options.swaggerUIPath; } if (options.OAS === 'v3.0') { settings.jsonPath = options.jsonPath || '/openapi.json'; settings.jsonRoutePath = options.jsonRoutePath || '/openapi.json'; } if (!options.jsonRoutePath && options.jsonPath) { settings.jsonRoutePath = options.jsonPath; } settings.log = (tags, data) => { tags.unshift('hapi-swagger'); if (settings.debug) { server.log(tags, data); } }; settings.log(['info'], 'Started'); // add server method for caching if (settings.cache && !server.methods.getSwaggerJSON) { // set default settings.cache.segment = 'hapi-swagger'; settings.cache.getDecoratedValue = true; if (!settings.cache.generateTimeout) { settings.cache.generateTimeout = 30 * 1000; } const getSwaggerJSON = Builder.getSwaggerJSON; // If you need access to the cache result envelope information { value, ttl, report }, // use the catbox getDecoratedValue option. const options = { cache: settings.cache, generateKey: (settings, request) => 'hapi-swagger-' + request.path }; server.method('getSwaggerJSON', getSwaggerJSON, options); } // patch: uiCompleteScript -- Implementing // mutate the uiCompleteScript before render into h.views if ( settings.uiCompleteScript !== '' && settings.uiCompleteScript !== null && typeof settings.uiCompleteScript === 'object' ) { settings.uiCompleteScript = ` const s = document.createElement('script'); s.src = '${settings.uiCompleteScript.src}'; s.type = 'text/javascript'; document.body.appendChild(s); `; } Joi.assert(settings, schema); // add routing swagger json server.route([ { method: 'GET', path: settings.jsonRoutePath, options: Hoek.applyToDefaults( { auth: settings.auth, cors: settings.cors, tags: settings.documentationRouteTags, handler: async (request, h) => { if (settings.cache) { const { cached, value } = await server.methods.getSwaggerJSON(settings, request); const lastModified = cached ? new Date(cached.stored) : new Date(); return h.response(value).header('last-modified', lastModified.toUTCString()); } const json = await Builder.getSwaggerJSON(settings, request); return json; }, plugins: { 'hapi-swagger': false } }, validateOption ) } ]); // only add '@hapi/inert' and '@hapi/vision' based routes if needed if (settings.documentationPage === true || settings.swaggerUI === true) { server.dependency(['@hapi/inert', '@hapi/vision'], (server) => { // Setup vision using handlebars from the templates directory const manager = server.views({ engines: { html: require('handlebars') }, path: settings.templates }); // Register a helper we can use in template to render a context value as JSON. manager.registerHelper('toJSON', (obj) => JSON.stringify(obj)); // add documentation page if (settings.documentationPage === true) { server.route([ { method: 'GET', path: settings.documentationPath, options: Hoek.applyToDefaults( { auth: settings.auth, tags: settings.documentationRouteTags, handler: (request, h) => { return h.view('index', {}); }, plugins: settings.documentationRoutePlugins }, validateOption ) } ]); } // add swagger UI if asked for or need by documentation page if (settings.documentationPage === true || settings.swaggerUI === true) { const filesToServe = [ 'favicon-16x16.png', 'favicon-32x32.png', 'index.html', 'oauth2-redirect.html', 'swagger-ui-bundle.js', 'swagger-ui-bundle.js.map', 'swagger-ui-standalone-preset.js', 'swagger-ui-standalone-preset.js.map', 'swagger-ui.css', 'swagger-ui.css.map', 'swagger-ui.js', 'swagger-ui.js.map' ]; filesToServe.forEach((filename) => { server.route({ method: 'GET', path: `${settings.routesBasePath}${filename}`, options: Hoek.applyToDefaults( { auth: settings.auth, tags: settings.documentationRouteTags, files: { relativeTo: swaggerUiAssetPath } }, validateOption ), handler: { file: `${filename}` } }); }); server.route({ method: 'GET', path: settings.routesBasePath + 'extend.js', options: Hoek.applyToDefaults( { tags: settings.documentationRouteTags, auth: settings.auth, files: { relativeTo: publicDirPath }, handler: { file: 'extend.js' } }, validateOption ) }); } // add debug page if (settings.debug === true) { server.route([ { method: 'GET', path: join(settings.documentationPath, sep, 'debug').split(sep).join('/'), options: Hoek.applyToDefaults( { auth: settings.auth, tags: settings.documentationRouteTags, handler: (request, h) => { return h.view('debug.html', {}).type('application/json'); }, plugins: settings.documentationRoutePlugins }, validateOption ) } ]); } appendDataContext(server, settings); }); } // TODO: need to work how to test this as it need a request object // Undocumented API interface, it may change /* $lab:coverage:off$ */ server.expose('getJSON', (exposeOptions, request, callback) => { // use either options passed to function or plug-in scope options let exposeSettings = {}; if (exposeOptions && Utilities.hasProperties(exposeOptions)) { exposeSettings = Hoek.applyToDefaults(Defaults, exposeOptions); Joi.assert(exposeSettings, schema); } else { exposeSettings = Hoek.clone(settings); } return Builder.getSwaggerJSON(exposeSettings, request, callback); }); /* $lab:coverage:on$ */ } }; /** * appends settings data in template context * * @param {Object} plugin * @param {Object} settings * @return {Object} */ const appendDataContext = function (plugin, settings) { plugin.ext('onPostHandler', (request, h) => { const response = request.response; const routePrefix = plugin.realm.modifiers.route.prefix; // if the reply is a view add settings data into template system if (response.variety === 'view') { // skip if the request is not for this handler if (routePrefix && !request.path.startsWith(routePrefix)) { return h.continue; } // Added to fix bug that cannot yet be reproduced in test - REVIEW /* $lab:coverage:off$ */ if (!response.source.context) { response.source.context = {}; } /* $lab:coverage:on$ */ const prefixedSettings = Hoek.clone(settings); // append tags from document request to JSON request prefixedSettings.jsonPath = request.query.tags ? Utilities.appendQueryString(settings.jsonPath, 'tags', request.query.tags) : settings.jsonPath; if (routePrefix) { ['jsonPath', 'swaggerUIPath'].forEach((setting) => { prefixedSettings[setting] = routePrefix + prefixedSettings[setting]; }); } // Need JWT plugin to work with Hapi v17+ to test this again const prefix = findAPIKeyPrefix(settings); if (prefix) { prefixedSettings.keyPrefix = prefix; } prefixedSettings.stringified = JSON.stringify(prefixedSettings); response.source.context.hapiSwagger = prefixedSettings; } return h.continue; }); }; /** * finds any keyPrefix in securityDefinitions - also add x- to name * * @param {Object} settings * @return {string} */ const findAPIKeyPrefix = function (settings) { // Need JWT plugin to work with Hapi v17+ to test this again /* $lab:coverage:off$ */ let out = ''; if (settings.securityDefinitions) { Object.keys(settings.securityDefinitions).forEach((key) => { if (settings.securityDefinitions[key]['x-keyPrefix']) { out = settings.securityDefinitions[key]['x-keyPrefix']; } }); } return out; /* $lab:coverage:on$ */ };