add component schemas

This commit is contained in:
Mark Kaylor 2022-03-17 16:54:37 +01:00
parent 447bbfed16
commit 191b5f856d
18 changed files with 310 additions and 339 deletions

View File

@ -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

@ -3,9 +3,10 @@
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');
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');
/**
* @description Parses a route with ':variable'
@ -69,17 +70,15 @@ const getPathWithPrefix = (prefix, path) => {
};
/**
* @description Get's all paths based on routes
*
* @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
* @param {object} apiInfo
* @property {object} apiInfo.routeInfo - The api routes object
* @property {string} apiInfo.uniqueName - Content type name | Api name + Content type name
*
* @returns {object}
*/
const getPaths = ({ routeInfo, attributes, tag }) => {
const getPaths = ({ routeInfo, uniqueName }) => {
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';
@ -91,11 +90,11 @@ const getPaths = ({ routeInfo, attributes, tag }) => {
: route.path;
const routePath = hasPathParams ? parsePathWithVariables(pathWithPrefix) : pathWithPrefix;
const { responses } = buildApiResponses(attributes, route, isListOfEntities);
const { responses } = getApiResponses(uniqueName, route, isListOfEntities);
const swaggerConfig = {
responses,
tags: [_.upperFirst(tag)],
tags: [_.upperFirst(uniqueName)],
parameters: [],
operationId: `${methodVerb}${routePath}`,
};
@ -110,7 +109,16 @@ const getPaths = ({ routeInfo, attributes, tag }) => {
}
if (['post', 'put'].includes(methodVerb)) {
const { requestBody } = buildApiRequests(attributes, route);
const requestBody = {
required: true,
content: {
'application/json': {
schema: {
$ref: `#/components/schemas/New${pascalCase(uniqueName)}`,
},
},
},
};
swaggerConfig.requestBody = requestBody;
}
@ -120,7 +128,61 @@ const getPaths = ({ routeInfo, attributes, tag }) => {
return acc;
}, {});
return { paths };
return paths;
};
/**
* @decription Gets all open api paths object for a given content type
*
* @param {object} apiInfo
* @property {string} apiInfo.name - The name of the api
* @property {string} apiInfo.getter - api | plugin
* @property {array} apiInfo.ctNames - All contentType names on the api
* @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 paths
*/
const getAllPathsForContentType = ({
name,
getter,
ctNames,
uniqueName,
routeInfo,
attributes,
}) => {
let paths = {};
if (!ctNames.length && getter === 'plugin') {
const routeInfo = strapi.plugin(name).routes['admin'];
const apiInfo = {
routeInfo,
uniqueName,
};
paths = {
...paths,
...getPaths(apiInfo),
};
return getPaths(apiInfo);
}
const apiInfo = {
routeInfo,
attributes,
uniqueName,
};
const pathsObject = getPaths(apiInfo);
paths = {
...paths,
...pathsObject,
};
return paths;
};
/**
@ -133,48 +195,8 @@ const getPaths = ({ routeInfo, attributes, tag }) => {
*
* @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 };
const buildApiEndpointPath = api => {
return loopContentTypeNames(api, getAllPathsForContentType);
};
module.exports = buildApiEndpointPath;

View File

@ -0,0 +1,76 @@
'use strict';
const cleanSchemaAttributes = require('./utils/clean-schema-attributes');
const getSchemaData = require('./utils/get-schema-data');
const loopContentTypeNames = require('./utils/loop-content-type-names');
const pascalCase = require('./utils/pascal-case');
/**
* @decription Gets all open api schema objects for a given content type
*
* @param {object} apiInfo
* @property {string} apiInfo.getter - api | plugin
* @property {array} apiInfo.ctNames - All contentType names on the api
* @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, ctNames, getter }) => {
// Store response and request schemas in an object
let schemas = {};
// Set flag false since schemas are always objects
const isListOfEntities = false;
// Get all the route methods
const routeMethods = routeInfo.routes.map(route => route.method);
if (!ctNames.length && getter === 'plugin') {
// Set arbitrary attributes
const attributes = { foo: { type: 'string' } };
schemas = {
...schemas,
[pascalCase(uniqueName)]: getSchemaData(isListOfEntities, cleanSchemaAttributes(attributes)),
};
}
if (routeMethods.includes('POST') || routeMethods.includes('PUT')) {
const requiredAttributes = Object.entries(attributes)
.filter(([, attribute]) => attribute.required)
.map(([attributeName, attribute]) => {
return { [attributeName]: attribute };
});
const requestAttributes =
routeMethods.includes('POST') && requiredAttributes.length
? Object.assign({}, ...requiredAttributes)
: attributes;
schemas = {
...schemas,
[`New${pascalCase(uniqueName)}`]: {
type: 'object',
properties: {
data: {
type: 'object',
properties: cleanSchemaAttributes(requestAttributes, { isRequest: true }),
},
},
},
};
}
schemas = {
...schemas,
[pascalCase(uniqueName)]: getSchemaData(isListOfEntities, cleanSchemaAttributes(attributes)),
};
return schemas;
};
const buildComponentSchema = api => {
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);

View File

@ -1,9 +1,6 @@
'use strict';
const getSchemaData = require('../get-schema-data');
const cleanSchemaAttributes = require('../clean-schema-attributes');
const errorResponse = require('../error-response');
const pascalCase = require('./pascal-case');
/**
*
* @param {boolean} isSingleEntity - Checks for a single entity
@ -29,6 +26,31 @@ const getMeta = isListOfEntities => {
return { type: 'object' };
};
const getSchemaAsArrayOrObject = (isListOfEntities, name) => {
if (isListOfEntities) {
return {
properties: {
data: {
type: 'array',
items: {
$ref: `#/components/schemas/${pascalCase(name)}`,
},
},
meta: getMeta(isListOfEntities),
},
};
}
return {
properties: {
data: {
$ref: `#/components/schemas/${pascalCase(name)}`,
},
meta: getMeta(isListOfEntities),
},
};
};
/**
* @description - Builds the Swagger response object for a given api
*
@ -38,7 +60,7 @@ const getMeta = isListOfEntities => {
*
* @returns The Swagger responses
*/
module.exports = (attributes, route, isListOfEntities = false) => {
module.exports = (name, route, isListOfEntities = false) => {
let schema;
if (route.method === 'DELETE') {
schema = {
@ -46,12 +68,7 @@ module.exports = (attributes, route, isListOfEntities = false) => {
format: 'int64',
};
} else {
schema = {
properties: {
data: getSchemaData(isListOfEntities, cleanSchemaAttributes(attributes)),
meta: getMeta(isListOfEntities),
},
};
schema = getSchemaAsArrayOrObject(isListOfEntities, name);
}
return {
@ -68,7 +85,9 @@ module.exports = (attributes, route, isListOfEntities = false) => {
description: 'Bad Request',
content: {
'application/json': {
schema: errorResponse,
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
@ -76,7 +95,9 @@ module.exports = (attributes, route, isListOfEntities = false) => {
description: 'Unauthorized',
content: {
'application/json': {
schema: errorResponse,
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
@ -84,7 +105,9 @@ module.exports = (attributes, route, isListOfEntities = false) => {
description: 'Forbidden',
content: {
'application/json': {
schema: errorResponse,
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
@ -92,7 +115,9 @@ module.exports = (attributes, route, isListOfEntities = false) => {
description: 'Not Found',
content: {
'application/json': {
schema: errorResponse,
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
@ -100,7 +125,9 @@ module.exports = (attributes, route, isListOfEntities = false) => {
description: 'Internal Server Error',
content: {
'application/json': {
schema: errorResponse,
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},

View File

@ -0,0 +1,38 @@
'use strict';
/**
* @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 {function}
*/
const loopContentTypeNames = (api, callback) => {
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 a unique name if the api name and contentType name don't match
const uniqueName = api.name === contentTypeName ? api.name : `${api.name} - ${contentTypeName}`;
const apiInfo = {
...api,
routeInfo,
attributes,
uniqueName,
};
return callback(apiInfo);
}
};
module.exports = loopContentTypeNames;

View File

@ -0,0 +1,8 @@
'use strict';
const _ = require('lodash');
const pascalCase = string => {
return _.startCase(_.camelCase(string)).replace(/ /g, '');
};
module.exports = pascalCase;

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,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,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',
},
},
},
},
};