2021-08-19 16:49:33 +02:00

1866 lines
54 KiB
JavaScript
Executable File

'use strict';
/**
* Documentation.js service
*
* @description: A set of functions similar to controller's actions to avoid code duplication.
*/
const fs = require('fs');
const path = require('path');
const _ = require('lodash');
const moment = require('moment');
const pathToRegexp = require('path-to-regexp');
// FIXME
/* eslint-disable import/extensions */
const defaultSettings = require('../config/default-config');
const defaultComponents = require('./utils/components.json');
const form = require('./utils/forms.json');
const parametersOptions = require('./utils/parametersOptions.json');
// keys to pick from the extended config
const defaultSettingsKeys = Object.keys(defaultSettings);
const customIsEqual = (obj1, obj2) => _.isEqualWith(obj1, obj2, customComparator);
const customComparator = (value1, value2) => {
if (_.isArray(value1) && _.isArray(value2)) {
if (value1.length !== value2.length) {
return false;
}
return value1.every(el1 => value2.findIndex(el2 => customIsEqual(el1, el2)) >= 0);
}
};
module.exports = ({ strapi }) => ({
areObjectsEquals: function(obj1, obj2) {
// stringify to remove nested empty objects
return customIsEqual(this.cleanObject(obj1), this.cleanObject(obj2));
},
cleanObject: obj => JSON.parse(JSON.stringify(obj)),
arrayCustomizer: (objValue, srcValue) => {
if (_.isArray(objValue)) return objValue.concat(srcValue);
},
checkIfAPIDocNeedsUpdate: function(apiName) {
const prevDocumentation = this.createDocObject(this.retrieveDocumentation(apiName));
const currentDocumentation = this.createDocObject(this.createDocumentationFile(apiName, false));
return !this.areObjectsEquals(prevDocumentation, currentDocumentation);
},
/**
* Check if the documentation folder with its related version of an API exists
* @param {String} apiName
*/
checkIfDocumentationFolderExists: function(apiName) {
try {
fs.accessSync(this.getDocumentationPath(apiName));
return true;
} catch (err) {
return false;
}
},
checkIfPluginDocumentationFolderExists: function(pluginName) {
try {
fs.accessSync(this.getPluginDocumentationPath(pluginName));
return true;
} catch (err) {
return false;
}
},
checkIfPluginDocNeedsUpdate: function(pluginName) {
const prevDocumentation = this.createDocObject(this.retrieveDocumentation(pluginName, true));
const currentDocumentation = this.createDocObject(
this.createPluginDocumentationFile(pluginName, false)
);
return !this.areObjectsEquals(prevDocumentation, currentDocumentation);
},
checkIfApiDefaultDocumentationFileExist: function(apiName, docName) {
try {
fs.accessSync(this.getAPIOverrideDocumentationPath(apiName, docName));
return true;
} catch (err) {
return false;
}
},
checkIfPluginDefaultDocumentFileExists: function(pluginName, docName) {
try {
fs.accessSync(this.getPluginOverrideDocumentationPath(pluginName, docName));
return true;
} catch (err) {
return false;
}
},
/**
* Check if the documentation folder exists in the documentation plugin
* @returns {Boolean}
*/
checkIfMergedDocumentationFolderExists: function() {
try {
fs.accessSync(this.getMergedDocumentationPath());
return true;
} catch (err) {
return false;
}
},
/**
* Recursively create missing directories
* @param {String} targetDir
*
*/
createDocumentationDirectory: function(targetDir) {
const sep = path.sep;
const initDir = path.isAbsolute(targetDir) ? sep : '';
const baseDir = '.';
return targetDir.split(sep).reduce((parentDir, childDir) => {
const curDir = path.resolve(baseDir, parentDir, childDir);
try {
fs.mkdirSync(curDir);
} catch (err) {
if (err.code === 'EEXIST') {
// curDir already exists!
return curDir;
}
// To avoid `EISDIR` error on Mac and `EACCES`-->`ENOENT` and `EPERM` on Windows.
if (err.code === 'ENOENT') {
// Throw the original parentDir error on curDir `ENOENT` failure.
throw new Error(
`Impossible to create the documentation folder in '${parentDir}', please check the permissions.`
);
}
const caughtErr = ['EACCES', 'EPERM', 'EISDIR'].indexOf(err.code) > -1;
if (!caughtErr || (caughtErr && targetDir === curDir)) {
throw err; // Throw if it's just the last created dir.
}
}
return curDir;
}, initDir);
},
/**
* Create the apiName.json and unclassified.json files inside an api's documentation/version folder
* @param {String} apiName
*/
createDocumentationFile: function(apiName, writeFile = true) {
// Retrieve all the routes from an API
const apiRoutes = this.getApiRoutes(apiName);
const apiDocumentation = this.generateApiDocumentation(apiName, apiRoutes);
return Object.keys(apiDocumentation).reduce((acc, docName) => {
const targetFile = path.resolve(this.getDocumentationPath(apiName), `${docName}.json`);
// Create the components object in each documentation file when we can create it
const components =
strapi.models[docName] !== undefined ? this.generateResponseComponent(docName) : {};
const tags = docName.split('-').length > 1 ? [] : this.generateTags(apiName, docName);
const documentation = Object.assign(apiDocumentation[docName], components, { tags });
try {
if (writeFile) {
return fs.writeFileSync(targetFile, JSON.stringify(documentation, null, 2), 'utf8');
} else {
return acc.concat(documentation);
}
} catch (err) {
return acc;
}
}, []);
},
createPluginDocumentationFile: function(pluginName, writeFile = true) {
const pluginRoutes = this.getPluginRoutesWithDescription(pluginName);
const pluginDocumentation = this.generatePluginDocumentation(pluginName, pluginRoutes);
return Object.keys(pluginDocumentation).reduce((acc, docName) => {
const targetFile = path.resolve(
this.getPluginDocumentationPath(pluginName),
`${docName}.json`
);
const components =
_.get(strapi, this.getModelForPlugin(docName, pluginName)) !== undefined &&
pluginName !== 'upload'
? this.generateResponseComponent(docName, pluginName, true)
: {};
const [plugin, name] = this.getModelAndNameForPlugin(docName, pluginName);
const tags =
docName !== 'unclassified'
? this.generateTags(plugin, docName, _.upperFirst(this.formatTag(plugin, name)), true)
: [];
const documentation = Object.assign(pluginDocumentation[docName], components, { tags });
try {
if (writeFile) {
return fs.writeFileSync(targetFile, JSON.stringify(documentation, null, 2), 'utf8');
} else {
return acc.concat(documentation);
}
} catch (err) {
// Silent
}
}, []);
},
createDocObject: function(array) {
// use custom merge for arrays
return array.reduce((acc, curr) => _.mergeWith(acc, curr, this.arrayCustomizer), {});
},
deleteDocumentation: async function(version = this.getDocumentationVersion()) {
const recursiveDeleteFiles = async (folderPath, removeCompleteFolder = true) => {
// Check if folderExist
try {
const arrayOfPromises = [];
fs.accessSync(folderPath);
const items = fs.readdirSync(folderPath).filter(x => x[0] !== '.');
items.forEach(item => {
const itemPath = path.join(folderPath, item);
// Check if directory
if (fs.lstatSync(itemPath).isDirectory()) {
if (removeCompleteFolder) {
return arrayOfPromises.push(recursiveDeleteFiles(itemPath), removeCompleteFolder);
} else if (!itemPath.includes('overrides')) {
return arrayOfPromises.push(recursiveDeleteFiles(itemPath), removeCompleteFolder);
}
} else {
// Delete all files
try {
fs.unlinkSync(itemPath);
} catch (err) {
console.log('Cannot delete file', err);
}
}
});
await Promise.all(arrayOfPromises);
try {
if (removeCompleteFolder) {
fs.rmdirSync(folderPath);
}
} catch (err) {
// console.log(err);
}
} catch (err) {
// console.log('The folder does not exist');
}
};
const arrayOfPromises = [];
// Delete api's documentation
const apis = this.getApis();
const plugins = this.getPluginsWithDocumentationNeeded();
apis.forEach(api => {
const apiPath = path.join(strapi.config.appPath, 'api', api, 'documentation', version);
arrayOfPromises.push(recursiveDeleteFiles(apiPath));
});
plugins.forEach(plugin => {
const pluginPath = path.join(
strapi.config.appPath,
'extensions',
plugin,
'documentation',
version
);
if (version !== '1.0.0') {
arrayOfPromises.push(recursiveDeleteFiles(pluginPath));
} else {
arrayOfPromises.push(recursiveDeleteFiles(pluginPath, false));
}
});
const fullDocPath = path.join(
strapi.config.appPath,
'extensions',
'documentation',
'documentation',
version
);
arrayOfPromises.push(recursiveDeleteFiles(fullDocPath));
return await Promise.all(arrayOfPromises);
},
/**
*
* Wrap endpoints variables in curly braces
* @param {String} endPoint
* @returns {String} (/products/{id})
*/
formatApiEndPoint: endPoint => {
return pathToRegexp
.parse(endPoint)
.map(token => {
if (_.isObject(token)) {
return token.prefix + '{' + token.name + '}'; // eslint-disable-line prefer-template
}
return token;
})
.join('');
},
/**
* Format a plugin model for example users-permissions, user => Users-Permissions - User
* @param {Sting} plugin
* @param {String} name
* @param {Boolean} withoutSpace
* @return {String}
*/
formatTag: (plugin, name, withoutSpace = false) => {
const formattedPluginName = plugin
.split('-')
.map(i => _.upperFirst(i))
.join('');
const formattedName = _.upperFirst(name);
if (withoutSpace) {
return `${formattedPluginName}${formattedName}`;
}
return `${formattedPluginName} - ${formattedName}`;
},
generateAssociationSchema: function(attributes, getter) {
return Object.keys(attributes).reduce(
(acc, curr) => {
const attribute = attributes[curr];
const isField = !_.has(attribute, 'model') && !_.has(attribute, 'collection');
if (attribute.required) {
acc.required.push(curr);
}
if (isField) {
acc.properties[curr] = { type: this.getType(attribute.type), enum: attribute.enum };
} else {
const newGetter = getter.slice();
newGetter.splice(newGetter.length - 1, 1, 'associations');
const relationNature = _.get(strapi, newGetter).filter(
association => association.alias === curr
)[0].nature;
switch (relationNature) {
case 'manyToMany':
case 'oneToMany':
case 'manyWay':
case 'manyToManyMorph':
acc.properties[curr] = {
type: 'array',
items: { type: 'string' },
};
break;
default:
acc.properties[curr] = { type: 'string' };
}
}
return acc;
},
{ required: ['id'], properties: { id: { type: 'string' } } }
);
},
/**
* Creates the paths object with all the needed information
* The object has the following structure { apiName: { paths: {} }, knownTag1: { paths: {} }, unclassified: { paths: {} } }
* Each key will create a documentation.json file
*
* @param {String} apiName
* @param {Array} routes
* @returns {Object}
*/
generateApiDocumentation: function(apiName, routes) {
return routes.reduce((acc, current) => {
const [controllerName, controllerMethod] = current.handler.split('.');
// Retrieve the tag key in the config object
const routeTagConfig = _.get(current, ['config', 'tag']);
// Add curly braces between dynamic params
const endPoint = this.formatApiEndPoint(current.path);
let verb;
if (Array.isArray(current.method)) {
verb = current.method.map(method => method.toLowerCase());
} else {
verb = current.method.toLowerCase();
}
// The key corresponds to firsts keys of the returned object
let key;
let tags;
if (controllerName.toLowerCase() === apiName && !_.isObject(routeTagConfig)) {
key = apiName;
} else if (routeTagConfig !== undefined) {
if (_.isObject(routeTagConfig)) {
const { name, plugin } = routeTagConfig;
const referencePlugin = !_.isEmpty(plugin);
key = referencePlugin ? `${plugin}-${name}` : name.toLowerCase();
tags = referencePlugin ? this.formatTag(plugin, name) : _.upperFirst(name);
} else {
key = routeTagConfig.toLowerCase();
}
} else {
key = 'unclassified';
}
const verbObject = {
deprecated: false,
description: this.generateVerbDescription(
verb,
current.handler,
key,
endPoint.split('/')[1],
_.get(current, 'config.description')
),
responses: this.generateResponses(verb, current, key),
summary: '',
tags: _.isEmpty(tags) ? [_.upperFirst(key)] : [_.upperFirst(tags)],
};
// Swagger is not support key with ',' symbol, for array of methods need generate documentation for each method
if (Array.isArray(verb)) {
verb.forEach(method => {
_.set(acc, [key, 'paths', endPoint, method], verbObject);
});
} else {
_.set(acc, [key, 'paths', endPoint, verb], verbObject);
}
if (verb.includes('post') || verb.includes('put')) {
let requestBody;
if (controllerMethod === 'create' || controllerMethod === 'update') {
requestBody = {
description: '',
required: true,
content: {
'application/json': {
schema: {
$ref: `#/components/schemas/New${_.upperFirst(key)}`,
},
},
},
};
} else {
requestBody = {
description: '',
required: true,
content: {
'application/json': {
schema: {
properties: {
foo: {
type: 'string',
},
},
},
},
},
};
}
if (Array.isArray(verb)) {
verb.forEach(method => {
_.set(acc, [key, 'paths', endPoint, method, 'requestBody'], requestBody);
});
} else {
_.set(acc, [key, 'paths', endPoint, verb, 'requestBody'], requestBody);
}
}
// Refer to https://swagger.io/specification/#pathItemObject
const parameters = this.generateVerbParameters(verb, controllerMethod, current.path);
if (!verb.includes('post')) {
if (Array.isArray(verb)) {
verb.forEach(method => {
_.set(acc, [key, 'paths', endPoint, method, 'parameters'], parameters);
});
} else {
_.set(acc, [key, 'paths', endPoint, verb, 'parameters'], parameters);
}
}
return acc;
}, {});
},
generateFullDoc: function(version = this.getDocumentationVersion()) {
const apisDoc = this.retrieveDocumentationFiles(false, version);
const pluginsDoc = this.retrieveDocumentationFiles(true, version);
const appDoc = [...apisDoc, ...pluginsDoc];
const defaultSettings = _.cloneDeep(
_.pick(strapi.plugins.documentation.config, defaultSettingsKeys)
);
_.set(defaultSettings, ['info', 'x-generation-date'], moment().format('L LTS'));
_.set(defaultSettings, ['info', 'version'], version);
const tags = appDoc.reduce((acc, current) => {
const tags = current.tags.filter(el => {
return _.findIndex(acc, ['name', el.name || '']) === -1;
});
return acc.concat(tags);
}, []);
const fullDoc = _.merge(
appDoc.reduce((acc, current) => {
return _.merge(acc, current);
}, defaultSettings),
defaultComponents
// { tags },
);
fullDoc.tags = tags;
return fullDoc;
},
/**
* Generate the main component that has refs to sub components
* @param {Object} attributes
* @param {Array} associations
* @returns {Object}
*/
generateMainComponent: function(attributes, associations) {
return Object.keys(attributes).reduce(
(acc, current) => {
const attribute = attributes[current];
// Refer to https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#dataTypes
const type = this.getType(attribute.type);
const {
description,
default: defaultValue,
minimum,
maxmimun,
maxLength,
minLength,
enum: enumeration,
} = attribute;
if (attribute.required === true) {
acc.required.push(current);
}
if (attribute.model || attribute.collection) {
const currentAssociation = associations.filter(
association => association.alias === current
)[0];
const relationNature = currentAssociation.nature;
const name = currentAssociation.model || currentAssociation.collection;
const getter =
currentAssociation.plugin !== undefined
? currentAssociation.plugin === 'admin'
? ['admin', 'models', name, 'attributes']
: ['plugins', currentAssociation.plugin, 'models', name, 'attributes']
: ['models', name.toLowerCase(), 'attributes'];
const associationAttributes = _.get(strapi, getter);
const associationSchema = this.generateAssociationSchema(associationAttributes, getter);
switch (relationNature) {
case 'manyToMany':
case 'oneToMany':
case 'manyWay':
case 'manyToManyMorph':
acc.properties[current] = {
type: 'array',
items: associationSchema,
};
break;
default:
acc.properties[current] = associationSchema;
}
} else if (type === 'component') {
const { repeatable, component, min, max } = attribute;
const cmp = this.generateMainComponent(
strapi.components[component].attributes,
strapi.components[component].associations
);
if (repeatable) {
acc.properties[current] = {
type: 'array',
items: {
type: 'object',
...cmp,
},
minItems: min,
maxItems: max,
};
} else {
acc.properties[current] = {
type: 'object',
...cmp,
description,
};
}
} else if (type === 'dynamiczone') {
const { components, min, max } = attribute;
const cmps = components.map(component => {
const schema = this.generateMainComponent(
strapi.components[component].attributes,
strapi.components[component].associations
);
return _.merge(
{
properties: {
__component: {
type: 'string',
enum: components,
},
},
},
schema
);
});
acc.properties[current] = {
type: 'array',
items: {
oneOf: cmps,
},
minItems: min,
maxItems: max,
};
} else {
acc.properties[current] = {
type,
format: this.getFormat(attribute.type),
description,
default: defaultValue,
minimum,
maxmimun,
maxLength,
minLength,
enum: enumeration,
};
}
return acc;
},
{ required: ['id'], properties: { id: { type: 'string' } } }
);
},
generatePluginDocumentation: function(pluginName, routes) {
return routes.reduce((acc, current) => {
const {
config: { description, prefix },
} = current;
const endPoint =
prefix === undefined
? this.formatApiEndPoint(`/${pluginName}${current.path}`)
: this.formatApiEndPoint(`${prefix}${current.path}`);
let verb;
if (Array.isArray(current.method)) {
verb = current.method.map(method => method.toLowerCase());
} else {
verb = current.method.toLowerCase();
}
const actionType = _.get(current, ['config', 'tag', 'actionType'], '');
let key;
let tags;
if (_.isObject(current.config.tag)) {
const { name, plugin } = current.config.tag;
key = plugin ? `${plugin}-${name}` : name;
tags = plugin ? [this.formatTag(plugin, name)] : [name];
} else {
const tag = current.config.tag;
key = !_.isEmpty(tag) ? tag : 'unclassified';
tags = !_.isEmpty(tag) ? [tag] : ['Unclassified'];
}
const hasDefaultDocumentation = this.checkIfPluginDefaultDocumentFileExists(pluginName, key);
const defaultDocumentation = hasDefaultDocumentation
? this.getPluginDefaultVerbDocumentation(pluginName, key, endPoint, verb)
: null;
const verbObject = {
deprecated: false,
description,
responses: this.generatePluginVerbResponses(current),
summary: '',
tags,
};
_.set(acc, [key, 'paths', endPoint, verb], verbObject);
const parameters = this.generateVerbParameters(
verb,
actionType,
`/${pluginName}${current.path}`
);
if (_.isEmpty(defaultDocumentation)) {
if (!verb.includes('post')) {
if (Array.isArray(verb)) {
verb.forEach(method => {
_.set(acc, [key, 'paths', endPoint, method, 'parameters'], parameters);
});
} else {
_.set(acc, [key, 'paths', endPoint, verb, 'parameters'], parameters);
}
}
if (verb.includes('post') || verb.includes('put')) {
let requestBody;
if (actionType === 'create' || actionType === 'update') {
const { name, plugin } = _.isObject(current.config.tag)
? current.config.tag
: { tag: current.config.tag };
const $ref = plugin
? `#/components/schemas/New${this.formatTag(plugin, name, true)}`
: `#/components/schemas/New${_.upperFirst(name)}`;
requestBody = {
description: '',
required: true,
content: {
'application/json': {
schema: {
$ref,
},
},
},
};
} else {
requestBody = {
description: '',
required: true,
content: {
'application/json': {
schema: {
properties: {
foo: {
type: 'string',
},
},
},
},
},
};
}
if (Array.isArray(verb)) {
verb.forEach(method => {
_.set(acc, [key, 'paths', endPoint, method, 'requestBody'], requestBody);
});
} else {
_.set(acc, [key, 'paths', endPoint, verb, 'requestBody'], requestBody);
}
}
}
return acc;
}, {});
},
generatePluginResponseSchema: function(tag) {
const { actionType, name, plugin } = _.isObject(tag) ? tag : { tag };
const getter = plugin ? ['plugins', plugin, 'models', name.toLowerCase()] : ['models', name];
const isModelRelated =
_.get(strapi, getter) !== undefined &&
['find', 'findOne', 'create', 'search', 'update', 'destroy', 'count'].includes(actionType);
const $ref = plugin
? `#/components/schemas/${this.formatTag(plugin, name, true)}`
: `#/components/schemas/${_.upperFirst(name)}`;
if (isModelRelated) {
switch (actionType) {
case 'find':
return {
type: 'array',
items: {
$ref,
},
};
case 'count':
return {
properties: {
count: {
type: 'integer',
},
},
};
case 'findOne':
case 'update':
case 'create':
return {
$ref,
};
default:
return {
properties: {
foo: {
type: 'string',
},
},
};
}
}
return {
properties: {
foo: {
type: 'string',
},
},
};
},
generatePluginVerbResponses: function(routeObject) {
const {
config: { tag },
} = routeObject;
const actionType = _.get(tag, 'actionType');
let schema;
if (!tag || !actionType) {
schema = {
properties: {
foo: {
type: 'string',
},
},
};
} else {
schema = this.generatePluginResponseSchema(tag);
}
const response = {
200: {
description: 'response',
content: {
'application/json': {
schema,
},
},
},
403: {
description: 'Forbidden',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
404: {
description: 'Not found',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
};
const { generateDefaultResponse } = strapi.config.get('plugin.documentation.x-strapi-config');
if (generateDefaultResponse) {
response.default = {
description: 'unexpected error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
};
}
return response;
},
/**
* Create the response object https://swagger.io/specification/#responsesObject
* @param {String} verb
* @param {Object} routeObject
* @param {String} tag
* @returns {Object}
*/
generateResponses: function(verb, routeObject, tag) {
const endPoint = routeObject.path.split('/')[1];
const description = this.generateResponseDescription(verb, tag, endPoint);
const schema = this.generateResponseSchema(verb, routeObject, tag, endPoint);
const response = {
200: {
description,
content: {
'application/json': {
schema,
},
},
},
403: {
description: 'Forbidden',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
404: {
description: 'Not found',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
};
const { generateDefaultResponse } = strapi.config.get('plugin.documentation.x-strapi-config');
if (generateDefaultResponse) {
response.default = {
description: 'unexpected error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
};
}
return response;
},
/**
* Retrieve all privates attributes from a model
* @param {Object} attributes
*/
getPrivateAttributes: function(attributes) {
const privateAttributes = Object.keys(attributes).reduce((acc, current) => {
if (attributes[current].private === true) {
acc.push(current);
}
return acc;
}, []);
return privateAttributes;
},
/**
* Create a component object with the model's attributes and relations
* Refer to https://swagger.io/docs/specification/components/
* @param {String} tag
* @returns {Object}
*/
generateResponseComponent: function(tag, pluginName = '', isPlugin = false) {
// The component's name have to be capitalised
const [plugin, name] = isPlugin ? this.getModelAndNameForPlugin(tag, pluginName) : [null, null];
const upperFirstTag = isPlugin ? this.formatTag(plugin, name, true) : _.upperFirst(tag);
const attributesGetter = isPlugin
? [...this.getModelForPlugin(tag, plugin), 'attributes']
: ['models', tag, 'attributes'];
const associationGetter = isPlugin
? [...this.getModelForPlugin(tag, plugin), 'associations']
: ['models', tag, 'associations'];
const attributesObject = _.get(strapi, attributesGetter);
const privateAttributes = this.getPrivateAttributes(attributesObject);
const modelAssociations = _.get(strapi, associationGetter);
const { attributes } = this.getModelAttributes(attributesObject);
const associationsWithUpload = modelAssociations
.filter(association => {
return association.plugin === 'upload';
})
.map(obj => obj.alias);
// We always create two nested components from the main one
const mainComponent = this.generateMainComponent(attributes, modelAssociations, upperFirstTag);
// Get Component that doesn't display the privates attributes since a mask is applied
// Please refer https://github.com/strapi/strapi/blob/585800b7b98093f596759b296a43f89c491d4f4f/packages/strapi/lib/middlewares/mask/index.js#L92-L100
const getComponent = Object.keys(mainComponent.properties).reduce(
(acc, current) => {
if (privateAttributes.indexOf(current) === -1) {
acc.properties[current] = mainComponent.properties[current];
}
return acc;
},
{ required: mainComponent.required, properties: {} }
);
// Special component only for POST || PUT verbs since the upload is made with a different route
const postComponent = Object.keys(mainComponent).reduce((acc, current) => {
if (current === 'required') {
const required = mainComponent.required.slice().filter(attr => {
return associationsWithUpload.indexOf(attr) === -1 && attr !== 'id' && attr !== '_id';
});
if (required.length > 0) {
acc.required = required;
}
}
if (current === 'properties') {
const properties = Object.keys(mainComponent.properties).reduce((acc, current) => {
if (
associationsWithUpload.indexOf(current) === -1 &&
current !== 'id' &&
current !== '_id'
) {
// The post request shouldn't include nested relations of type 2
// For instance if a product has many tags
// we expect to find an array of tags objects containing other relations in the get response
// and since we use to getComponent to generate this one we need to
// remove this object since we only send an array of tag ids.
if (_.find(modelAssociations, ['alias', current])) {
const isArrayProperty =
_.get(mainComponent, ['properties', current, 'type']) !== undefined;
if (isArrayProperty) {
acc[current] = { type: 'array', items: { type: 'string' } };
} else {
acc[current] = { type: 'string' };
}
} else {
// If the field is not an association we take the one from the component
acc[current] = mainComponent.properties[current];
}
}
return acc;
}, {});
acc.properties = properties;
}
return acc;
}, {});
return {
components: {
schemas: {
[upperFirstTag]: getComponent,
[`New${upperFirstTag}`]: postComponent,
},
},
};
},
/**
* Generate a better description for a response when we can guess what's the user is going to retrieve
* @param {String} verb
* @param {String} tag
* @param {String} endPoint
* @returns {String}
*/
generateResponseDescription: function(verb, tag, endPoint) {
const isModelRelated = strapi.models[tag] !== undefined && tag === endPoint;
if (Array.isArray(verb)) {
verb = verb.map(method => method.toLocaleLowerCase());
}
if (verb.includes('get') || verb.includes('put') || verb.includes('post')) {
return isModelRelated ? `Retrieve ${tag} document(s)` : 'response';
} else if (verb.includes('delete')) {
return isModelRelated
? `deletes a single ${tag} based on the ID supplied`
: 'deletes a single record based on the ID supplied';
} else {
return 'response';
}
},
/**
* For each response generate its schema
* Its schema is either a component when we know what the routes returns otherwise, it returns a dummy schema
* that the user will modify later
* @param {String} verb
* @param {Object} route
* @param {String} tag
* @param {String} endPoint
* @returns {Object}
*/
generateResponseSchema: function(verb, routeObject, tag) {
const { handler } = routeObject;
let [controller, handlerMethod] = handler.split('.');
let upperFirstTag = _.upperFirst(tag);
if (verb === 'delete') {
return {
type: 'integer',
format: 'int64',
};
}
// A tag key might be added to a route to tell if a custom endPoint in an api/<model>/config/routes.json
// Retrieves data from another model it is a faster way to generate the response
const routeReferenceTag = _.get(routeObject, ['config', 'tag']);
let isModelRelated = false;
const shouldCheckIfACustomEndPointReferencesAnotherModel =
_.isObject(routeReferenceTag) && !_.isEmpty(_.get(routeReferenceTag, 'name'));
if (shouldCheckIfACustomEndPointReferencesAnotherModel) {
const { actionType, name, plugin } = routeReferenceTag;
// A model could be in either a plugin or the api folder
// The path is different depending on the case
const getter = !_.isEmpty(plugin)
? ['plugins', plugin, 'models', name.toLowerCase()]
: ['models', name.toLowerCase()];
// An actionType key might be added to the tag object to guide the algorithm is generating an automatic response
const isKnownAction = [
'find',
'findOne',
'create',
'search',
'update',
'destroy',
'count',
].includes(actionType);
// Check if a route points to a model
isModelRelated = _.get(strapi, getter) !== undefined && isKnownAction;
if (isModelRelated && isKnownAction) {
// We need to change the handlerMethod name if it is know to generate the good schema
handlerMethod = actionType;
// This is to retrieve the correct component if a custom endpoints references a plugin model
if (!_.isEmpty(plugin)) {
upperFirstTag = this.formatTag(plugin, name, true);
}
}
} else {
// Normal way there's no tag object
isModelRelated = strapi.models[tag] !== undefined && tag === _.lowerCase(controller);
}
// We create a component when we are sure that we can 'guess' what's needed to be sent
// https://swagger.io/specification/#referenceObject
if (isModelRelated) {
switch (handlerMethod) {
case 'find':
return {
type: 'array',
items: {
$ref: `#/components/schemas/${upperFirstTag}`,
},
};
case 'count':
return {
properties: {
count: {
type: 'integer',
},
},
};
case 'findOne':
case 'update':
case 'create':
return {
$ref: `#/components/schemas/${upperFirstTag}`,
};
default:
return {
properties: {
foo: {
type: 'string',
},
},
};
}
}
return {
properties: {
foo: {
type: 'string',
},
},
};
},
generateTags: function(name, docName, tag = '', isPlugin = false) {
return [
{
name: isPlugin ? tag : _.upperFirst(docName),
},
];
},
/**
* Add a default description when it's implied
*
* @param {String} verb
* @param {String} handler
* @param {String} tag
* @param {String} endPoint
* @returns {String}
*/
generateVerbDescription: (verb, handler, tag, endPoint, description) => {
const isModelRelated = strapi.models[tag] !== undefined && tag === endPoint;
if (description) {
return description;
}
if (Array.isArray(verb)) {
const [, controllerMethod] = handler.split('.');
if ((verb.includes('get') && verb.includes('post')) || controllerMethod === 'findOrCreate') {
return `Find or create ${tag} record`;
}
if (
(verb.includes('put') && verb.includes('post')) ||
controllerMethod === 'createOrUpdate'
) {
return `Create or update ${tag} record`;
}
return '';
}
switch (verb) {
case 'get': {
const [, controllerMethod] = handler.split('.');
if (isModelRelated) {
switch (controllerMethod) {
case 'count':
return `Retrieve the number of ${tag} documents`;
case 'findOne':
return `Find one ${tag} record`;
case 'find':
return `Find all the ${tag}'s records`;
default:
return '';
}
}
return '';
}
case 'delete':
return isModelRelated ? `Delete a single ${tag} record` : 'Delete a record';
case 'post':
return isModelRelated ? `Create a new ${tag} record` : 'Create a new record';
case 'put':
return isModelRelated ? `Update a single ${tag} record` : 'Update a record';
case 'patch':
return '';
case 'head':
return '';
default:
return '';
}
},
/**
* Generate the verb parameters object
* Refer to https://swagger.io/specification/#pathItemObject
* @param {Sting} verb
* @param {String} controllerMethod
* @param {String} endPoint
*/
generateVerbParameters: function(verb, controllerMethod, endPoint) {
const params = pathToRegexp
.parse(endPoint)
.filter(token => _.isObject(token))
.reduce((acc, current) => {
const param = {
name: current.name,
in: 'path',
description: '',
deprecated: false,
required: true,
schema: { type: 'string' },
};
return acc.concat(param);
}, []);
if (verb === 'get' && controllerMethod === 'find') {
// parametersOptions corresponds to this section
// of the documentation https://strapi.io/documentation/developer-docs/latest/developer-resources/content-api/content-api.html#filters
return [...params, ...parametersOptions];
}
return params;
},
/**
* Retrieve the apis in /api
* @returns {Array}
*/
getApis: () => {
return Object.keys(strapi.api || {});
},
getAPIOverrideComponentsDocumentation: function(apiName, docName) {
try {
const documentation = JSON.parse(
fs.readFileSync(this.getAPIOverrideDocumentationPath(apiName, docName), 'utf8')
);
return _.get(documentation, 'components', null);
} catch (err) {
return null;
}
},
getAPIDefaultTagsDocumentation: function(name, docName) {
try {
const documentation = JSON.parse(
fs.readFileSync(this.getAPIOverrideDocumentationPath(name, docName), 'utf8')
);
return _.get(documentation, 'tags', null);
} catch (err) {
return null;
}
},
getAPIDefaultVerbDocumentation: function(apiName, docName, routePath, verb) {
try {
const documentation = JSON.parse(
fs.readFileSync(this.getAPIOverrideDocumentationPath(apiName, docName), 'utf8')
);
return _.get(documentation, ['paths', routePath, verb], null);
} catch (err) {
return null;
}
},
getAPIOverrideDocumentationPath: function(apiName, docName) {
return path.join(
strapi.config.appPath,
'api',
apiName,
'documentation',
'overrides',
this.getDocumentationVersion(),
`${docName}.json`
);
},
/**
* Given an api retrieve its endpoints
* @param {String}
* @returns {Array}
*/
getApiRoutes: apiName => {
return _.get(strapi, ['api', apiName, 'config', 'routes'], []);
},
getDocumentationOverridesPath: function(apiName) {
return path.join(
strapi.config.appPath,
'api',
apiName,
'documentation',
this.getDocumentationVersion(),
'overrides'
);
},
/**
* Given an api from /api retrieve its version directory
* @param {String} apiName
* @returns {Path}
*/
getDocumentationPath: function(apiName) {
return path.join(
strapi.config.appPath,
'api',
apiName,
'documentation',
this.getDocumentationVersion()
);
},
getFullDocumentationPath: () => {
return path.join(strapi.config.appPath, 'extensions', 'documentation', 'documentation');
},
/**
* Retrieve the plugin's configuration version
*/
getDocumentationVersion: () => {
const version = strapi.config.get('plugin.documentation.info.version');
return version;
},
/**
* Retrieve the documentation plugin documentation directory
*/
getMergedDocumentationPath: function(version = this.getDocumentationVersion()) {
return path.join(
strapi.config.appPath,
'extensions',
'documentation',
'documentation',
version
);
},
/**
* Retrieve the model's attributes
* @param {Objet} modelAttributes
* @returns {Object} { associations: [{ name: 'foo', getter: [], tag: 'foos' }], attributes }
*/
getModelAttributes: function(modelAttributes) {
const associations = [];
const attributes = Object.keys(modelAttributes)
.map(attr => {
const attribute = modelAttributes[attr];
const isField = !_.has(attribute, 'model') && !_.has(attribute, 'collection');
if (!isField) {
const name = attribute.model || attribute.collection;
const getter =
attribute.plugin !== undefined
? ['plugins', attribute.plugin, 'models', name, 'attributes']
: ['models', name, 'attributes'];
associations.push({ name, getter, tag: attr });
}
return attr;
})
.reduce((acc, current) => {
acc[current] = modelAttributes[current];
return acc;
}, {});
return { associations, attributes };
},
/**
* Refer to https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#dataTypes
* @param {String} type
* @returns {String}
*/
getType: type => {
switch (type) {
case 'string':
case 'byte':
case 'binary':
case 'password':
case 'email':
case 'text':
case 'enumeration':
case 'date':
case 'datetime':
case 'time':
case 'richtext':
return 'string';
case 'float':
case 'decimal':
case 'double':
return 'number';
case 'integer':
case 'biginteger':
case 'long':
return 'integer';
case 'json':
return 'object';
default:
return type;
}
},
/**
* Refer to https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#dataTypes
* @param {String} type
* @returns {String}
*/
getFormat: type => {
switch (type) {
case 'date':
return 'date';
case 'datetime':
return 'date-time';
case 'password':
return 'password';
default:
return undefined;
}
},
getPluginDefaultVerbDocumentation: function(pluginName, docName, routePath, verb) {
try {
const documentation = JSON.parse(
fs.readFileSync(this.getPluginOverrideDocumentationPath(pluginName, docName), 'utf8')
);
return _.get(documentation, ['paths', routePath, verb], null);
} catch (err) {
return null;
}
},
getPluginDefaultTagsDocumentation: function(pluginName, docName) {
try {
const documentation = JSON.parse(
fs.readFileSync(this.getPluginOverrideDocumentationPath(pluginName, docName), 'utf8')
);
return _.get(documentation, ['tags'], null);
} catch (err) {
return null;
}
},
getPluginOverrideComponents: function(pluginName, docName) {
try {
const documentation = JSON.parse(
fs.readFileSync(this.getPluginOverrideDocumentationPath(pluginName, docName), 'utf8')
);
return _.get(documentation, 'components', null);
} catch (err) {
return null;
}
},
getPluginOverrideDocumentationPath: function(pluginName, docName) {
const defaultPath = path.join(
strapi.config.appPath,
'extensions',
pluginName,
'documentation',
this.getDocumentationVersion(),
'overrides'
);
if (docName) {
return path.resolve(defaultPath, `${docName.json}`);
} else {
return defaultPath;
}
},
/**
* Given a plugin retrieve its documentation version
*/
getPluginDocumentationPath: function(pluginName) {
return path.join(
strapi.config.appPath,
'extensions',
pluginName,
'documentation',
this.getDocumentationVersion()
);
},
/**
* Retrieve all plugins that have a description inside one of its route
* @return {Arrray}
*/
getPluginsWithDocumentationNeeded: function() {
return Object.keys(strapi.plugins).reduce((acc, current) => {
const isDocumentationNeeded = this.isPluginDocumentationNeeded(current);
if (isDocumentationNeeded) {
return acc.concat(current);
}
return acc;
}, []);
},
/**
* Retrieve all the routes that have a description from a plugin
* @param {String} pluginName
* @returns {Array}
*/
getPluginRoutesWithDescription: function(pluginName) {
return _.get(strapi, ['plugins', pluginName, 'config', 'routes'], []).filter(
route => _.get(route, ['config', 'description']) !== undefined
);
},
/**
* Given a string and a pluginName retrieve the model and the pluginName
* @param {String} string
* @param {Sting} pluginName
* @returns {Array}
*/
getModelAndNameForPlugin: (string, pluginName) => {
return _.replace(string, `${pluginName}-`, `${pluginName}.`).split('.');
},
/**
* Retrieve the path needed to get a model from a plugin
* @param (String) string
* @param {String} plugin
* @returns {Array}
*/
getModelForPlugin: function(string, pluginName) {
const [plugin, model] = this.getModelAndNameForPlugin(string, pluginName);
return ['plugins', plugin, 'models', _.lowerCase(model)];
},
/**
* Check whether or not a plugin needs documentation
* @param {String} pluginName
* @returns {Boolean}
*/
isPluginDocumentationNeeded: function(pluginName) {
const { pluginsForWhichToGenerateDoc } = strapi.config.get(
'plugins.documentation.x-strapi-config'
);
if (
Array.isArray(pluginsForWhichToGenerateDoc) &&
!pluginsForWhichToGenerateDoc.includes(pluginName)
) {
return false;
} else {
return this.getPluginRoutesWithDescription(pluginName).length > 0;
}
},
/**
* Merge two components by replacing the default ones by the overides and keeping the others
* @param {Object} initObj
* @param {Object} srcObj
* @returns {Object}
*/
mergeComponents: (initObj, srcObj) => {
const cleanedObj = Object.keys(_.get(initObj, 'schemas', {})).reduce((acc, current) => {
const targetObj = _.has(_.get(srcObj, ['schemas'], {}), current) ? srcObj : initObj;
_.set(acc, ['schemas', current], _.get(targetObj, ['schemas', current], {}));
return acc;
}, {});
return _.merge(cleanedObj, srcObj);
},
mergePaths: function(initObj, srcObj) {
return Object.keys(initObj.paths).reduce((acc, current) => {
if (_.has(_.get(srcObj, ['paths'], {}), current)) {
const verbs = Object.keys(initObj.paths[current]).reduce((acc1, curr) => {
const verb = this.mergeVerbObject(
initObj.paths[current][curr],
_.get(srcObj, ['paths', current, curr], {})
);
_.set(acc1, [curr], verb);
return acc1;
}, {});
_.set(acc, ['paths', current], verbs);
} else {
_.set(acc, ['paths', current], _.get(initObj, ['paths', current], {}));
}
return acc;
}, {});
},
mergeTags: (initObj, srcObj) => {
return _.get(srcObj, 'tags', _.get(initObj, 'tags', []));
},
/**
* Merge two verb objects with a customizer
* @param {Object} initObj
* @param {Object} srcObj
* @returns {Object}
*/
mergeVerbObject: function(initObj, srcObj) {
return _.mergeWith(initObj, srcObj, (objValue, srcValue) => {
if (_.isPlainObject(objValue)) {
return Object.assign(objValue, srcValue);
}
return srcValue;
});
},
retrieveDocumentation: function(name, isPlugin = false) {
const documentationPath = isPlugin
? [strapi.config.appPath, 'extensions', name, 'documentation', this.getDocumentationVersion()]
: [strapi.config.appPath, 'api', name, 'documentation', this.getDocumentationVersion()];
try {
const documentationFiles = fs
.readdirSync(path.resolve(documentationPath.join('/')))
.filter(el => el.includes('.json'));
return documentationFiles.reduce((acc, current) => {
try {
const doc = JSON.parse(
fs.readFileSync(path.resolve([...documentationPath, current].join('/')), 'utf8')
);
acc.push(doc);
} catch (err) {
// console.log(path.resolve([...documentationPath, current].join('/')), err);
}
return acc;
}, []);
} catch (err) {
return [];
}
},
/**
* Retrieve all documentation files from either the APIs or the plugins
* @param {Boolean} isPlugin
* @returns {Array}
*/
retrieveDocumentationFiles: function(isPlugin = false, version = this.getDocumentationVersion()) {
const array = isPlugin ? this.getPluginsWithDocumentationNeeded() : this.getApis();
return array.reduce((acc, current) => {
const documentationPath = isPlugin
? [strapi.config.appPath, 'extensions', current, 'documentation', version]
: [strapi.config.appPath, 'api', current, 'documentation', version];
try {
const documentationFiles = fs
.readdirSync(path.resolve(documentationPath.join('/')))
.filter(el => el.includes('.json'));
documentationFiles.forEach(el => {
try {
let documentation = JSON.parse(
fs.readFileSync(path.resolve([...documentationPath, el].join('/')), 'utf8')
);
/* eslint-disable indent */
const overrideDocumentationPath = isPlugin
? path.resolve(
strapi.config.appPath,
'extensions',
current,
'documentation',
version,
'overrides',
el
)
: path.resolve(
strapi.config.appPath,
'api',
current,
'documentation',
version,
'overrides',
el
);
/* eslint-enable indent */
let overrideDocumentation;
try {
overrideDocumentation = JSON.parse(
fs.readFileSync(overrideDocumentationPath, 'utf8')
);
} catch (err) {
overrideDocumentation = null;
}
if (!_.isEmpty(overrideDocumentation)) {
documentation.paths = this.mergePaths(documentation, overrideDocumentation).paths;
documentation.tags = _.cloneDeep(
this.mergeTags(documentation, overrideDocumentation)
);
const documentationComponents = _.get(documentation, 'components', {});
const overrideComponents = _.get(overrideDocumentation, 'components', {});
const mergedComponents = this.mergeComponents(
documentationComponents,
overrideComponents
);
if (!_.isEmpty(mergedComponents)) {
documentation.components = mergedComponents;
}
}
acc.push(documentation);
} catch (err) {
strapi.log.error(err);
console.log(
`Unable to access the documentation for ${[...documentationPath, el].join('/')}`
);
}
});
} catch (err) {
strapi.log.error(err);
console.log(
`Unable to retrieve documentation for the ${isPlugin ? 'plugin' : 'api'} ${current}`
);
}
return acc;
}, []);
},
retrieveDocumentationVersions: function() {
return fs
.readdirSync(this.getFullDocumentationPath())
.map(version => {
try {
const doc = JSON.parse(
fs.readFileSync(
path.resolve(this.getFullDocumentationPath(), version, 'full_documentation.json')
)
);
const generatedDate = _.get(doc, ['info', 'x-generation-date'], null);
return { version, generatedDate, url: '' };
} catch (err) {
return null;
}
})
.filter(x => x);
},
retrieveFrontForm: async function() {
const config = await strapi
.store({
environment: '',
type: 'plugin',
name: 'documentation',
key: 'config',
})
.get();
const forms = JSON.parse(JSON.stringify(form));
_.set(forms, [0, 0, 'value'], config.restrictedAccess);
_.set(forms, [0, 1, 'value'], config.password || '');
return forms;
},
});