Merge pull request #12929 from strapi/fix/documentation-component-schemas

Add component schemas
This commit is contained in:
Alexandre BODIN 2022-05-11 10:18:02 +02:00 committed by GitHub
commit 8eec9c2d71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 896 additions and 556 deletions

View File

@ -0,0 +1,41 @@
'use strict';
const strapi = {
plugins: {
'users-permissions': {
contentTypes: {
role: {
attributes: {
name: {
type: 'string',
},
},
},
},
routes: {
'content-api': {
routes: [],
},
},
},
},
api: {
restaurant: {
contentTypes: {
restaurant: {
attributes: {
name: {
type: 'string',
},
},
},
},
routes: {
restaurant: { routes: [] },
},
},
},
contentType: () => ({ info: {}, attributes: { test: { type: 'string' } } }),
};
module.exports = strapi;

View File

@ -0,0 +1,266 @@
'use strict';
const _ = require('lodash');
const buildComponentSchema = require('../server/services/helpers/build-component-schema');
const strapi = require('../__mocks__/strapi');
describe('Build Component Schema', () => {
beforeEach(() => {
// Reset the mocked strapi instance
global.strapi = _.cloneDeep(strapi);
});
it('builds the Response schema', () => {
const apiMocks = [
{
name: 'users-permissions',
getter: 'plugin',
ctNames: ['role'],
},
{ name: 'restaurant', getter: 'api', ctNames: ['restaurant'] },
];
let schemas = {};
for (const mock of apiMocks) {
schemas = {
...schemas,
...buildComponentSchema(mock),
};
}
const schemaNames = Object.keys(schemas);
const [pluginResponseName, apiResponseName] = Object.keys(schemas);
const [pluginResponseValue, apiResponseValue] = Object.values(schemas);
const expectedShape = {
properties: {
data: {
type: 'object',
properties: {
id: { type: 'string' },
attributes: { type: 'object', properties: { test: { type: 'string' } } },
},
},
meta: { type: 'object' },
},
};
expect(schemaNames.length).toBe(2);
expect(pluginResponseName).toBe('UsersPermissionsRoleResponse');
expect(apiResponseName).toBe('RestaurantResponse');
expect(pluginResponseValue).toStrictEqual(expectedShape);
expect(apiResponseValue).toStrictEqual(expectedShape);
});
it('builds the ResponseList schema', () => {
global.strapi.plugins['users-permissions'].routes['content-api'].routes = [
{ method: 'GET', path: '/test', handler: 'test.find' },
];
global.strapi.api.restaurant.routes.restaurant.routes = [
{ method: 'GET', path: '/test', handler: 'test.find' },
];
const apiMocks = [
{
name: 'users-permissions',
getter: 'plugin',
ctNames: ['role'],
},
{ name: 'restaurant', getter: 'api', ctNames: ['restaurant'] },
];
let schemas = {};
for (const mock of apiMocks) {
schemas = {
...schemas,
...buildComponentSchema(mock),
};
}
const schemaNames = Object.keys(schemas);
const pluginListResponseValue = schemas['UsersPermissionsRoleListResponse'];
const apiListResponseValue = schemas['RestaurantListResponse'];
const expectedShape = {
properties: {
data: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
attributes: { type: 'object', properties: { test: { type: 'string' } } },
},
},
},
meta: {
type: 'object',
properties: {
pagination: {
properties: {
page: { type: 'integer' },
pageSize: { type: 'integer', minimum: 25 },
pageCount: { type: 'integer', maximum: 1 },
total: { type: 'integer' },
},
},
},
},
},
};
expect(schemaNames.length).toBe(4);
expect(schemaNames.includes('UsersPermissionsRoleListResponse')).toBe(true);
expect(schemaNames.includes('RestaurantListResponse')).toBe(true);
expect(pluginListResponseValue).toStrictEqual(expectedShape);
expect(apiListResponseValue).toStrictEqual(expectedShape);
});
it('builds the Request schema', () => {
global.strapi.plugins['users-permissions'].routes['content-api'].routes = [
{ method: 'POST', path: '/test', handler: 'test.create' },
];
global.strapi.api.restaurant.routes.restaurant.routes = [
{ method: 'POST', path: '/test', handler: 'test.create' },
];
const apiMocks = [
{
name: 'users-permissions',
getter: 'plugin',
ctNames: ['role'],
},
{ name: 'restaurant', getter: 'api', ctNames: ['restaurant'] },
];
let schemas = {};
for (const mock of apiMocks) {
schemas = {
...schemas,
...buildComponentSchema(mock),
};
}
const schemaNames = Object.keys(schemas);
const pluginListResponseValue = schemas['UsersPermissionsRoleRequest'];
const apiListResponseValue = schemas['RestaurantRequest'];
const expectedShape = {
type: 'object',
properties: {
data: {
type: 'object',
properties: { test: { type: 'string' } },
},
},
};
expect(schemaNames.length).toBe(4);
expect(schemaNames.includes('UsersPermissionsRoleRequest')).toBe(true);
expect(schemaNames.includes('RestaurantRequest')).toBe(true);
expect(pluginListResponseValue).toStrictEqual(expectedShape);
expect(apiListResponseValue).toStrictEqual(expectedShape);
});
it('builds the LocalizationResponse schema', () => {
global.strapi.plugins['users-permissions'].routes['content-api'].routes = [
{ method: 'GET', path: '/localizations', handler: 'test' },
];
global.strapi.api.restaurant.routes.restaurant.routes = [
{ method: 'GET', path: '/localizations', handler: 'test' },
];
const apiMocks = [
{
name: 'users-permissions',
getter: 'plugin',
ctNames: ['role'],
},
{ name: 'restaurant', getter: 'api', ctNames: ['restaurant'] },
];
let schemas = {};
for (const mock of apiMocks) {
schemas = {
...schemas,
...buildComponentSchema(mock),
};
}
const schemaNames = Object.keys(schemas);
const pluginListResponseValue = schemas['UsersPermissionsRoleLocalizationResponse'];
const apiListResponseValue = schemas['RestaurantLocalizationResponse'];
const expectedShape = {
type: 'object',
properties: {
id: { type: 'string' },
test: { type: 'string' },
},
};
expect(schemaNames.length).toBe(4);
expect(schemaNames.includes('UsersPermissionsRoleLocalizationResponse')).toBe(true);
expect(schemaNames.includes('RestaurantLocalizationResponse')).toBe(true);
expect(pluginListResponseValue).toStrictEqual(expectedShape);
expect(apiListResponseValue).toStrictEqual(expectedShape);
});
it('builds the LocalizationRequest schema', () => {
global.strapi.plugins['users-permissions'].routes['content-api'].routes = [
{ method: 'POST', path: '/localizations', handler: 'test' },
];
global.strapi.api.restaurant.routes.restaurant.routes = [
{ method: 'POST', path: '/localizations', handler: 'test' },
];
const apiMocks = [
{
name: 'users-permissions',
getter: 'plugin',
ctNames: ['role'],
},
{ name: 'restaurant', getter: 'api', ctNames: ['restaurant'] },
];
let schemas = {};
for (const mock of apiMocks) {
schemas = {
...schemas,
...buildComponentSchema(mock),
};
}
const schemaNames = Object.keys(schemas);
const pluginListResponseValue = schemas['UsersPermissionsRoleLocalizationRequest'];
const apiListResponseValue = schemas['RestaurantLocalizationRequest'];
const expectedShape = {
type: 'object',
properties: { test: { type: 'string' } },
};
expect(schemaNames.length).toBe(8);
expect(schemaNames.includes('UsersPermissionsRoleLocalizationRequest')).toBe(true);
expect(schemaNames.includes('RestaurantLocalizationRequest')).toBe(true);
expect(pluginListResponseValue).toStrictEqual(expectedShape);
expect(apiListResponseValue).toStrictEqual(expectedShape);
});
it('creates the correct name given multiple content types', () => {
const apiMock = {
name: 'users-permissions',
getter: 'plugin',
ctNames: ['permission', 'role', 'user'],
};
const schemas = buildComponentSchema(apiMock);
const schemaNames = Object.keys(schemas);
const [permission, role, user] = schemaNames;
expect(schemaNames.length).toBe(3);
expect(permission).toBe('UsersPermissionsPermissionResponse');
expect(role).toBe('UsersPermissionsRoleResponse');
expect(user).toBe('UsersPermissionsUserResponse');
});
});

View File

@ -21,7 +21,7 @@ module.exports = {
path: '/documentation',
showGeneratedFiles: true,
generateDefaultResponse: true,
plugins: ['email', 'upload'],
plugins: ['email', 'upload', 'users-permissions'],
},
servers: [],
externalDocs: {
@ -41,5 +41,34 @@ module.exports = {
bearerFormat: 'JWT',
},
},
schemas: {
Error: {
type: 'object',
required: ['error'],
properties: {
data: {
nullable: true,
oneOf: [{ type: 'object' }, { type: 'array' }],
},
error: {
type: 'object',
properties: {
status: {
type: 'integer',
},
name: {
type: 'string',
},
message: {
type: 'string',
},
details: {
type: 'object',
},
},
},
},
},
},
},
};

View File

@ -1,7 +1,7 @@
'use strict';
const defaultDocumentationConfig = require('./default-config');
const defaultPluginConfig = require('./default-plugin-config');
module.exports = {
default: defaultDocumentationConfig,
default: defaultPluginConfig,
};

View File

@ -5,8 +5,8 @@ const fs = require('fs-extra');
const _ = require('lodash');
const { getAbsoluteServerUrl } = require('@strapi/utils');
const { builApiEndpointPath } = require('../utils/builders');
const defaultConfig = require('../config/default-config');
const defaultPluginConfig = require('../config/default-plugin-config');
const { builApiEndpointPath, buildComponentSchema } = require('./helpers');
module.exports = ({ strapi }) => {
const config = strapi.config.get('plugin.documentation');
@ -107,7 +107,7 @@ module.exports = ({ strapi }) => {
return [...apisToDocument, ...pluginsToDocument];
},
async getCustomSettings() {
async getCustomConfig() {
const customConfigPath = this.getCustomDocumentationPath();
const pathExists = await fs.pathExists(customConfigPath);
if (pathExists) {
@ -122,23 +122,31 @@ module.exports = ({ strapi }) => {
*/
async generateFullDoc(version = this.getDocumentationVersion()) {
let paths = {};
let schemas = {};
const apis = this.getPluginAndApiInfo();
for (const api of apis) {
const apiName = api.name;
const apiDirPath = path.join(this.getApiDocumentationPath(api), version);
const apiDocPath = path.join(apiDirPath, `${apiName}.json`);
const apiPathsObject = builApiEndpointPath(api);
if (!apiPathsObject) {
const apiPath = builApiEndpointPath(api);
if (!apiPath) {
continue;
}
await fs.ensureFile(apiDocPath);
await fs.writeJson(apiDocPath, apiPathsObject, { spaces: 2 });
await fs.writeJson(apiDocPath, apiPath, { spaces: 2 });
paths = { ...paths, ...apiPathsObject.paths };
const componentSchema = buildComponentSchema(api);
schemas = {
...schemas,
...componentSchema,
};
paths = { ...paths, ...apiPath };
}
const fullDocJsonPath = path.join(
@ -147,27 +155,26 @@ module.exports = ({ strapi }) => {
'full_documentation.json'
);
const defaultSettings = _.cloneDeep(defaultConfig);
const defaultConfig = _.cloneDeep(defaultPluginConfig);
const serverUrl = getAbsoluteServerUrl(strapi.config);
const apiPath = strapi.config.get('api.rest.prefix');
_.set(defaultSettings, 'servers', [
_.set(defaultConfig, 'servers', [
{
url: `${serverUrl}${apiPath}`,
description: 'Development server',
},
]);
_.set(defaultConfig, ['info', 'x-generation-date'], new Date().toISOString());
_.set(defaultConfig, ['info', 'version'], version);
_.merge(defaultConfig.components, { schemas });
_.set(defaultSettings, ['info', 'x-generation-date'], new Date().toISOString());
_.set(defaultSettings, ['info', 'version'], version);
const customSettings = await this.getCustomSettings();
const settings = _.merge(defaultSettings, customSettings);
const customConfig = await this.getCustomConfig();
const config = _.merge(defaultConfig, customConfig);
await fs.ensureFile(fullDocJsonPath);
await fs.writeJson(fullDocJsonPath, { ...settings, paths }, { spaces: 2 });
await fs.writeJson(fullDocJsonPath, { ...config, paths }, { spaces: 2 });
},
};
};

View File

@ -0,0 +1,185 @@
'use strict';
const _ = require('lodash');
const pathToRegexp = require('path-to-regexp');
const pascalCase = require('./utils/pascal-case');
const queryParams = require('./utils/query-params');
const loopContentTypeNames = require('./utils/loop-content-type-names');
const getApiResponses = require('./utils/get-api-responses');
const { hasFindMethod, isLocalizedPath } = require('./utils/routes');
/**
* @description Parses a route with ':variable'
*
* @param {string} routePath - The route's path property
* @returns {string}
*/
const parsePathWithVariables = (routePath) => {
return pathToRegexp
.parse(routePath)
.map((token) => {
if (_.isObject(token)) {
return token.prefix + '{' + token.name + '}';
}
return token;
})
.join('');
};
/**
* @description Builds the required object for a path parameter
*
* @param {string} routePath - The route's path property
*
* @returns {object } Swagger path params object
*/
const getPathParams = (routePath) => {
return pathToRegexp
.parse(routePath)
.filter((token) => _.isObject(token))
.map((param) => {
return {
name: param.name,
in: 'path',
description: '',
deprecated: false,
required: true,
schema: { type: 'string' },
};
});
};
/**
*
* @param {string} prefix - The prefix found on the routes object
* @param {string} route - The current route
* @property {string} route.path - The current route's path
* @property {object} route.config - The current route's config object
*
* @returns {string}
*/
const getPathWithPrefix = (prefix, route) => {
// When the prefix is set on the routes and
// the current route is not trying to remove it
if (prefix && !_.has(route.config, 'prefix')) {
// Add the prefix to the path
return prefix.concat(route.path);
}
// Otherwise just return path
return route.path;
};
/**
* @description Gets all paths based on routes
*
* @param {object} apiInfo
* @property {object} apiInfo.routeInfo - The api routes object
* @property {string} apiInfo.uniqueName - Content type name | Api name + Content type name
* @property {object} apiInfo.contentTypeInfo - The info object found on content type schemas
*
* @returns {object}
*/
const getPaths = ({ routeInfo, uniqueName, contentTypeInfo }) => {
// Get the routes for the current content type
const contentTypeRoutes = routeInfo.routes.filter((route) => {
return (
route.path.includes(contentTypeInfo.pluralName) ||
route.path.includes(contentTypeInfo.singularName)
);
});
const paths = contentTypeRoutes.reduce((acc, route) => {
// TODO: Find a more reliable way to determine list of entities vs a single entity
const isListOfEntities = hasFindMethod(route.handler);
const isLocalizationPath = isLocalizedPath(route.path);
const methodVerb = route.method.toLowerCase();
const hasPathParams = route.path.includes('/:');
const pathWithPrefix = getPathWithPrefix(routeInfo.prefix, route);
const routePath = hasPathParams ? parsePathWithVariables(pathWithPrefix) : pathWithPrefix;
const { responses } = getApiResponses({
uniqueName,
route,
isListOfEntities,
isLocalizationPath,
});
const swaggerConfig = {
responses,
tags: [_.upperFirst(uniqueName)],
parameters: [],
operationId: `${methodVerb}${routePath}`,
};
if (isListOfEntities) {
swaggerConfig.parameters.push(...queryParams);
}
if (hasPathParams) {
const pathParams = getPathParams(route.path);
swaggerConfig.parameters.push(...pathParams);
}
if (['post', 'put'].includes(methodVerb)) {
const refName = isLocalizationPath ? 'LocalizationRequest' : 'Request';
const requestBody = {
required: true,
content: {
'application/json': {
schema: {
$ref: `#/components/schemas/${pascalCase(uniqueName)}${refName}`,
},
},
},
};
swaggerConfig.requestBody = requestBody;
}
_.set(acc, `${routePath}.${methodVerb}`, swaggerConfig);
return acc;
}, {});
return paths;
};
/**
* @decription Gets all open api paths object for a given content type
*
* @param {object} apiInfo
*
* @returns {object} Open API paths
*/
const getAllPathsForContentType = (apiInfo) => {
let paths = {};
const pathsObject = getPaths(apiInfo);
paths = {
...paths,
...pathsObject,
};
return paths;
};
/**
* @description - Builds the Swagger paths object for each api
*
* @param {object} api - Information about the current api
* @property {string} api.name - The name of the api
* @property {string} api.getter - The getter for the api (api | plugin)
* @property {array} api.ctNames - The name of all contentTypes found on the api
*
* @returns {object}
*/
const buildApiEndpointPath = (api) => {
// A reusable loop for building paths and component schemas
// Uses the api param to build a new set of params for each content type
// Passes these new params to the function provided
return loopContentTypeNames(api, getAllPathsForContentType);
};
module.exports = buildApiEndpointPath;

View File

@ -0,0 +1,153 @@
'use strict';
const _ = require('lodash');
const cleanSchemaAttributes = require('./utils/clean-schema-attributes');
const loopContentTypeNames = require('./utils/loop-content-type-names');
const pascalCase = require('./utils/pascal-case');
const { hasFindMethod, isLocalizedPath } = require('./utils/routes');
/**
* @decription Get all open api schema objects for a given content type
*
* @param {object} apiInfo
* @property {string} apiInfo.uniqueName - Api name | Api name + Content type name
* @property {object} apiInfo.attributes - Attributes on content type
* @property {object} apiInfo.routeInfo - The routes for the api
*
* @returns {object} Open API schemas
*/
const getAllSchemasForContentType = ({ routeInfo, attributes, uniqueName }) => {
// Store response and request schemas in an object
let schemas = {};
// Get all the route methods
const routeMethods = routeInfo.routes.map((route) => route.method);
// Check for localized paths
const hasLocalizationPath = routeInfo.routes.filter((route) =>
isLocalizedPath(route.path)
).length;
// When the route methods contain any post or put requests
if (routeMethods.includes('POST') || routeMethods.includes('PUT')) {
const attributesToOmit = [
'createdAt',
'updatedAt',
'publishedAt',
'publishedBy',
'updatedBy',
'createdBy',
'localizations',
];
const attributesForRequest = _.omit(attributes, attributesToOmit);
const requiredAttributes = Object.entries(attributesForRequest)
.filter(([, attribute]) => attribute.required)
.map(([attributeName, attribute]) => {
return { [attributeName]: attribute };
});
const requestAttributes =
routeMethods.includes('POST') && requiredAttributes.length
? Object.assign({}, ...requiredAttributes)
: attributesForRequest;
if (hasLocalizationPath) {
schemas = {
...schemas,
[`${pascalCase(uniqueName)}LocalizationRequest`]: {
type: 'object',
properties: cleanSchemaAttributes(requestAttributes, { isRequest: true }),
},
};
}
// Build the request schema
schemas = {
...schemas,
[`${pascalCase(uniqueName)}Request`]: {
type: 'object',
properties: {
data: {
type: 'object',
properties: cleanSchemaAttributes(requestAttributes, { isRequest: true }),
},
},
},
};
}
if (hasLocalizationPath) {
schemas = {
...schemas,
[`${pascalCase(uniqueName)}LocalizationResponse`]: {
type: 'object',
properties: {
id: { type: 'string' },
...cleanSchemaAttributes(attributes),
},
},
};
}
// Check for routes that need to return a list
const hasListOfEntities = routeInfo.routes.filter((route) => hasFindMethod(route.handler)).length;
if (hasListOfEntities) {
// Build the list response schema
schemas = {
...schemas,
[`${pascalCase(uniqueName)}ListResponse`]: {
properties: {
data: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
attributes: { type: 'object', properties: cleanSchemaAttributes(attributes) },
},
},
},
meta: {
type: 'object',
properties: {
pagination: {
properties: {
page: { type: 'integer' },
pageSize: { type: 'integer', minimum: 25 },
pageCount: { type: 'integer', maximum: 1 },
total: { type: 'integer' },
},
},
},
},
},
},
};
}
// Build the response schema
schemas = {
...schemas,
[`${pascalCase(uniqueName)}Response`]: {
properties: {
data: {
type: 'object',
properties: {
id: { type: 'string' },
attributes: { type: 'object', properties: cleanSchemaAttributes(attributes) },
},
},
meta: { type: 'object' },
},
},
};
return schemas;
};
const buildComponentSchema = (api) => {
// A reusable loop for building paths and component schemas
// Uses the api param to build a new set of params for each content type
// Passes these new params to the function provided
return loopContentTypeNames(api, getAllSchemasForContentType);
};
module.exports = buildComponentSchema;

View File

@ -0,0 +1,9 @@
'use strict';
const builApiEndpointPath = require('./build-api-endpoint-path');
const buildComponentSchema = require('./build-component-schema');
module.exports = {
builApiEndpointPath,
buildComponentSchema,
};

View File

@ -4,13 +4,12 @@ const _ = require('lodash');
const getSchemaData = require('./get-schema-data');
/**
* @description - Converts types found on attributes to OpenAPI specific data types
* @description - Converts types found on attributes to OpenAPI acceptable data types
*
* @param {object} attributes - The attributes found on a contentType
* @param {{ typeMap: Map, isRequest: boolean }} opts
* @returns Attributes using OpenAPI acceptable data types
*/
const cleanSchemaAttributes = (attributes, { typeMap = new Map(), isRequest = false } = {}) => {
const attributesCopy = _.cloneDeep(attributes);
@ -114,7 +113,7 @@ const cleanSchemaAttributes = (attributes, { typeMap = new Map(), isRequest = fa
break;
}
case 'dynamiczone': {
const components = attribute.components.map(component => {
const components = attribute.components.map((component) => {
const componentAttributes = strapi.components[component].attributes;
return {
type: 'object',
@ -170,6 +169,14 @@ const cleanSchemaAttributes = (attributes, { typeMap = new Map(), isRequest = fa
break;
}
if (prop === 'localizations') {
attributesCopy[prop] = {
type: 'array',
items: { type: 'object', properties: {} },
};
break;
}
if (!attribute.target || typeMap.has(attribute.target)) {
attributesCopy[prop] = {
type: 'object',

View File

@ -0,0 +1,105 @@
'use strict';
const pascalCase = require('./pascal-case');
/**
* @description - Builds the Swagger response object for a given api
*
* @param {object} name - Name of the api or plugin
* @param {object} route - The current route
* @param {boolean} isListOfEntities - Checks for a list of entitities
*
* @returns The Swagger responses
*/
const getApiResponse = ({
uniqueName,
route,
isListOfEntities = false,
isLocalizationPath = false,
}) => {
const getSchema = () => {
if (route.method === 'DELETE') {
return {
type: 'integer',
format: 'int64',
};
}
if (isLocalizationPath) {
return { $ref: `#/components/schemas/${pascalCase(uniqueName)}LocalizationResponse` };
}
if (isListOfEntities) {
return { $ref: `#/components/schemas/${pascalCase(uniqueName)}ListResponse` };
}
return { $ref: `#/components/schemas/${pascalCase(uniqueName)}Response` };
};
const schema = getSchema();
return {
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema,
},
},
},
400: {
description: 'Bad Request',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
401: {
description: 'Unauthorized',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
403: {
description: 'Forbidden',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
404: {
description: 'Not Found',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
500: {
description: 'Internal Server Error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
},
};
};
module.exports = getApiResponse;

View File

@ -0,0 +1,52 @@
'use strict';
const _ = require('lodash');
/**
* @description A reusable loop for building api endpoint paths and component schemas
*
* @param {object} api - Api information to pass to the callback
* @param {function} callback - Logic to execute for the given api
*
* @returns {object}
*/
const loopContentTypeNames = (api, callback) => {
let result = {};
for (const contentTypeName of api.ctNames) {
// Get the attributes found on the api's contentType
const uid = `${api.getter}::${api.name}.${contentTypeName}`;
const { attributes, info: contentTypeInfo } = strapi.contentType(uid);
// Get the routes for the current api
const routeInfo =
api.getter === 'plugin'
? strapi.plugin(api.name).routes['content-api']
: strapi.api[api.name].routes[contentTypeName];
// Continue to next iteration if routeInfo is undefined
if (!routeInfo) continue;
// Uppercase the first letter of the api name
const apiName = _.upperFirst(api.name);
// Create a unique name if the api name and contentType name don't match
const uniqueName =
api.name === contentTypeName ? apiName : `${apiName} - ${_.upperFirst(contentTypeName)}`;
const apiInfo = {
...api,
routeInfo,
attributes,
uniqueName,
contentTypeInfo,
};
result = {
...result,
...callback(apiInfo),
};
}
return result;
};
module.exports = loopContentTypeNames;

View File

@ -0,0 +1,9 @@
'use strict';
const _ = require('lodash');
const pascalCase = (string) => {
return _.upperFirst(_.camelCase(string));
};
module.exports = pascalCase;

View File

@ -0,0 +1,10 @@
'use strict';
const hasFindMethod = (handler) => handler.split('.').pop() === 'find';
const isLocalizedPath = (routePath) => routePath.includes('localizations');
module.exports = {
isLocalizedPath,
hasFindMethod,
};

View File

@ -1,25 +0,0 @@
{
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"schemas": {
"Error": {
"required": ["code", "message"],
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
}
}
}
}
}
}

View File

@ -1,134 +0,0 @@
[
{
"name": "_limit",
"in": "query",
"required": false,
"description": "Maximum number of results possible",
"schema": {
"type": "integer"
},
"deprecated": false
},
{
"name": "_sort",
"in": "query",
"required": false,
"description": "Sort according to a specific field.",
"schema": {
"type": "string"
},
"deprecated": false
},
{
"name": "_start",
"in": "query",
"required": false,
"description": "Skip a specific number of entries (especially useful for pagination)",
"schema": {
"type": "integer"
},
"deprecated": false
},
{
"name": "=",
"in": "query",
"required": false,
"description": "Get entries that matches exactly your input",
"schema": {
"type": "string"
},
"deprecated": false
},
{
"name": "_ne",
"in": "query",
"required": false,
"description": "Get records that are not equals to something",
"schema": {
"type": "string"
},
"deprecated": false
},
{
"name": "_lt",
"in": "query",
"required": false,
"description": "Get record that are lower than a value",
"schema": {
"type": "string"
},
"deprecated": false
},
{
"name": "_lte",
"in": "query",
"required": false,
"description": "Get records that are lower than or equal to a value",
"schema": {
"type": "string"
},
"deprecated": false
},
{
"name": "_gt",
"in": "query",
"required": false,
"description": "Get records that are greater than a value",
"schema": {
"type": "string"
},
"deprecated": false
},
{
"name": "_gte",
"in": "query",
"required": false,
"description": "Get records that are greater than or equal a value",
"schema": {
"type": "string"
},
"deprecated": false
},
{
"name": "_contains",
"in": "query",
"required": false,
"description": "Get records that contains a value",
"schema": {
"type": "string"
},
"deprecated": false
},
{
"name": "_containss",
"in": "query",
"required": false,
"description": "Get records that contains (case sensitive) a value",
"schema": {
"type": "string"
},
"deprecated": false
},
{
"name": "_in",
"in": "query",
"required": false,
"description": "Get records that matches any value in the array of values",
"schema": {
"type": "array",
"items": { "type": "string" }
},
"deprecated": false
},
{
"name": "_nin",
"in": "query",
"required": false,
"description": "Get records that doesn't match any value in the array of values",
"schema": {
"type": "array",
"items": { "type": "string" }
},
"deprecated": false
}
]

View File

@ -1,11 +0,0 @@
{
"components": {
"schemas": {
"Foo": {
"properties": {
"bar": "string"
}
}
}
}
}

View File

@ -1,180 +0,0 @@
'use strict';
const _ = require('lodash');
const pathToRegexp = require('path-to-regexp');
const queryParams = require('../query-params');
const buildApiRequests = require('./build-api-requests');
const buildApiResponses = require('./build-api-responses');
/**
* @description Parses a route with ':variable'
*
* @param {string} routePath - The route's path property
* @returns {string}
*/
const parsePathWithVariables = routePath => {
return pathToRegexp
.parse(routePath)
.map(token => {
if (_.isObject(token)) {
return token.prefix + '{' + token.name + '}';
}
return token;
})
.join('');
};
/**
* @description Builds the required object for a path parameter
*
* @param {string} routePath - The route's path property
*
* @returns {object } Swagger path params object
*/
const getPathParams = routePath => {
return pathToRegexp
.parse(routePath)
.filter(token => _.isObject(token))
.map(param => {
return {
name: param.name,
in: 'path',
description: '',
deprecated: false,
required: true,
schema: { type: 'string' },
};
});
};
/**
*
* @param {string} prefix - The route prefix
* @param {string} path - The route path
*
* @returns {string}
*/
const getPathWithPrefix = (prefix, path) => {
if (path.includes('localizations')) {
return path;
}
if (path.endsWith('/')) {
return prefix;
}
return prefix.concat(path);
};
/**
*
* @param {object} api - Information about the api
* @param {object} api.routeInfo - The routes for a given api or plugin
* @param {string} api.routeInfo.prefix - The prefix for all routes
* @param {array} api.routeInfo.routes - The routes for the current api
* @param {object} api.attributes - The attributes for a given api or plugin
* @param {string} api.tag - A descriptor for OpenAPI
*
* @returns {object}
*/
const getPaths = ({ routeInfo, attributes, tag }) => {
const paths = routeInfo.routes.reduce((acc, route) => {
// TODO: Find a more reliable way to determine list of entities vs a single entity
const isListOfEntities = route.handler.split('.').pop() === 'find';
const methodVerb = route.method.toLowerCase();
const hasPathParams = route.path.includes('/:');
const pathWithPrefix = routeInfo.prefix
? getPathWithPrefix(routeInfo.prefix, route.path)
: route.path;
const routePath = hasPathParams ? parsePathWithVariables(pathWithPrefix) : pathWithPrefix;
const { responses } = buildApiResponses(attributes, route, isListOfEntities);
const swaggerConfig = {
responses,
tags: [_.upperFirst(tag)],
parameters: [],
operationId: `${methodVerb}${routePath}`,
};
if (isListOfEntities) {
swaggerConfig.parameters.push(...queryParams);
}
if (hasPathParams) {
const pathParams = getPathParams(route.path);
swaggerConfig.parameters.push(...pathParams);
}
if (['post', 'put'].includes(methodVerb)) {
const { requestBody } = buildApiRequests(attributes, route);
swaggerConfig.requestBody = requestBody;
}
_.set(acc, `${routePath}.${methodVerb}`, swaggerConfig);
return acc;
}, {});
return { paths };
};
/**
* @description - Builds the Swagger paths object for each api
*
* @param {object} api - Information about the current api
* @property {string} api.name - The name of the api
* @property {string} api.getter - The getter for the api (api | plugin)
* @property {array} api.ctNames - The name of all contentTypes found on the api
*
* @returns {object}
*/
module.exports = api => {
if (!api.ctNames.length && api.getter === 'plugin') {
// Set arbitrary attributes
const attributes = { foo: { type: 'string' } };
const routeInfo = strapi.plugin(api.name).routes['admin'];
const apiInfo = {
routeInfo,
attributes,
tag: api.name,
};
return getPaths(apiInfo);
}
// An api could have multiple contentTypes
let paths = {};
for (const contentTypeName of api.ctNames) {
// Get the attributes found on the api's contentType
const uid = `${api.getter}::${api.name}.${contentTypeName}`;
const ct = strapi.contentType(uid);
const attributes = ct.attributes;
// Get the routes for the current api
const routeInfo =
api.getter === 'plugin'
? strapi.plugin(api.name).routes['content-api']
: strapi.api[api.name].routes[contentTypeName];
// Parse an identifier for OpenAPI tag if the api name and contentType name don't match
const tag = api.name === contentTypeName ? api.name : `${api.name} - ${contentTypeName}`;
const apiInfo = {
routeInfo,
attributes,
tag,
};
paths = {
...paths,
...getPaths(apiInfo).paths,
};
}
return { paths };
};

View File

@ -1,41 +0,0 @@
'use strict';
const cleanSchemaAttributes = require('../clean-schema-attributes');
/**
*
* @param {object} attributes - The attributes found on a contentType
* @param {object} route - The current route
*
* @returns The Swagger requestBody
*/
module.exports = (attributes, route) => {
const requiredAttributes = Object.entries(attributes)
.filter(([, attribute]) => attribute.required)
.map(([attributeName, attribute]) => {
return { [attributeName]: attribute };
});
const requestAttributes =
route.method === 'POST' && requiredAttributes.length
? Object.assign({}, ...requiredAttributes)
: attributes;
return {
requestBody: {
required: true,
content: {
'application/json': {
schema: {
properties: {
data: {
type: 'object',
properties: cleanSchemaAttributes(requestAttributes, { isRequest: true }),
},
},
},
},
},
},
};
};

View File

@ -1,109 +0,0 @@
'use strict';
const getSchemaData = require('../get-schema-data');
const cleanSchemaAttributes = require('../clean-schema-attributes');
const errorResponse = require('../error-response');
/**
*
* @param {boolean} isSingleEntity - Checks for a single entity
* @returns {object} The correctly formatted meta object
*/
const getMeta = isListOfEntities => {
if (isListOfEntities) {
return {
type: 'object',
properties: {
pagination: {
properties: {
page: { type: 'integer' },
pageSize: { type: 'integer', minimum: 25 },
pageCount: { type: 'integer', maximum: 1 },
total: { type: 'integer' },
},
},
},
};
}
return { type: 'object' };
};
/**
* @description - Builds the Swagger response object for a given api
*
* @param {object} attributes - The attributes found on a contentType
* @param {object} route - The current route
* @param {boolean} isListOfEntities - Checks for a list of entitities
*
* @returns The Swagger responses
*/
module.exports = (attributes, route, isListOfEntities = false) => {
let schema;
if (route.method === 'DELETE') {
schema = {
type: 'integer',
format: 'int64',
};
} else {
schema = {
properties: {
data: getSchemaData(isListOfEntities, cleanSchemaAttributes(attributes)),
meta: getMeta(isListOfEntities),
},
};
}
return {
responses: {
'200': {
description: 'OK',
content: {
'application/json': {
schema,
},
},
},
'400': {
description: 'Bad Request',
content: {
'application/json': {
schema: errorResponse,
},
},
},
'401': {
description: 'Unauthorized',
content: {
'application/json': {
schema: errorResponse,
},
},
},
'403': {
description: 'Forbidden',
content: {
'application/json': {
schema: errorResponse,
},
},
},
'404': {
description: 'Not Found',
content: {
'application/json': {
schema: errorResponse,
},
},
},
'500': {
description: 'Internal Server Error',
content: {
'application/json': {
schema: errorResponse,
},
},
},
},
};
};

View File

@ -1,11 +0,0 @@
'use strict';
const buildApiResponses = require('./build-api-responses');
const buildApiRequests = require('./build-api-requests');
const builApiEndpointPath = require('./build-api-endpoint-path');
module.exports = {
buildApiResponses,
buildApiRequests,
builApiEndpointPath,
};

View File

@ -1,22 +0,0 @@
'use strict';
module.exports = {
type: 'object',
required: ['error'],
properties: {
error: {
type: 'object',
properties: {
status: {
type: 'integer',
},
name: {
type: 'string',
},
message: {
type: 'string',
},
},
},
},
};