hapi-swagger
Version:
A swagger documentation UI generator plugin for hapi
682 lines (589 loc) • 22.1 kB
JavaScript
const Hoek = require('@hapi/hoek');
const Joi = require('joi');
const Parameters = require('../lib/parameters');
const Definitions = require('../lib/definitions');
const Properties = require('../lib/properties');
const Responses = require('../lib/responses');
const Utilities = require('../lib/utilities');
const internals = {};
exports =
module.exports =
internals.paths =
function (settings) {
this.settings = settings;
this.definitions = new Definitions(settings);
this.properties = new Properties(settings, {}, {});
this.responses = new Responses(settings, {}, {});
this.defaults = {
responses: {}
};
this.schema = Joi.object({
tags: Joi.array().items(Joi.string()),
summary: Joi.string(),
description: Joi.string(),
externalDocs: Joi.object({
description: Joi.string(),
url: Joi.string().uri()
}),
operationId: Joi.string(),
consumes: Joi.array().items(Joi.string()),
produces: Joi.array().items(Joi.string()),
parameters: Joi.array().items(Joi.object()),
responses: Joi.object().required(),
schemes: Joi.array().items(Joi.string().valid('http', 'https', 'ws', 'wss')),
deprecated: Joi.boolean(),
security: Joi.array().items(Joi.object())
});
};
/**
* build the swagger path section
*
* @param {Array} routes
* @return {Object}
*/
internals.paths.prototype.build = function (routes) {
const self = this;
const routesData = [];
// loop each route
routes.forEach((route) => {
const routeOptions = Hoek.reach(route, 'settings.plugins.hapi-swagger') || {};
const routeData = internals.createRouteMetaData(route, routeOptions);
Utilities.assignVendorExtensions(routeData, routeOptions);
routeData.path = Utilities.replaceInPath(routeData.path, ['endpoints'], this.settings.pathReplacements);
// user configured interface through route plugin options
if (Hoek.reach(routeOptions, 'validate.query')) {
routeData.queryParams = Utilities.toJoiObject(Hoek.reach(routeOptions, 'validate.query'));
}
if (Hoek.reach(routeOptions, 'validate.params')) {
routeData.pathParams = Utilities.toJoiObject(Hoek.reach(routeOptions, 'validate.params'));
}
if (Hoek.reach(routeOptions, 'validate.headers')) {
routeData.headerParams = Utilities.toJoiObject(Hoek.reach(routeOptions, 'validate.headers'));
}
if (Hoek.reach(routeOptions, 'validate.payload')) {
// has different structure, just pass straight through
routeData.payloadParams = Hoek.reach(routeOptions, 'validate.payload');
// if its a native javascript object convert it to JOI
if (!Utilities.isJoi(routeData.payloadParams)) {
routeData.payloadParams = Joi.object(routeData.payloadParams);
}
}
// swap out any custom validation function for Joi object/string
['queryParams', 'pathParams', 'headerParams', 'payloadParams'].forEach((property) => {
// swap out any custom validation function for Joi object/string
if (Utilities.isFunction(routeData[property])) {
if (property !== 'pathParams') {
self.settings.log(
['validation', 'warning'],
'Using a Joi.function for a query, header or payload is not supported.'
);
routeData[property] =
property === 'payloadParams'
? Joi.object().label('Hidden Model')
: Joi.object({ 'Hidden Model': Joi.string() });
} else {
self.settings.log(
['validation', 'error'],
'Using a Joi.function for a params is not supported and has been removed.'
);
routeData[property] = null;
}
}
});
routesData.push(...internals.getMultiMethodRoutes(routeData, self.settings.wildcardMethods));
});
return this.buildRoutes(routesData);
};
/**
* Create route meta data
* @param {Object} route
* @param {Object} routeOptions
* @returns {Object}
*/
internals.createRouteMetaData = function (route, routeOptions) {
return {
path: route.path,
method: route.method.toUpperCase(),
description: route.settings.description,
notes: route.settings.notes,
tags: Hoek.reach(route, 'settings.tags'),
authAccess: Hoek.reach(route, 'settings.auth.access') || null,
queryParams: Hoek.reach(route, 'settings.validate.query'),
pathParams: Hoek.reach(route, 'settings.validate.params'),
payloadParams: Hoek.reach(route, 'settings.validate.payload'),
responseSchema: Hoek.reach(route, 'settings.response.schema'),
responseStatus: Hoek.reach(route, 'settings.response.status'),
headerParams: Hoek.reach(route, 'settings.validate.headers'),
consumes: Hoek.reach(routeOptions, 'consumes') || null,
produces: Hoek.reach(routeOptions, 'produces') || null,
responses: Hoek.reach(routeOptions, 'responses') || null,
payloadType: Hoek.reach(routeOptions, 'payloadType') || null,
security: Hoek.reach(routeOptions, 'security') || null,
order: Hoek.reach(routeOptions, 'order') || null,
deprecated: Hoek.reach(routeOptions, 'deprecated') || null,
id: Hoek.reach(routeOptions, 'id') || null,
groups: route.group
};
};
/**
* Handle the case when route's method is declared with wildcard syntax
* @param {Object} route
* @param {Array<string>} wildcardMethods
* @returns {*[]}
*/
internals.getMultiMethodRoutes = function (route, wildcardMethods) {
return route.method !== '*'
? [Hoek.clone(route)]
: wildcardMethods.map((method) => {
return {
...Hoek.clone(route),
method
};
});
};
/**
* build the path section from hapi routes data
*
* @param {Array} routes
* @return {Object}
*/
internals.paths.prototype.buildRoutes = function (routes) {
if (this.settings.OAS === 'v2') {
return this.buildSwaggerRoutes(routes);
}
return this.buildOpenApiRoutes(routes);
};
/**
* Eventually append the auth access information to the description.
*
* @param {Object} route
* @param {Object} out
*/
internals.paths.prototype.appendAuthAccessInformation = function (route, out) {
if (this.settings.authAccessFormatter) {
const formattedAccess = this.settings.authAccessFormatter(route.authAccess);
if (formattedAccess) {
out.description = `${out.description ? '<br/><br/>' : ''}${formattedAccess}`;
}
}
};
/**
* build the swagger path section from hapi routes data
*
* @param {Array} routes
* @return {Object}
*/
internals.paths.prototype.buildSwaggerRoutes = function (routes) {
const self = this;
const pathObj = {};
const swagger = {
definitions: {},
'x-alt-definitions': {}
};
const definitionCache = [new WeakMap(), new WeakMap()];
// reset properties
this.properties = new Properties(this.settings, swagger.definitions, swagger['x-alt-definitions'], definitionCache);
this.responses = new Responses(this.settings, swagger.definitions, swagger['x-alt-definitions'], definitionCache);
routes.forEach((route) => {
const method = route.method;
let path = internals.removeBasePath(route.path, this.settings.basePath, this.settings.pathReplacements);
const out = {
summary: route.description,
operationId: route.id || Utilities.createId(route.method, path),
description: route.notes,
parameters: [],
consumes: [],
produces: []
};
// tags in swagger are used for grouping
if (this.settings.grouping === 'tags') {
out.tags = (route.tags || []).filter(this.settings.tagsGroupingFilter);
} else {
out.tags = route.groups;
}
out.description = Array.isArray(route.notes) ? route.notes.join('<br/><br/>') : route.notes;
this.appendAuthAccessInformation(route, out);
if (route.security) {
out.security = route.security;
}
// add user defined over automaticlly discovered
if (this.settings.consumes || route.consumes) {
out.consumes = internals.overload(this.settings.consumes, route.consumes);
}
if (this.settings.produces || route.produces) {
out.produces = internals.overload(this.settings.produces, route.produces);
}
// set from plugin options or from route options
const payloadType = internals.overload(this.settings.payloadType, route.payloadType);
// build payload either with JSON or form input
let payloadStructures = this.getDefaultStructures();
const payloadJoi = route.payloadParams;
if (payloadType.toLowerCase() === 'json') {
// set as json
payloadStructures = this.getSwaggerStructures(payloadJoi, 'body', true, false);
} else {
// set as formData
if (Utilities.hasJoiChildren(payloadJoi)) {
payloadStructures = this.getSwaggerStructures(payloadJoi, 'formData', false, false);
} else {
self.testParameterError(payloadJoi, 'payload form-urlencoded', path);
}
// add form data mimetype
if (out.consumes.length === 0) {
// change form mimetype based on meta property 'swaggerType'
out.consumes = internals.hasFileType(route) ? ['multipart/form-data'] : ['application/x-www-form-urlencoded'];
}
}
// set required true/false for each path params
let pathStructures = this.getDefaultStructures();
const pathJoi = route.pathParams;
if (Utilities.hasJoiChildren(pathJoi)) {
pathStructures = this.getSwaggerStructures(pathJoi, 'path', false, false);
pathStructures.parameters.forEach((item) => {
// add required based on path pattern {prama} and {prama?}
if (item.required === undefined) {
if (path.indexOf('{' + item.name + '}') > -1) {
item.required = true;
}
if (path.indexOf('{' + item.name + '?}') > -1) {
delete item.required;
}
}
if (item.required === false) {
delete item.required;
}
if (!item.required) {
self.settings.log(
['validation', 'warning'],
'The ' +
path +
' params parameter {' +
item.name +
'} is set as optional. This will work in the UI, but is invalid in the swagger spec'
);
}
});
} else {
self.testParameterError(pathJoi, 'params', path);
}
// removes ? from {prama?} after we have set required/optional for path params
path = internals.cleanPathParameters(path);
let headerStructures = this.getDefaultStructures();
const headerJoi = route.headerParams;
if (Utilities.hasJoiChildren(headerJoi)) {
headerStructures = this.getSwaggerStructures(headerJoi, 'header', false, false);
} else {
self.testParameterError(headerJoi, 'headers', path);
}
// if the API has a user set accept header with a enum convert into the produces array
if (this.settings.acceptToProduce === true) {
headerStructures.parameters = headerStructures.parameters.filter((header) => {
if (header.name.toLowerCase() === 'accept') {
if (header.enum) {
out.produces = Utilities.sortFirstItem(header.enum, header.default);
return false;
}
}
return true;
});
}
let queryStructures = this.getDefaultStructures();
const queryJoi = route.queryParams;
if (Utilities.hasJoiChildren(queryJoi)) {
queryStructures = this.getSwaggerStructures(queryJoi, 'query', false, false);
} else {
self.testParameterError(queryJoi, 'query', path);
}
out.parameters = out.parameters.concat(
headerStructures.parameters,
pathStructures.parameters,
queryStructures.parameters,
payloadStructures.parameters
);
// if the api sets the content-type header parameter use that
if (internals.hasContentTypeHeader(out)) {
delete out.consumes;
}
//const name = out.operationId + method;
//userDefindedSchemas, defaultSchema, statusSchemas, useDefinitions, isAlt
out.responses = this.responses.build(route.responses, route.responseSchema, route.responseStatus, true, false);
if (route.order) {
out['x-order'] = route.order;
}
Utilities.assignVendorExtensions(out, route);
if (route.deprecated !== null) {
out.deprecated = route.deprecated;
}
if (!pathObj[path]) {
pathObj[path] = {};
}
pathObj[path][method.toLowerCase()] = Utilities.deleteEmptyProperties(out);
});
swagger.paths = pathObj;
return swagger;
};
/**
* build the OpenAPI path section from hapi routes data
*
* @param {Array} routes
* @return {Object}
*/
internals.paths.prototype.buildOpenApiRoutes = function (routes) {
const pathObj = {};
const openApi = {
definitions: {},
'x-alt-definitions': {}
};
const definitionCache = [new WeakMap(), new WeakMap()];
// reset properties
this.properties = new Properties(this.settings, openApi.definitions, openApi['x-alt-definitions'], definitionCache);
this.responses = new Responses(this.settings, openApi.definitions, openApi['x-alt-definitions'], definitionCache);
routes.forEach((route) => {
const method = route.method;
let path = internals.removeBasePath(route.path, this.settings.basePath, this.settings.pathReplacements);
const out = {
summary: route.description,
operationId: route.id || Utilities.createId(route.method, path),
description: route.notes,
parameters: []
};
// tags in swagger are used for grouping
if (this.settings.grouping === 'tags') {
out.tags = (route.tags || []).filter(this.settings.tagsGroupingFilter);
} else {
out.tags = route.groups;
}
out.description = Array.isArray(route.notes) ? route.notes.join('<br/><br/>') : route.notes;
this.appendAuthAccessInformation(route, out);
if (route.security) {
out.security = route.security;
}
// add user defined over automatically discovered
let consumes = internals.overload(this.settings.consumes, route.consumes);
let produces = internals.overload(this.settings.produces, route.produces) || ['application/json'];
// set from plugin options or from route options
const payloadType = internals.overload(this.settings.payloadType, route.payloadType);
// build payload either with JSON or form input
const payloadJoi = route.payloadParams;
if (payloadType.toLowerCase() === 'json') {
const schema = this.getSwaggerStructures(payloadJoi, 'body', true, false).properties;
if (schema && Object.keys(schema).length > 0) {
out.requestBody = {
content: Object.fromEntries((consumes || ['application/json']).map((c) => [c, { schema }]))
};
}
} else {
// add form data mimetype
if (!consumes || consumes.length === 0) {
// change form mimetype based on meta property 'swaggerType'
consumes = internals.hasFileType(route) ? ['multipart/form-data'] : ['application/x-www-form-urlencoded'];
}
// set as formData
if (Utilities.hasJoiChildren(payloadJoi)) {
const schema = this.getSwaggerStructures(payloadJoi, 'formData', false, false).properties;
out.requestBody = {
content: Object.fromEntries(consumes.map((c) => [c, { schema }]))
};
} else {
this.testParameterError(payloadJoi, 'payload form-urlencoded', path);
}
}
// set required true/false for each path params
let pathStructures = this.getDefaultStructures();
const pathJoi = route.pathParams;
if (Utilities.hasJoiChildren(pathJoi)) {
pathStructures = this.getSwaggerStructures(pathJoi, 'path', false, false);
pathStructures.parameters.forEach((item) => {
// add required based on path pattern {prama} and {prama?}
if (item.required === undefined) {
if (path.indexOf('{' + item.name + '}') > -1) {
item.required = true;
}
if (path.indexOf('{' + item.name + '?}') > -1) {
delete item.required;
}
}
if (item.required === false) {
delete item.required;
}
if (!item.required) {
this.settings.log(
['validation', 'warning'],
'The ' +
path +
' params parameter {' +
item.name +
'} is set as optional. This will work in the UI, but is invalid in the swagger spec'
);
}
});
} else {
this.testParameterError(pathJoi, 'params', path);
}
// removes ? from {prama?} after we have set required/optional for path params
path = internals.cleanPathParameters(path);
let headerStructures = this.getDefaultStructures();
const headerJoi = route.headerParams;
if (Utilities.hasJoiChildren(headerJoi)) {
headerStructures = this.getSwaggerStructures(headerJoi, 'header', false, false);
} else {
this.testParameterError(headerJoi, 'headers', path);
}
// if the API has a user set accept header with a enum convert into the produces array
if (this.settings.acceptToProduce === true) {
headerStructures.parameters = headerStructures.parameters.filter((header) => {
if (header.name.toLowerCase() === 'accept') {
if (header.schema.enum) {
produces = Utilities.sortFirstItem(header.schema.enum, header.default);
return false;
}
}
return true;
});
}
let queryStructures = this.getDefaultStructures();
const queryJoi = route.queryParams;
if (Utilities.hasJoiChildren(queryJoi)) {
queryStructures = this.getSwaggerStructures(queryJoi, 'query', false, false);
} else {
this.testParameterError(queryJoi, 'query', path);
}
out.parameters = out.parameters.concat(
headerStructures.parameters,
pathStructures.parameters,
queryStructures.parameters
);
// if the api sets the content-type header parameter use that
if (internals.hasContentTypeHeader(out)) {
delete out.consumes;
}
//const name = out.operationId + method;
//userDefindedSchemas, defaultSchema, statusSchemas, useDefinitions, isAlt
const responses = this.responses.build(route.responses, route.responseSchema, route.responseStatus, true, false);
out.responses = Object.fromEntries(
Object.entries(responses).map(([response, { schema, examples, ...definition }]) => [
response,
Object.assign(
definition,
schema
? {
content: Object.fromEntries(
produces.map((mimeType) => [
mimeType,
Object.assign({ schema }, examples ? { example: examples } : {})
])
)
}
: {}
)
])
);
if (route.order) {
out['x-order'] = route.order;
}
Utilities.assignVendorExtensions(out, route);
if (route.deprecated !== null) {
out.deprecated = route.deprecated;
}
if (!pathObj[path]) {
pathObj[path] = {};
}
pathObj[path][method.toLowerCase()] = Utilities.deleteEmptyProperties(out);
});
openApi.paths = pathObj;
return openApi;
};
/**
* overload one object with another
*
* @param {Object} base
* @param {Object} priority
* @return {Object}
*/
internals.overload = function (base, priority) {
return priority || base;
};
/**
* does route have property swaggerType of file
*
* @param {Object} route
* @return {Boolean}
*/
internals.hasFileType = function (route) {
const payloadParamsString = JSON.stringify(route.payloadParams, (key, value) => {
// _currentJoi is a circular reference, introduced in Joi v11.0.0
return key === '_currentJoi' ? undefined : value;
});
return payloadParamsString.includes('swaggerType');
};
/**
* clear path parameters of optional char flag
*
* @param {string} path
* @return {string}
*/
internals.cleanPathParameters = function (path) {
return path.replace('?}', '}');
};
/**
* remove the base path from endpoint
*
* @param {string} path
* @param {string} basePath
* @param {Array} pathReplacements
* @return {string}
*/
internals.removeBasePath = function (path, basePath, pathReplacements) {
if (basePath !== '/' && path.startsWith(basePath)) {
path = path.replace(basePath, '');
path = Utilities.replaceInPath(path, ['endpoints'], pathReplacements);
}
return path;
};
/**
* does path parameters have a content-type header
*
* @param {string} path
* @return {boolean}
*/
internals.hasContentTypeHeader = function (path) {
return path.parameters.some(
(parameter) => parameter.in === 'header' && parameter.name.toLowerCase() === 'content-type'
);
};
/**
* builds an object containing different swagger structures that can be use to represent one object
*
* @param {Object} joiObj
* @param {string} parameterType
* @param {Boolean} useDefinitions
* @param {Boolean} isAlt
* @return {Object}
*/
internals.paths.prototype.getSwaggerStructures = function (joiObj, parameterType, useDefinitions, isAlt) {
let outProperties;
let outParameters;
if (joiObj) {
// name, joiObj, parent, parameterType, useDefinitions, isAlt
outProperties = this.properties.parseProperty(null, joiObj, null, parameterType, useDefinitions, isAlt);
outParameters = Parameters.fromProperties(outProperties, parameterType, this.settings);
}
return {
properties: outProperties || {},
parameters: outParameters || []
};
};
internals.paths.prototype.getDefaultStructures = function () {
return {
properties: {},
parameters: []
};
};
internals.paths.prototype.testParameterError = function (joiObj, parameterType, path) {
if (joiObj && !Utilities.hasJoiChildren(joiObj)) {
this.settings.log(
['validation', 'error'],
'The ' + path + ' route ' + parameterType + ' parameter was set, but not as a Joi.object() with child properties'
);
}
};