mirror of
https://github.com/strapi/strapi.git
synced 2025-07-04 07:27:23 +00:00

- Hooks registry - D&P CT migrations - i18N CT migrations - Umzug with js / sql migrations - Eslint updates
1862 lines
54 KiB
JavaScript
Executable File
1862 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(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(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(apiName) {
|
|
try {
|
|
fs.accessSync(this.getDocumentationPath(apiName));
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
checkIfPluginDocumentationFolderExists(pluginName) {
|
|
try {
|
|
fs.accessSync(this.getPluginDocumentationPath(pluginName));
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
checkIfPluginDocNeedsUpdate(pluginName) {
|
|
const prevDocumentation = this.createDocObject(this.retrieveDocumentation(pluginName, true));
|
|
const currentDocumentation = this.createDocObject(
|
|
this.createPluginDocumentationFile(pluginName, false)
|
|
);
|
|
|
|
return !this.areObjectsEquals(prevDocumentation, currentDocumentation);
|
|
},
|
|
|
|
checkIfApiDefaultDocumentationFileExist(apiName, docName) {
|
|
try {
|
|
fs.accessSync(this.getAPIOverrideDocumentationPath(apiName, docName));
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
checkIfPluginDefaultDocumentFileExists(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() {
|
|
try {
|
|
fs.accessSync(this.getMergedDocumentationPath());
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Recursively create missing directories
|
|
* @param {String} targetDir
|
|
*
|
|
*/
|
|
createDocumentationDirectory(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(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(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(array) {
|
|
// use custom merge for arrays
|
|
return array.reduce((acc, curr) => _.mergeWith(acc, curr, this.arrayCustomizer), {});
|
|
},
|
|
|
|
async deleteDocumentation(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 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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(apiName, docName) {
|
|
try {
|
|
const documentation = JSON.parse(
|
|
fs.readFileSync(this.getAPIOverrideDocumentationPath(apiName, docName), 'utf8')
|
|
);
|
|
|
|
return _.get(documentation, 'components', null);
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
getAPIDefaultTagsDocumentation(name, docName) {
|
|
try {
|
|
const documentation = JSON.parse(
|
|
fs.readFileSync(this.getAPIOverrideDocumentationPath(name, docName), 'utf8')
|
|
);
|
|
|
|
return _.get(documentation, 'tags', null);
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
getAPIDefaultVerbDocumentation(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(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(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(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(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(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(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(pluginName, docName) {
|
|
try {
|
|
const documentation = JSON.parse(
|
|
fs.readFileSync(this.getPluginOverrideDocumentationPath(pluginName, docName), 'utf8')
|
|
);
|
|
|
|
return _.get(documentation, ['tags'], null);
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
getPluginOverrideComponents(pluginName, docName) {
|
|
try {
|
|
const documentation = JSON.parse(
|
|
fs.readFileSync(this.getPluginOverrideDocumentationPath(pluginName, docName), 'utf8')
|
|
);
|
|
|
|
return _.get(documentation, 'components', null);
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
getPluginOverrideDocumentationPath(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(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() {
|
|
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(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(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(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(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(initObj, srcObj) {
|
|
return _.mergeWith(initObj, srcObj, (objValue, srcValue) => {
|
|
if (_.isPlainObject(objValue)) {
|
|
return Object.assign(objValue, srcValue);
|
|
}
|
|
|
|
return srcValue;
|
|
});
|
|
},
|
|
|
|
retrieveDocumentation(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(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() {
|
|
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);
|
|
},
|
|
|
|
async retrieveFrontForm() {
|
|
const config = await strapi
|
|
.store({ 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;
|
|
},
|
|
});
|