328 lines
9.1 KiB
JavaScript
328 lines
9.1 KiB
JavaScript
const doctrine = require('doctrine');
|
|
const parser = require('swagger-parser');
|
|
const YAML = require('yaml');
|
|
|
|
const {
|
|
hasEmptyProperty,
|
|
convertGlobPaths,
|
|
extractAnnotations,
|
|
mergeDeep,
|
|
extractYamlFromJsDoc,
|
|
isTagPresentInTags,
|
|
} = require('./utils');
|
|
|
|
/**
|
|
* Prepare the swagger/openapi specification object.
|
|
* @see https://github.com/OAI/OpenAPI-Specification/tree/master/versions
|
|
* @param {object} definition - The `definition` or `swaggerDefinition` from options.
|
|
* @returns {object} swaggerObject
|
|
*/
|
|
function prepare(definition) {
|
|
const swaggerObject = JSON.parse(JSON.stringify(definition));
|
|
const specificationTemplate = {
|
|
v2: [
|
|
'paths',
|
|
'definitions',
|
|
'responses',
|
|
'parameters',
|
|
'securityDefinitions',
|
|
],
|
|
v3: [
|
|
'paths',
|
|
'definitions',
|
|
'responses',
|
|
'parameters',
|
|
'securityDefinitions',
|
|
'components',
|
|
],
|
|
v4: ['components', 'channels'],
|
|
};
|
|
|
|
const getVersion = () => {
|
|
if (swaggerObject.asyncapi) {
|
|
return 'v4';
|
|
}
|
|
|
|
if (swaggerObject.openapi) {
|
|
return 'v3';
|
|
}
|
|
|
|
if (swaggerObject.swagger) {
|
|
return 'v2';
|
|
}
|
|
|
|
swaggerObject.swagger = '2.0';
|
|
return 'v2';
|
|
};
|
|
|
|
const version = getVersion();
|
|
|
|
specificationTemplate[version].forEach((property) => {
|
|
swaggerObject[property] = swaggerObject[property] || {};
|
|
});
|
|
|
|
swaggerObject.tags = swaggerObject.tags || [];
|
|
|
|
return swaggerObject;
|
|
}
|
|
|
|
/**
|
|
* @param {object} obj
|
|
* @param {string} ext
|
|
*/
|
|
function format(swaggerObject, ext) {
|
|
if (ext === '.yml' || ext === '.yaml') {
|
|
return YAML.stringify(swaggerObject);
|
|
}
|
|
return swaggerObject;
|
|
}
|
|
|
|
/**
|
|
* OpenAPI specification validator does not accept empty values for a few properties.
|
|
* Solves validator error: "Schema error should NOT have additional properties"
|
|
* @param {object} swaggerObject
|
|
* @returns {object} swaggerObject
|
|
*/
|
|
function clean(swaggerObject) {
|
|
for (const prop of [
|
|
'definitions',
|
|
'responses',
|
|
'parameters',
|
|
'securityDefinitions',
|
|
]) {
|
|
if (hasEmptyProperty(swaggerObject[prop])) {
|
|
delete swaggerObject[prop];
|
|
}
|
|
}
|
|
|
|
return swaggerObject;
|
|
}
|
|
|
|
/**
|
|
* Parse the swagger object and remove useless properties if necessary.
|
|
*
|
|
* @param {object} swaggerObject - Swagger object from parsing the api files.
|
|
* @returns {object} The specification.
|
|
*/
|
|
function finalize(swaggerObject, options) {
|
|
let specification = swaggerObject;
|
|
parser.parse(swaggerObject, (err, api) => {
|
|
if (!err) {
|
|
specification = api;
|
|
}
|
|
});
|
|
|
|
if (specification.openapi) {
|
|
specification = clean(specification);
|
|
}
|
|
|
|
return format(specification, options.format);
|
|
}
|
|
|
|
/**
|
|
* @param {object} swaggerObject
|
|
* @param {object} annotation
|
|
* @param {string} property
|
|
*/
|
|
function organize(swaggerObject, annotation, property) {
|
|
// Root property on purpose.
|
|
// @see https://github.com/OAI/OpenAPI-Specification/blob/master/proposals/002_Webhooks.md#proposed-solution
|
|
if (property === 'x-webhooks') {
|
|
swaggerObject[property] = mergeDeep(
|
|
swaggerObject[property],
|
|
annotation[property]
|
|
);
|
|
}
|
|
|
|
// Other extensions can be in varying places depending on different vendors and opinions.
|
|
// The following return makes it so that they are not put in `paths` in the last case.
|
|
// New specific extensions will need to be handled on case-by-case if to be included in `paths`.
|
|
if (property.startsWith('x-')) return;
|
|
|
|
const commonProperties = [
|
|
'components',
|
|
'consumes',
|
|
'produces',
|
|
'paths',
|
|
'schemas',
|
|
'securityDefinitions',
|
|
'responses',
|
|
'parameters',
|
|
'definitions',
|
|
'channels',
|
|
];
|
|
if (commonProperties.includes(property)) {
|
|
for (const definition of Object.keys(annotation[property])) {
|
|
swaggerObject[property][definition] = mergeDeep(
|
|
swaggerObject[property][definition],
|
|
annotation[property][definition]
|
|
);
|
|
}
|
|
} else if (property === 'tags') {
|
|
const { tags } = annotation;
|
|
|
|
if (Array.isArray(tags)) {
|
|
for (const tag of tags) {
|
|
if (!isTagPresentInTags(tag, swaggerObject.tags)) {
|
|
swaggerObject.tags.push(tag);
|
|
}
|
|
}
|
|
} else if (!isTagPresentInTags(tags, swaggerObject.tags)) {
|
|
swaggerObject.tags.push(tags);
|
|
}
|
|
} else {
|
|
// Paths which are not defined as "paths" property, starting with a slash "/"
|
|
swaggerObject.paths[property] = mergeDeep(
|
|
swaggerObject.paths[property],
|
|
annotation[property]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {object} options
|
|
* @returns {object} swaggerObject
|
|
*/
|
|
function build(options) {
|
|
YAML.defaultOptions.keepCstNodes = true;
|
|
|
|
// Get input definition and prepare the specification's skeleton
|
|
const definition = options.swaggerDefinition || options.definition;
|
|
const specification = prepare(definition);
|
|
const yamlDocsAnchors = new Map();
|
|
const yamlDocsErrors = [];
|
|
const yamlDocsReady = [];
|
|
|
|
for (const filePath of convertGlobPaths(options.apis)) {
|
|
const {
|
|
yaml: yamlAnnotations,
|
|
jsdoc: jsdocAnnotations,
|
|
} = extractAnnotations(filePath, options.encoding);
|
|
|
|
if (yamlAnnotations.length) {
|
|
for (const annotation of yamlAnnotations) {
|
|
const parsed = Object.assign(YAML.parseDocument(annotation), {
|
|
filePath,
|
|
});
|
|
|
|
const anchors = parsed.anchors.getNames();
|
|
if (anchors.length) {
|
|
for (const anchor of anchors) {
|
|
yamlDocsAnchors.set(anchor, parsed);
|
|
}
|
|
} else if (parsed.errors && parsed.errors.length) {
|
|
// Attach the relevent yaml section to the error for verbose logging
|
|
parsed.errors.forEach((err) => {
|
|
err.annotation = annotation;
|
|
});
|
|
yamlDocsErrors.push(parsed);
|
|
} else {
|
|
yamlDocsReady.push(parsed);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (jsdocAnnotations.length) {
|
|
for (const annotation of jsdocAnnotations) {
|
|
const jsDocComment = doctrine.parse(annotation, { unwrap: true });
|
|
for (const doc of extractYamlFromJsDoc(jsDocComment)) {
|
|
const parsed = Object.assign(YAML.parseDocument(doc), { filePath });
|
|
|
|
const anchors = parsed.anchors.getNames();
|
|
if (anchors.length) {
|
|
for (const anchor of anchors) {
|
|
yamlDocsAnchors.set(anchor, parsed);
|
|
}
|
|
} else if (parsed.errors && parsed.errors.length) {
|
|
// Attach the relevent yaml section to the error for verbose logging
|
|
parsed.errors.forEach((err) => {
|
|
err.annotation = doc;
|
|
});
|
|
yamlDocsErrors.push(parsed);
|
|
} else {
|
|
yamlDocsReady.push(parsed);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (yamlDocsErrors.length) {
|
|
for (const docWithErr of yamlDocsErrors) {
|
|
const errsToDelete = [];
|
|
docWithErr.errors.forEach((error, index) => {
|
|
if (error.name === 'YAMLReferenceError') {
|
|
// This should either be a smart regex or ideally a YAML library method using the error.range.
|
|
// The following works with both pretty and not pretty errors.
|
|
const refErr = error.message
|
|
.split('Aliased anchor not found: ')
|
|
.filter((a) => a)
|
|
.join('')
|
|
.split(' at line')[0];
|
|
const anchor = yamlDocsAnchors.get(refErr);
|
|
const anchorString = anchor.cstNode.toString();
|
|
const originalString = docWithErr.cstNode.toString();
|
|
const readyDocument = YAML.parseDocument(
|
|
`${anchorString}\n${originalString}`
|
|
);
|
|
|
|
yamlDocsReady.push(readyDocument);
|
|
errsToDelete.push(index);
|
|
}
|
|
});
|
|
// reverse sort the deletion array so we always delete from the end
|
|
errsToDelete.sort((a, b) => b - a);
|
|
|
|
// Cleanup solved errors in order to allow for parser to pass through.
|
|
for (const errIndex of errsToDelete) {
|
|
docWithErr.errors.splice(errIndex, 1);
|
|
}
|
|
}
|
|
|
|
// Format errors into a printable/throwable string
|
|
const errReport = yamlDocsErrors
|
|
.filter((doc) => doc.errors.length)
|
|
.map(({ errors, filePath }) => {
|
|
let str = `Error in ${filePath} :\n`;
|
|
if (options.verbose) {
|
|
str += errors
|
|
.map(
|
|
(e) =>
|
|
`${e.toString()}\nImbedded within:\n\`\`\`\n ${e.annotation.replace(
|
|
/\n/g,
|
|
'\n '
|
|
)}\n\`\`\``
|
|
)
|
|
.join('\n');
|
|
} else {
|
|
str += errors.map((e) => e.toString()).join('\n');
|
|
}
|
|
return str;
|
|
})
|
|
.filter((error) => !!error);
|
|
|
|
if (errReport.length) {
|
|
if (options.failOnErrors) {
|
|
throw new Error(errReport);
|
|
}
|
|
// Place to provide feedback for errors. Previously throwing, now reporting only.
|
|
console.info(
|
|
'Not all input has been taken into account at your final specification.'
|
|
);
|
|
|
|
console.error(`Here's the report: \n\n\n ${errReport}`);
|
|
}
|
|
}
|
|
|
|
for (const document of yamlDocsReady) {
|
|
const parsedDoc = document.toJSON();
|
|
for (const property in parsedDoc) {
|
|
organize(specification, parsedDoc, property);
|
|
}
|
|
}
|
|
|
|
return finalize(specification, options);
|
|
}
|
|
|
|
module.exports = { prepare, build, organize, finalize, format };
|