hapi-swagger
Version:
A swagger documentation UI generator plugin for hapi
789 lines (687 loc) • 24.3 kB
JavaScript
const Hoek = require('@hapi/hoek');
const Definitions = require('../lib/definitions');
const Utilities = require('../lib/utilities');
const internals = {};
/**
* constructor for properties
*
* @param {Object} settings
* @param {Object} definitionCollection
* @param {Object} altDefinitionCollection
* @param {Array} definitionCache
*/
exports =
module.exports =
internals.properties =
function (settings, definitionCollection, altDefinitionCollection, definitionCache) {
this.settings = settings;
this.definitionCollection = definitionCollection;
this.altDefinitionCollection = altDefinitionCollection;
// definitionCache has to be an array of two WeakMaps
this.definitionCache = definitionCache;
this.definitions = new Definitions(settings);
// swagger type can be 'string', 'number', 'integer', 'boolean', 'array' or 'file'
this.simpleTypePropertyMap = {
boolean: { type: 'boolean' },
binary: { type: 'string', format: 'binary' },
date: { type: 'string', format: 'date' },
number: { type: 'number' },
string: { type: 'string' }
};
this.complexTypePropertyMap = {
any: { type: 'string' },
array: { type: 'array' },
func: { type: 'string' },
object: { type: 'object' },
alternatives: { type: 'alternatives' }
};
// merge
this.propertyMap = Hoek.applyToDefaults(this.simpleTypePropertyMap, this.complexTypePropertyMap);
//this.allowedProps = ['$ref','format','title','description','default','multipleOf','maximum','exclusiveMaximum','minimum','exclusiveMinimum','maxLength','minLength','pattern','maxItems','minItems','uniqueItems','maxProperties','minProperties','required','enum','type','items','allOf','properties','additionalProperties','discriminator','readOnly','xml','externalDocs','example'];
// add none swagger property needed to flag touched state of required property
//this.allowedProps.push('optional');
};
/**
* parse Joi validators object into a swagger property
*
* @param {string} name
* @param {Object} joiObj
* @param {string} parent
* @param {string} parameterType
* @param {Boolean} useDefinitions
* @param {Boolean} isAlt
* @return {Object}
*/
internals.properties.prototype.parseProperty = function (name, joiObj, parent, parameterType, useDefinitions, isAlt) {
let property = { type: 'void' };
// if wrong format or forbidden - return undefined
if (!Utilities.isJoi(joiObj)) {
return undefined;
}
if (Hoek.reach(joiObj, '_flags.presence') === 'forbidden') {
return undefined;
}
if (Utilities.getJoiMetaProperty(joiObj, 'swaggerHidden') === true) {
return undefined;
}
// default the use of definitions to true
if (useDefinitions === undefined || useDefinitions === null) {
useDefinitions = true;
}
// get name from joi label if its not a path
if (!name && parameterType !== 'path') {
const joiLabel = Utilities.getJoiLabel(joiObj);
if (joiLabel) {
name = joiLabel;
}
}
// add correct type and format by mapping
let joiType = joiObj.type.toLowerCase();
// for Joi extension, use the any type
if (!(joiType in this.propertyMap)) {
joiType = 'any';
}
const map = this.propertyMap[joiType];
property.type = map.type;
if (map.format) {
property.format = map.format;
}
// this must be done even if the caching returns something
this.setRequiredAndOptionalOnParent(name, parent, joiObj);
// Joi object caching - speeds up parsing
let joiCache;
if (useDefinitions && Array.isArray(this.definitionCache) && property.type === 'object') {
// select WeakMap for current definition collection
joiCache = isAlt === true ? this.definitionCache[1] : this.definitionCache[0];
if (joiCache.has(joiObj)) {
return joiCache.get(joiObj);
}
}
property = this.parsePropertyMetadata(property, name, parent, joiObj);
// add enum
const describe = joiObj.describe();
const allowDropdown = !property['x-meta'] || !property['x-meta'].disableDropdown;
if (allowDropdown && Array.isArray(describe.allow) && describe.allow.length) {
if (
describe.allow[0] &&
typeof describe.allow[0] === 'object' &&
describe.allow[0].override === true &&
Object.keys(describe.allow[0]).length === 1
) {
describe.allow.shift();
}
// filter out empty values and arrays
const enums = describe.allow.filter((item) => {
return item !== '' && item !== null;
});
if (enums.length > 0) {
property.enum = enums;
}
if (this.settings.OAS === 'v3.0' && describe.allow.some((item) => item === null)) {
property.nullable = true;
}
}
// add number properties
if (property.type === 'string') {
property = this.parseString(property, joiObj);
if (useDefinitions === true && property.enum) {
const refName = this.definitions.append(name, property, this.getDefinitionCollection(isAlt), this.settings);
property = {
$ref: this.getDefinitionRef(isAlt) + refName
};
}
}
// add number properties
if (property.type === 'number') {
property = this.parseNumber(property, joiObj);
}
// add date properties
if (property.type === 'string' && property.format === 'date') {
property = this.parseDate(property, joiObj);
}
// add object child properties
if (property.type === 'object') {
if (Utilities.hasJoiChildren(joiObj) || Utilities.hasJoiDescription(joiObj)) {
property = this.parseObject(property, joiObj, name, parameterType, useDefinitions, isAlt);
if (useDefinitions === true) {
const refName = this.definitions.append(name, property, this.getDefinitionCollection(isAlt), this.settings);
property = {
$ref: this.getDefinitionRef(isAlt) + refName
};
}
} else if (joiObj.$_terms.patterns) {
const objectPattern = joiObj.$_terms.patterns[0];
let patternName = 'string';
if (objectPattern.schema) {
patternName =
objectPattern.schema.$_terms.examples && objectPattern.schema.$_terms.examples[0]
? objectPattern.schema.$_terms.examples[0]
: objectPattern.schema.type;
}
property.properties = {
[patternName]: this.parseProperty(
patternName,
objectPattern.rule,
property,
parameterType,
useDefinitions,
isAlt
)
};
} else {
// default empty object
property.properties = {};
if (useDefinitions === true) {
const objectSchema = { type: 'object', properties: {} };
const refName = this.definitions.append(name, objectSchema, this.getDefinitionCollection(isAlt), this.settings);
property = {
$ref: this.getDefinitionRef(isAlt) + refName
};
}
}
const allowUnknown = joiObj._flags.allowUnknown;
if (allowUnknown !== undefined && !allowUnknown) {
property.additionalProperties = false;
}
}
// add array properties
if (property.type === 'array') {
property = this.parseArray(property, joiObj, name, parameterType, useDefinitions, isAlt);
if (useDefinitions === true) {
const refName = this.definitions.append(name, property, this.getDefinitionCollection(isAlt), this.settings);
property = {
$ref: this.getDefinitionRef(isAlt) + refName
};
}
}
// add alternatives properties
if (property.type === 'alternatives') {
property = this.parseAlternatives(property, joiObj, name, parameterType, useDefinitions);
}
// convert property to file upload, if indicated by meta property
if (Utilities.getJoiMetaProperty(joiObj, 'swaggerType') === 'file') {
if (this.settings.OAS === 'v2') {
property.type = 'file';
property.in = 'formData';
} else {
property.type = 'string';
property.format = 'binary';
}
}
property = Utilities.deleteEmptyProperties(property);
if (joiCache && property.$ref) {
joiCache.set(joiObj, property);
}
return property;
};
/**
* set required and optional properties on parent if they do not exist yet
*
* @param {string} name
* @param {string} parent
* @param {Object} joiObj
* @returns {void}
*/
internals.properties.prototype.setRequiredAndOptionalOnParent = function (name, parent, joiObj) {
// nothing needs to be done if the parent or the property doesn't exist
if (!parent || !name) {
return;
}
const describe = joiObj.describe();
if (Hoek.reach(joiObj, '_flags.presence')) {
if (parent.required === undefined) {
parent.required = [];
}
if (this.settings.OAS === 'v2' && parent.optional === undefined) {
parent.optional = [];
}
if (Hoek.reach(joiObj, '_flags.presence') === 'required') {
if (!parent.required.includes(name)) {
parent.required.push(name);
}
}
if (this.settings.OAS === 'v2' && Hoek.reach(joiObj, '_flags.presence') === 'optional') {
if (!parent.optional.includes(name)) {
parent.optional.push(name);
}
}
}
// interdependencies are not yet supported https://github.com/OAI/OpenAPI-Specification/issues/256
if (describe.whens && describe.whens.length > 0) {
describe.whens.forEach((test) => {
if (Hoek.reach(test, 'then.flags.presence') === 'required') {
if (parent.required === undefined) {
parent.required = [];
}
if (!parent.required.includes(name)) {
parent.required.push(name);
}
}
});
}
};
/**
* parse property metadata
*
* @param {Object} property
* @param {Object} joiObj
* @return {Object}
*/
internals.properties.prototype.parsePropertyMetadata = function (property, name, parent, joiObj) {
const describe = joiObj.describe();
// add common properties
property.description = Hoek.reach(joiObj, '_flags.description');
property.notes = Hoek.reach(joiObj, '$_terms.notes');
property.tags = Hoek.reach(joiObj, '$_terms.tags');
property.title = Utilities.getJoiMetaProperty(joiObj, 'title');
// add extended properties not part of openAPI spec
if (this.settings.xProperties === true) {
internals.convertRules(property, describe.rules, ['unit'], 'x-format');
const exampleObj = Hoek.reach(joiObj, '$_terms.examples.0');
/* $lab:coverage:off$ */
// exampleObj.value != null will coerce undefined and null
// eslint-disable-next-line no-eq-null
property.example = exampleObj && exampleObj.value != null ? exampleObj.value : exampleObj;
/* $lab:coverage:on$ */
const xMeta = Hoek.reach(joiObj, '$_terms.metas.0');
if (xMeta) {
const meta = Hoek.clone(xMeta);
delete meta.swaggerLabel;
if (Object.keys(meta).length) {
if ('xml' in meta) {
property.xml = meta.xml;
delete meta.xml;
}
property['x-meta'] = meta;
}
}
}
this.setRequiredAndOptionalOnParent(name, parent, joiObj);
let propertyDefault = Hoek.reach(joiObj, '_flags.default');
// allow for function calls
if (Utilities.isFunction(propertyDefault)) {
propertyDefault = propertyDefault();
}
if (property.type === 'array' && Array.isArray(propertyDefault) && propertyDefault.length === 0) {
// Default should not be set for empty arrays
} else {
property.default = propertyDefault;
}
return property;
};
/**
* parse string property
*
* @param {Object} property
* @param {Object} joiObj
* @return {Object}
*/
internals.properties.prototype.parseString = function (property, joiObj) {
const describe = joiObj.describe();
if (describe.type !== 'date') {
property.minLength = internals.getArgByName(describe.rules, 'min', 'limit');
property.maxLength = internals.getArgByName(describe.rules, 'max', 'limit');
}
// add regex
joiObj._rules.forEach((test) => {
if (Utilities.isObject(test.args) && test.args.regex) {
if (Utilities.isRegex(test.args.regex)) {
// get the regex source (as opposed to regex.toString()) so
// we exclude the surrounding '/' delimeters as well as any
// trailing flags (g, i, m)
property.pattern = test.args.regex.source;
} else {
property.pattern = test.args.regex;
}
}
});
// add extended properties not part of openAPI spec
if (this.settings.xProperties === true) {
internals.convertRules(property, describe.rules, ['insensitive', 'length'], 'x-constraint');
internals.convertRules(
property,
describe.rules,
['creditCard', 'alphanum', 'token', 'email', 'ip', 'uri', 'guid', 'hex', 'hostname', 'isoDate'],
'x-format'
);
internals.convertRules(property, describe.rules, ['case', 'trim'], 'x-convert');
}
return property;
};
/**
* parse number property
*
* @param {Object} property
* @param {Object} joiObj
* @return {Object}
*/
internals.properties.prototype.parseNumber = function (property, joiObj) {
const describe = joiObj.describe();
property.minimum = internals.getArgByName(describe.rules, 'min');
property.maximum = internals.getArgByName(describe.rules, 'max');
if (internals.hasPropertyByName(describe.rules, 'integer')) {
property.type = 'integer';
}
if (Array.isArray(describe.metas)) {
const meta = describe.metas.find((meta) => {
return typeof meta.format === 'string';
});
if (meta) {
property.format = meta.format;
}
}
// add extended properties not part of openAPI spec
if (this.settings.xProperties === true) {
internals.convertRules(
property,
describe.rules,
['greater', 'less', 'precision', 'multiple', 'sign'],
'x-constraint'
);
}
return property;
};
/**
* parse date property - adds additional schema info based on Joi date formats
*
* @param {Object} input
* @param {Object} joiObj
* @return {Object}
*/
internals.properties.prototype.parseDate = function (input, joiObj) {
const dateFormat = Hoek.reach(joiObj, '_flags.format');
const property = Hoek.clone(input);
if (['timestamp', 'javascript'].includes(dateFormat)) {
// Seems like the exact name of the format differs for different versions of Joi
// Javascript is what is set by Joi.date().timestamp()
property.type = 'integer';
delete property.format;
} else if (dateFormat === 'iso') {
// Joi.date().iso()
property.type = 'string';
property.format = 'date-time';
}
return property;
};
/**
* parse object property
*
* @param {Object} property
* @param {Object} joiObj
* @param {string} name
* @param {Boolean} useDefinitions
* @param {Boolean} isAlt
* @return {Object}
*/
internals.properties.prototype.parseObject = function (property, joiObj, name, parameterType, useDefinitions, isAlt) {
property.properties = {};
joiObj._ids._byKey.forEach((obj) => {
const keyName = obj.id;
let itemName = obj.id;
const joiChildObj = obj.schema;
// get name form label if set
if (Utilities.getJoiLabel(joiChildObj)) {
itemName = Utilities.getJoiLabel(joiChildObj);
}
//name, joiObj, parent, parameterType, useDefinitions, isAlt
const parsedProperty = this.parseProperty(itemName, joiChildObj, property, parameterType, useDefinitions, isAlt);
if (parsedProperty) {
delete parsedProperty.name;
property.properties[keyName] = parsedProperty;
}
// switch references if naming has changed
if (keyName !== itemName) {
property.required = Utilities.replaceValue(property.required, itemName, keyName);
if (this.settings.OAS === 'v2') {
property.optional = Utilities.replaceValue(property.optional, itemName, keyName);
}
}
});
return property;
};
/**
* parse array property
*
* @param {Object} property
* @param {Object} joiObj
* @param {string} name
* @param {string} parameterType,
* @param {Boolean} useDefinitions
* @param {Boolean} isAlt
* @return {Object}
*/
internals.properties.prototype.parseArray = function (property, joiObj, name, parameterType, useDefinitions, isAlt) {
const describe = joiObj.describe();
property.minItems = internals.getArgByName(describe.rules, 'min');
property.maxItems = internals.getArgByName(describe.rules, 'max');
// add extended properties not part of openAPI spec
if (this.settings.xProperties === true) {
internals.convertRules(property, describe.rules, ['length', 'unique'], 'x-constraint');
if (describe.flags && describe.flags.sparse) {
internals.addToPropertyObject(property, 'x-constraint', 'sparse', true);
}
if (describe.flags && describe.flags.single) {
internals.addToPropertyObject(property, 'x-constraint', 'single', true);
}
}
// default the items with type:string
property.items = {
type: 'string'
};
// set swaggers collectionFormat to one that works with hapi
if ((parameterType === 'query' || parameterType === 'formData') && this.settings.OAS === 'v2') {
property.collectionFormat = 'multi';
}
// if there are multiple types, use anyOf, otherwise grab the first one
const arrayItemTypes = joiObj.$_terms.items;
const firstArrayItem = Utilities.first(arrayItemTypes);
if (arrayItemTypes.length > 1 && this.settings.OAS === 'v3.0') {
delete property.items.type;
property.items.anyOf = arrayItemTypes.map((item) => {
return this.parseProperty(Utilities.getJoiLabel(item), item, property, parameterType, useDefinitions, isAlt);
});
} else if (firstArrayItem) {
// get name of item if it has one
let itemName;
if (Utilities.getJoiLabel(firstArrayItem)) {
itemName = Utilities.getJoiLabel(firstArrayItem);
}
//name, joiObj, parent, parameterType, useDefinitions, isAlt
const arrayItemProperty = this.parseProperty(
itemName,
firstArrayItem,
property,
parameterType,
useDefinitions,
isAlt
);
if (this.simpleTypePropertyMap[firstArrayItem.type.toLowerCase()]) {
// map simple types directly
property.items = {};
for (const key in arrayItemProperty) {
property.items[key] = arrayItemProperty[key];
}
} else {
property.items = arrayItemProperty;
}
}
property.name = name;
return property;
};
/**
* parse alternatives property
*
* @param {Object} property
* @param {Object} joiObj
* @param {string} name
* @param {string} parameterType
* @param {Boolean} useDefinitions
* @return {Object}
*/
internals.properties.prototype.parseAlternatives = function (property, joiObj, name, parameterType, useDefinitions) {
const buildAlternativesArray = (schemas) => {
return schemas
.map((schema) => {
const childMetaName = Utilities.getJoiMetaProperty(schema, 'swaggerLabel');
const altName = childMetaName || Utilities.getJoiLabel(schema) || name;
//name, joiObj, parent, parameterType, useDefinitions, isAlt
return this.parseProperty(altName, schema, property, parameterType, useDefinitions, true);
})
.filter((obj) => obj);
};
// convert .try() alternatives structures
if (Hoek.reach(joiObj, '$_terms.matches.0.schema')) {
if (this.settings.OAS === 'v2') {
// add first into definitionCollection
const child = joiObj.$_terms.matches[0].schema;
const childName = Utilities.getJoiLabel(joiObj);
//name, joiObj, parent, parameterType, useDefinitions, isAlt
property = this.parseProperty(childName, child, property, parameterType, useDefinitions, false);
// create the alternatives without appending to the definitionCollection
// if (property && this.settings.xProperties === true) {
if (this.settings.xProperties === true) {
const altArray = buildAlternativesArray(joiObj.$_terms.matches.map((obj) => obj.schema));
property['x-alternatives'] = Hoek.clone(altArray);
}
} else {
property.anyOf = buildAlternativesArray(joiObj.$_terms.matches.map((obj) => obj.schema));
delete property.type;
}
}
// convert .when() alternatives structures
else {
if (this.settings.OAS === 'v2') {
// add first into definitionCollection
const child = joiObj.$_terms.matches[0].then;
const childMetaName = Utilities.getJoiMetaProperty(child, 'swaggerLabel');
const childName = childMetaName || Utilities.getJoiLabel(child) || name;
//name, joiObj, parent, parameterType, useDefinitions, isAlt
property = this.parseProperty(childName, child, property, parameterType, useDefinitions, false);
// create the alternatives without appending to the definitionCollection
if (property && this.settings.xProperties === true) {
const altArray = buildAlternativesArray(
joiObj.$_terms.matches.reduce((res, obj) => {
obj.then && res.push(obj.then);
obj.otherwise && res.push(obj.otherwise);
return res;
}, [])
);
property['x-alternatives'] = Hoek.clone(altArray);
}
} else {
property.anyOf = buildAlternativesArray(
joiObj.$_terms.matches.reduce((res, obj) => {
obj.then && res.push(obj.then);
obj.otherwise && res.push(obj.otherwise);
return res;
}, [])
);
delete property.type;
}
}
return property;
};
/**
* selects the correct definition collection
*
* @param {Boolean} isAlt
* @return {Object}
*/
internals.properties.prototype.getDefinitionCollection = function (isAlt) {
return isAlt === true ? this.altDefinitionCollection : this.definitionCollection;
};
/**
* selects the correct definition reference
*
* @param {Boolean} isAlt
* @return {string}
*/
internals.properties.prototype.getDefinitionRef = function (isAlt) {
if (isAlt === true) {
return '#/x-alt-definitions/';
}
if (this.settings.OAS === 'v2') {
return '#/definitions/';
}
return '#/components/schemas/';
};
/**
* coverts rules into property objects
*
* @param {Object} property
* @param {Array} rules
* @param {Array} ruleNames
* @param {string} groupName
*/
internals.convertRules = function (property, rules, ruleNames, groupName) {
ruleNames.forEach((ruleName) => {
internals.appendToPropertyObject(property, rules, groupName, ruleName);
});
};
/**
* appends a name item to object on a property
*
* @param {Object} property
* @param {Array} rules
* @param {string} groupName
* @param {string} ruleName
*/
internals.appendToPropertyObject = function (property, rules, groupName, ruleName) {
if (internals.hasPropertyByName(rules, ruleName)) {
let value = internals.getArgByName(rules, ruleName);
if (Utilities.isObject(value) && Utilities.hasProperties(value) === false) {
value = undefined;
}
internals.addToPropertyObject(property, groupName, ruleName, value);
}
};
/**
* add a name item to object on a property
*
* @param {Object} property
* @param {string} groupName
* @param {string} ruleName
* @param {string} value
*/
internals.addToPropertyObject = function (property, groupName, ruleName, value) {
if (!property[groupName]) {
property[groupName] = {};
}
property[groupName][ruleName] = value !== undefined ? value : true;
};
/**
* return the value of an item in array of object by name - structure [ { name: 'value', arg: 'value' } ]
*
* @param {Array} array
* @param {string} name
* @return {String || Undefined}
*/
internals.getArgByName = function (array, name, key = undefined) {
if (Array.isArray(array)) {
let i = array.length;
while (i--) {
if (array[i].name === name) {
if (array[i].args) {
if (key) {
return array[i].args[key];
}
// If not specified, return first
return Object.values(array[i].args)[0];
}
// TODO check why we return true if we must return a string or undefined
return true;
}
}
}
return undefined;
};
/**
* return existence of an item in array of - structure [ { name: 'value' } ]
*
* @param {Array} array
* @param {string} name
* @return {Boolean}
*/
internals.hasPropertyByName = function (array, name) {
return Array.isArray(array) && array.some((obj) => obj.name === name);
};