diff --git a/packages/plugins/documentation/__mocks__/strapi.js b/packages/plugins/documentation/__mocks__/strapi.js new file mode 100644 index 0000000000..4c6bb18199 --- /dev/null +++ b/packages/plugins/documentation/__mocks__/strapi.js @@ -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; diff --git a/packages/plugins/documentation/__tests__/build-component-schema.test.js b/packages/plugins/documentation/__tests__/build-component-schema.test.js new file mode 100644 index 0000000000..15fc1d3e3f --- /dev/null +++ b/packages/plugins/documentation/__tests__/build-component-schema.test.js @@ -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'); + }); +}); diff --git a/packages/plugins/documentation/server/config/default-config.js b/packages/plugins/documentation/server/config/default-plugin-config.js similarity index 56% rename from packages/plugins/documentation/server/config/default-config.js rename to packages/plugins/documentation/server/config/default-plugin-config.js index 1e8de480c6..f341d7a996 100644 --- a/packages/plugins/documentation/server/config/default-config.js +++ b/packages/plugins/documentation/server/config/default-plugin-config.js @@ -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', + }, + }, + }, + }, + }, + }, }, }; diff --git a/packages/plugins/documentation/server/config/index.js b/packages/plugins/documentation/server/config/index.js index e5af201f7c..44b410793d 100644 --- a/packages/plugins/documentation/server/config/index.js +++ b/packages/plugins/documentation/server/config/index.js @@ -1,7 +1,7 @@ 'use strict'; -const defaultDocumentationConfig = require('./default-config'); +const defaultPluginConfig = require('./default-plugin-config'); module.exports = { - default: defaultDocumentationConfig, + default: defaultPluginConfig, }; diff --git a/packages/plugins/documentation/server/services/documentation.js b/packages/plugins/documentation/server/services/documentation.js index 810b0be064..ac6da2e672 100755 --- a/packages/plugins/documentation/server/services/documentation.js +++ b/packages/plugins/documentation/server/services/documentation.js @@ -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 }); }, }; }; diff --git a/packages/plugins/documentation/server/services/helpers/build-api-endpoint-path.js b/packages/plugins/documentation/server/services/helpers/build-api-endpoint-path.js new file mode 100644 index 0000000000..32b9b2eb64 --- /dev/null +++ b/packages/plugins/documentation/server/services/helpers/build-api-endpoint-path.js @@ -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; diff --git a/packages/plugins/documentation/server/services/helpers/build-component-schema.js b/packages/plugins/documentation/server/services/helpers/build-component-schema.js new file mode 100644 index 0000000000..36e66b9618 --- /dev/null +++ b/packages/plugins/documentation/server/services/helpers/build-component-schema.js @@ -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; diff --git a/packages/plugins/documentation/server/services/helpers/index.js b/packages/plugins/documentation/server/services/helpers/index.js new file mode 100644 index 0000000000..ff3aeac05b --- /dev/null +++ b/packages/plugins/documentation/server/services/helpers/index.js @@ -0,0 +1,9 @@ +'use strict'; + +const builApiEndpointPath = require('./build-api-endpoint-path'); +const buildComponentSchema = require('./build-component-schema'); + +module.exports = { + builApiEndpointPath, + buildComponentSchema, +}; diff --git a/packages/plugins/documentation/server/utils/clean-schema-attributes.js b/packages/plugins/documentation/server/services/helpers/utils/clean-schema-attributes.js similarity index 95% rename from packages/plugins/documentation/server/utils/clean-schema-attributes.js rename to packages/plugins/documentation/server/services/helpers/utils/clean-schema-attributes.js index 116b6cdfbd..4fb7fa4a76 100644 --- a/packages/plugins/documentation/server/utils/clean-schema-attributes.js +++ b/packages/plugins/documentation/server/services/helpers/utils/clean-schema-attributes.js @@ -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', diff --git a/packages/plugins/documentation/server/services/helpers/utils/get-api-responses.js b/packages/plugins/documentation/server/services/helpers/utils/get-api-responses.js new file mode 100644 index 0000000000..0b8847fded --- /dev/null +++ b/packages/plugins/documentation/server/services/helpers/utils/get-api-responses.js @@ -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; diff --git a/packages/plugins/documentation/server/utils/get-schema-data.js b/packages/plugins/documentation/server/services/helpers/utils/get-schema-data.js similarity index 100% rename from packages/plugins/documentation/server/utils/get-schema-data.js rename to packages/plugins/documentation/server/services/helpers/utils/get-schema-data.js diff --git a/packages/plugins/documentation/server/services/helpers/utils/loop-content-type-names.js b/packages/plugins/documentation/server/services/helpers/utils/loop-content-type-names.js new file mode 100644 index 0000000000..67d37b46d3 --- /dev/null +++ b/packages/plugins/documentation/server/services/helpers/utils/loop-content-type-names.js @@ -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; diff --git a/packages/plugins/documentation/server/services/helpers/utils/pascal-case.js b/packages/plugins/documentation/server/services/helpers/utils/pascal-case.js new file mode 100644 index 0000000000..eddeb55ab5 --- /dev/null +++ b/packages/plugins/documentation/server/services/helpers/utils/pascal-case.js @@ -0,0 +1,9 @@ +'use strict'; + +const _ = require('lodash'); + +const pascalCase = (string) => { + return _.upperFirst(_.camelCase(string)); +}; + +module.exports = pascalCase; diff --git a/packages/plugins/documentation/server/utils/query-params.js b/packages/plugins/documentation/server/services/helpers/utils/query-params.js similarity index 100% rename from packages/plugins/documentation/server/utils/query-params.js rename to packages/plugins/documentation/server/services/helpers/utils/query-params.js diff --git a/packages/plugins/documentation/server/services/helpers/utils/routes.js b/packages/plugins/documentation/server/services/helpers/utils/routes.js new file mode 100644 index 0000000000..0a06900a7a --- /dev/null +++ b/packages/plugins/documentation/server/services/helpers/utils/routes.js @@ -0,0 +1,10 @@ +'use strict'; + +const hasFindMethod = (handler) => handler.split('.').pop() === 'find'; + +const isLocalizedPath = (routePath) => routePath.includes('localizations'); + +module.exports = { + isLocalizedPath, + hasFindMethod, +}; diff --git a/packages/plugins/documentation/server/services/utils/components.json b/packages/plugins/documentation/server/services/utils/components.json deleted file mode 100755 index 03ebeaf17d..0000000000 --- a/packages/plugins/documentation/server/services/utils/components.json +++ /dev/null @@ -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" - } - } - } - } - } -} diff --git a/packages/plugins/documentation/server/services/utils/parametersOptions.json b/packages/plugins/documentation/server/services/utils/parametersOptions.json deleted file mode 100755 index 180901f067..0000000000 --- a/packages/plugins/documentation/server/services/utils/parametersOptions.json +++ /dev/null @@ -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 - } -] diff --git a/packages/plugins/documentation/server/services/utils/unknownComponent.json b/packages/plugins/documentation/server/services/utils/unknownComponent.json deleted file mode 100755 index d2e3ec22ad..0000000000 --- a/packages/plugins/documentation/server/services/utils/unknownComponent.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "components": { - "schemas": { - "Foo": { - "properties": { - "bar": "string" - } - } - } - } -} \ No newline at end of file diff --git a/packages/plugins/documentation/server/utils/builders/build-api-endpoint-path.js b/packages/plugins/documentation/server/utils/builders/build-api-endpoint-path.js deleted file mode 100644 index 4d984c4013..0000000000 --- a/packages/plugins/documentation/server/utils/builders/build-api-endpoint-path.js +++ /dev/null @@ -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 }; -}; diff --git a/packages/plugins/documentation/server/utils/builders/build-api-requests.js b/packages/plugins/documentation/server/utils/builders/build-api-requests.js deleted file mode 100644 index efb809c118..0000000000 --- a/packages/plugins/documentation/server/utils/builders/build-api-requests.js +++ /dev/null @@ -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 }), - }, - }, - }, - }, - }, - }, - }; -}; diff --git a/packages/plugins/documentation/server/utils/builders/build-api-responses.js b/packages/plugins/documentation/server/utils/builders/build-api-responses.js deleted file mode 100644 index 84a9441996..0000000000 --- a/packages/plugins/documentation/server/utils/builders/build-api-responses.js +++ /dev/null @@ -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, - }, - }, - }, - }, - }; -}; diff --git a/packages/plugins/documentation/server/utils/builders/index.js b/packages/plugins/documentation/server/utils/builders/index.js deleted file mode 100644 index dd2d6a1564..0000000000 --- a/packages/plugins/documentation/server/utils/builders/index.js +++ /dev/null @@ -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, -}; diff --git a/packages/plugins/documentation/server/utils/error-response.js b/packages/plugins/documentation/server/utils/error-response.js deleted file mode 100644 index ed035e2617..0000000000 --- a/packages/plugins/documentation/server/utils/error-response.js +++ /dev/null @@ -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', - }, - }, - }, - }, -};