swagger-jsdoc
Version:
Generates swagger doc based on JSDoc
295 lines (258 loc) • 8.22 kB
JavaScript
import doctrine from 'doctrine';
import parser from 'swagger-parser';
import YAML from 'yaml';
import {
hasEmptyProperty,
convertGlobPaths,
extractAnnotations,
extractYamlFromJsDoc,
isTagPresentInTags,
} from './utils.js';
/**
* Prepare the swagger/openapi specification object.
* @see https://github.com/OAI/OpenAPI-Specification/tree/master/versions
* @param {object} options The library input options.
* @returns {object} swaggerObject
*/
export function prepare(options) {
let version;
const swaggerObject = options.swaggerDefinition || options.definition;
const specificationTemplate = {
v2: [
'paths',
'definitions',
'responses',
'parameters',
'securityDefinitions',
],
v3: [
'paths',
'definitions',
'responses',
'parameters',
'securityDefinitions',
'components',
],
};
if (swaggerObject.openapi) {
version = 'v3';
} else if (swaggerObject.swagger) {
version = 'v2';
} else {
version = 'v2';
swaggerObject.swagger = '2.0';
}
specificationTemplate[version].forEach((property) => {
swaggerObject[property] = swaggerObject[property] || {};
});
swaggerObject.tags = swaggerObject.tags || [];
return swaggerObject;
}
/**
* @param {object} obj
* @param {string} ext
*/
export 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
*/
export 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.
*/
export function finalize(swaggerObject, options) {
let specification = swaggerObject;
parser.parse(swaggerObject, (err, api) => {
if (!err) {
specification = api;
}
});
if (specification.openapi) {
specification = clean(specification);
}
if (options && options.format) {
specification = format(specification, options.format);
}
return specification;
}
/**
* @param {object} swaggerObject
* @param {Array<object>} annotations
* @returns {object} swaggerObject
*/
export function organize(swaggerObject, annotations) {
for (const annotation of annotations) {
for (const property in annotation) {
// 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] = 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-')) continue;
const commonProperties = [
'components',
'consumes',
'produces',
'paths',
'schemas',
'securityDefinitions',
'responses',
'parameters',
'definitions',
];
if (commonProperties.includes(property)) {
for (const definition of Object.keys(annotation[property])) {
swaggerObject[property][definition] = {
...swaggerObject[property][definition],
...annotation[property][definition],
};
}
} else if (property === 'tags') {
if (swaggerObject.tags === undefined) {
swaggerObject.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] = {
...swaggerObject.paths[property],
...annotation[property],
};
}
}
}
return swaggerObject;
}
/**
* @param {object} options
* @returns {object} swaggerObject
*/
export async function extract(options) {
if (
!options ||
!options.apis ||
options.apis.length === 0 ||
Array.isArray(options.apis) === false
) {
throw new Error(
'Bad input parameter: options is required, as well as options.apis[]'
);
}
YAML.defaultOptions.keepCstNodes = true;
const yamlDocsAnchors = new Map();
const yamlDocsErrors = [];
const yamlDocsReady = [];
for (const filePath of convertGlobPaths(options.apis)) {
const {
yaml: yamlAnnotations,
jsdoc: jsdocAnnotations,
} = await extractAnnotations(filePath, options.encoding);
if (yamlAnnotations.length) {
for (const annotation of yamlAnnotations) {
const parsed = YAML.parseDocument(annotation);
const anchors = parsed.anchors.getNames();
if (anchors.length) {
for (const anchor of anchors) {
yamlDocsAnchors.set(anchor, parsed);
}
} else if (parsed.errors && parsed.errors.length) {
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 = YAML.parseDocument(doc);
const anchors = parsed.anchors.getNames();
if (anchors.length) {
for (const anchor of anchors) {
yamlDocsAnchors.set(anchor, parsed);
}
} else if (parsed.errors && parsed.errors.length) {
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);
}
// Cleanup solved errors in order to allow for parser to pass through.
for (const errIndex of errsToDelete) {
docWithErr.errors.splice(errIndex, 1);
}
});
}
const errReport = yamlDocsErrors
.map(({ errors }) => errors.join('\n'))
.filter((error) => !!error);
if (errReport.length) {
// 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}`);
}
}
return yamlDocsReady.map((doc) => doc.toJSON());
}