swagger-typescript-api-nextgen
Version:
Nextgen TypeScript/JavaScript API generator from Swagger schema
795 lines (688 loc) • 23.1 kB
JavaScript
const _ = require("lodash");
const {
types,
parseSchema,
getType,
getRefType,
getInlineParseContent,
checkAndAddNull,
} = require("./schema");
const { formatModelName } = require("./modelNames");
const {
DEFAULT_BODY_ARG_NAME,
SUCCESS_RESPONSE_STATUS_RANGE,
TS_KEYWORDS,
RESERVED_QUERY_ARG_NAMES,
RESERVED_BODY_ARG_NAMES,
RESERVED_PATH_ARG_NAMES,
RESERVED_HEADER_ARG_NAMES,
} = require("./constants");
const { formatDescription, classNameCase } = require("./common");
const { config } = require("./config");
const { nanoid } = require("nanoid");
const { getRouteName } = require("./routeNames");
const { createComponent } = require("./components");
const { logger } = require("./logger");
const { SpecificArgNameResolver } = require("./utils/resolveName");
const formDataTypes = _.uniq([types.file, types.string.binary]);
const getSchemaFromRequestType = (requestInfo) => {
const content = _.get(requestInfo, "content");
if (!content) return null;
/* content: { "multipart/form-data": { schema: {...} }, "application/json": { schema: {...} } } */
/* for example: dataType = "multipart/form-data" */
for (const dataType in content) {
if (content[dataType] && content[dataType].schema) {
return {
...content[dataType].schema,
dataType,
};
}
}
return null;
};
const getTypeFromRequestInfo = ({
requestInfo,
parsedSchemas,
operationId,
defaultType,
typeName,
}) => {
// TODO: make more flexible pick schema without content type
const schema = getSchemaFromRequestType(requestInfo);
const refTypeInfo = getRefType(requestInfo);
if (schema) {
const content = getInlineParseContent(schema, typeName);
const foundedSchemaByName = _.find(
parsedSchemas,
(parsedSchema) => formatModelName(parsedSchema.name) === content,
);
const foundSchemaByContent = _.find(parsedSchemas, (parsedSchema) =>
_.isEqual(parsedSchema.content, content),
);
const foundSchema = foundedSchemaByName || foundSchemaByContent;
return foundSchema ? formatModelName(foundSchema.name) : content;
}
if (refTypeInfo) {
// const refTypeWithoutOpId = refType.replace(operationId, '');
// const foundedSchemaByName = _.find(parsedSchemas, ({ name }) => name === refType || name === refTypeWithoutOpId)
// TODO:HACK fix problem of swagger2opeanpi
const typeNameWithoutOpId = _.replace(refTypeInfo.typeName, operationId, "");
if (_.find(parsedSchemas, (schema) => schema.name === typeNameWithoutOpId)) {
return formatModelName(typeNameWithoutOpId);
}
switch (refTypeInfo.componentName) {
case "schemas":
return formatModelName(refTypeInfo.typeName);
case "responses":
case "requestBodies":
return getInlineParseContent(
getSchemaFromRequestType(refTypeInfo.rawTypeData),
refTypeInfo.typeName || null,
);
default:
return getInlineParseContent(refTypeInfo.rawTypeData, refTypeInfo.typeName || null);
}
}
return defaultType || TS_KEYWORDS.ANY;
};
const getRequestInfoTypes = ({ requestInfos, parsedSchemas, operationId, defaultType }) =>
_.reduce(
requestInfos,
(acc, requestInfo, status) => {
const contentTypes = getContentTypes([requestInfo]);
return [
...acc,
{
...(requestInfo || {}),
contentTypes: contentTypes,
contentKind: getContentKind(contentTypes),
type: checkAndAddNull(
requestInfo,
getTypeFromRequestInfo({
requestInfo,
parsedSchemas,
operationId,
defaultType,
}),
),
description: formatDescription(requestInfo.description || "", true),
status: _.isNaN(+status) ? status : +status,
isSuccess: isSuccessStatus(status),
},
];
},
[],
);
const isSuccessStatus = (status) =>
(config.defaultResponseAsSuccess && status === "default") ||
(+status >= SUCCESS_RESPONSE_STATUS_RANGE[0] && +status < SUCCESS_RESPONSE_STATUS_RANGE[1]) ||
status === "2xx";
const parseRoute = (route) => {
const pathParamMatches = (route || "").match(
/({(([a-zA-Z]-?_?){1,})([0-9]{1,})?})|(:(([a-zA-Z]-?_?){1,})([0-9]{1,})?:?)/g,
);
// used in case when path parameters is not declared in requestInfo.parameters ("in": "path")
const pathParams = _.reduce(
pathParamMatches,
(pathParams, match) => {
const paramName = _.replace(match, /\{|\}|\:/g, "");
if (!paramName) return pathParams;
if (_.includes(paramName, "-")) {
logger.warn("wrong path param name", paramName);
}
return [
...pathParams,
{
$match: match,
name: _.camelCase(paramName),
required: true,
type: "string",
description: "",
schema: {
type: "string",
},
in: "path",
},
];
},
[],
);
const fixedRoute = _.reduce(
pathParams,
(fixedRoute, pathParam) => {
return _.replace(fixedRoute, pathParam.$match, `\${${pathParam.name}}`);
},
route || "",
);
return {
originalRoute: route || "",
route: fixedRoute,
pathParams,
};
};
const getRouteParams = (routeInfo, pathParams) => {
const { parameters } = routeInfo;
const routeParams = {
path: [],
header: [],
body: [],
query: [],
body: [],
formData: [],
cookie: [],
};
_.each(parameters, (parameter) => {
const refTypeInfo = getRefType(parameter);
let routeParam = null;
if (refTypeInfo && refTypeInfo.rawTypeData.in && refTypeInfo.rawTypeData) {
if (!routeParams[refTypeInfo.rawTypeData.in]) {
routeParams[refTypeInfo.rawTypeData.in] = [];
}
routeParam = {
...refTypeInfo.rawTypeData,
...(refTypeInfo.rawTypeData.schema || {}),
};
} else {
if (!parameter.in) return;
if (!routeParams[parameter.in]) {
routeParams[parameter.in] = [];
}
routeParam = {
...parameter,
...(parameter.schema || {}),
};
}
if (routeParam.in === "path") {
if (!routeParam.name) return;
routeParam.name = _.camelCase(routeParam.name);
}
if (routeParam) {
routeParams[routeParam.in].push(routeParam);
}
});
// used in case when path parameters is not declared in requestInfo.parameters ("in": "path")
_.each(pathParams, (pathParam) => {
const alreadyExist = _.some(routeParams.path, (parameter) => parameter.name === pathParam.name);
if (!alreadyExist) {
routeParams.path.push(pathParam);
}
});
return routeParams;
};
const convertRouteParamsIntoObject = (params) => {
return _.reduce(
params,
(objectSchema, schemaPart) => {
if (!schemaPart || !schemaPart.name) return objectSchema;
return {
...objectSchema,
properties: {
...objectSchema.properties,
[schemaPart.name]: {
...schemaPart,
...(schemaPart.schema || {}),
},
},
};
},
{
properties: {},
type: "object",
},
);
};
const createRequestsMap = (routeInfoByMethodsMap) => {
const parameters = _.get(routeInfoByMethodsMap, "parameters");
return _.reduce(
routeInfoByMethodsMap,
(acc, requestInfo, method) => {
if (method.startsWith("x-") || ["parameters", "$ref"].includes(method)) {
return acc;
}
acc[method] = {
...requestInfo,
parameters: _.compact(_.concat(parameters, requestInfo.parameters)),
};
return acc;
},
{},
);
};
const createRequestParamsSchema = ({
queryParams,
queryObjectSchema,
pathArgsSchemas,
extractRequestParams,
routeName,
}) => {
if (!queryParams || !queryParams.length) return null;
const pathParams = _.reduce(
pathArgsSchemas,
(acc, pathArgSchema) => {
if (pathArgSchema.name) {
acc[pathArgSchema.name] = {
...pathArgSchema,
in: "path",
};
}
return acc;
},
{},
);
const fixedQueryParams = _.reduce(
_.get(queryObjectSchema, "properties", {}),
(acc, property, name) => {
if (name && _.isObject(property)) {
acc[name] = {
...property,
in: "query",
};
}
return acc;
},
{},
);
const schema = {
...queryObjectSchema,
properties: {
...fixedQueryParams,
...pathParams,
},
};
const fixedSchema = config.hooks.onCreateRequestParams(schema);
if (fixedSchema) return fixedSchema;
if (extractRequestParams) {
const typeName = config.componentTypeNameResolver.resolve([
classNameCase(`${routeName.usage} Params`),
]);
return createComponent("schemas", typeName, { ...schema });
}
return schema;
};
const getContentTypes = (requestInfo, extraContentTypes) =>
_.uniq(
_.compact([
...(extraContentTypes || []),
..._.flatten(
_.map(requestInfo, (requestInfoData) => requestInfoData && _.keys(requestInfoData.content)),
),
]),
);
const CONTENT_KIND = {
JSON: "JSON",
URL_ENCODED: "URL_ENCODED",
FORM_DATA: "FORM_DATA",
IMAGE: "IMAGE",
OTHER: "OTHER",
};
const getContentKind = (contentTypes) => {
if (
_.includes(contentTypes, "application/json") ||
_.some(contentTypes, (contentType) => _.endsWith(contentType, "+json"))
) {
return CONTENT_KIND.JSON;
}
if (contentTypes.includes("application/x-www-form-urlencoded")) {
return CONTENT_KIND.URL_ENCODED;
}
if (contentTypes.includes("multipart/form-data")) {
return CONTENT_KIND.FORM_DATA;
}
if (_.some(contentTypes, (contentType) => _.includes(contentType, "image/"))) {
return CONTENT_KIND.IMAGE;
}
return CONTENT_KIND.OTHER;
};
const getRequestBodyInfo = (routeInfo, routeParams, parsedSchemas, routeName) => {
const { requestBody, consumes, requestBodyName, operationId } = routeInfo;
let schema = null;
let type = null;
const contentTypes = getContentTypes(
[requestBody],
[...(consumes || []), routeInfo["x-contentType"]],
);
let contentKind = getContentKind(contentTypes);
let typeName = null;
if (config.extractRequestBody) {
typeName = config.componentTypeNameResolver.resolve([
classNameCase(`${routeName.usage} Payload`),
classNameCase(`${routeName.usage} Body`),
classNameCase(`${routeName.usage} Input`),
]);
}
if (routeParams.formData.length) {
contentKind = CONTENT_KIND.FORM_DATA;
schema = convertRouteParamsIntoObject(routeParams.formData);
type = getInlineParseContent(schema, typeName);
} else if (contentKind === CONTENT_KIND.FORM_DATA) {
schema = getSchemaFromRequestType(requestBody);
type = getInlineParseContent(schema, typeName);
} else if (requestBody) {
schema = getSchemaFromRequestType(requestBody);
type = checkAndAddNull(
requestBody,
getTypeFromRequestInfo({
requestInfo: requestBody,
parsedSchemas,
operationId,
typeName,
}),
);
// TODO: Refactor that.
// It needed for cases when swagger schema is not declared request body type as form data
// but request body data type contains form data types like File
if (formDataTypes.some((dataType) => _.includes(type, `: ${dataType}`))) {
contentKind = CONTENT_KIND.FORM_DATA;
}
}
if (schema && !schema.$ref && config.extractRequestBody) {
schema = createComponent("schemas", typeName, { ...schema });
type = getInlineParseContent(schema);
}
return {
paramName: requestBodyName || (requestBody && requestBody.name) || DEFAULT_BODY_ARG_NAME,
contentTypes,
contentKind,
schema,
type,
required:
requestBody && (typeof requestBody.required === "undefined" || !!requestBody.required),
};
};
const getResponseBodyInfo = (routeInfo, routeParams, parsedSchemas) => {
const { produces, operationId, responses } = routeInfo;
const contentTypes = getContentTypes(responses, [...(produces || []), routeInfo["x-accepts"]]);
const responseInfos = getRequestInfoTypes({
requestInfos: responses,
parsedSchemas,
operationId,
defaultType: config.defaultResponseType || TS_KEYWORDS.VOID,
});
const successResponse = responseInfos.find((response) => response.isSuccess);
const errorResponses = responseInfos.filter(
(response) => !response.isSuccess && response.type !== TS_KEYWORDS.ANY,
);
const handleResponseHeaders = (src) => {
if (!src) {
return "headers: {},";
}
const headerTypes = Object.fromEntries(
Object.entries(src).map(([k, v]) => {
return [k, getType(v)];
}),
);
const r = `headers: { ${Object.entries(headerTypes)
.map(([k, v]) => `"${k}": ${v}`)
.join(",")} },`;
return r;
};
return {
contentTypes,
responses: responseInfos,
success: {
schema: successResponse,
type: (successResponse && successResponse.type) || TS_KEYWORDS.ANY,
},
error: {
schemas: errorResponses,
type: _.uniq(errorResponses.map((response) => response.type)).join(" | ") || TS_KEYWORDS.ANY,
},
full: {
types:
responseInfos
.map(
(response) => `{
data: ${response.type}, status: ${response.status}, statusCode: ${
response.status
}, statusText: "${response.description}", ${handleResponseHeaders(
response.headers,
)} config: {} }`,
)
.join(" | ") || TS_KEYWORDS.ANY,
},
};
};
const parseRoutes = ({
usageSchema,
parsedSchemas,
moduleNameIndex,
moduleNameFirstTag,
extractRequestParams,
}) => {
const { paths, security: globalSecurity } = usageSchema;
const pathsEntries = _.entries(paths);
return pathsEntries.reduce((routes, [rawRoute, routeInfoByMethodsMap]) => {
if (rawRoute.startsWith("x-")) return routes;
const routeInfosMap = createRequestsMap(routeInfoByMethodsMap);
return [
...routes,
..._.compact(
_.map(routeInfosMap, (routeInfo, method) => {
const {
operationId,
requestBody,
security,
parameters,
summary,
description,
tags,
responses,
requestBodyName,
produces,
consumes,
...otherInfo
} = routeInfo;
const { route, pathParams } = parseRoute(rawRoute);
const routeId = nanoid(12);
const firstTag = tags && tags.length > 0 ? tags[0] : null;
const moduleName =
moduleNameFirstTag && firstTag
? _.camelCase(firstTag)
: _.camelCase(_.compact(_.split(route, "/"))[moduleNameIndex]);
const hasSecurity = !!(
(globalSecurity && globalSecurity.length) ||
(security && security.length)
);
const routeParams = getRouteParams(routeInfo, pathParams);
const pathArgs = routeParams.path.map((pathArgSchema) => ({
name: pathArgSchema.name,
optional: !pathArgSchema.required,
type: getInlineParseContent(pathArgSchema.schema),
description: pathArgSchema.description,
}));
const pathArgsNames = pathArgs.map((arg) => arg.name);
const responseBodyInfo = getResponseBodyInfo(routeInfo, routeParams, parsedSchemas);
const rawRouteInfo = {
pathArgs,
operationId,
method,
route: rawRoute,
moduleName,
responsesTypes: responseBodyInfo.responses,
description,
tags,
summary,
responses,
produces,
requestBody,
consumes,
...otherInfo,
};
const queryObjectSchema = convertRouteParamsIntoObject(routeParams.query);
const pathObjectSchema = convertRouteParamsIntoObject(routeParams.path);
const headersObjectSchema = convertRouteParamsIntoObject(routeParams.header);
const routeName = getRouteName(rawRouteInfo);
const requestBodyInfo = getRequestBodyInfo(
routeInfo,
routeParams,
parsedSchemas,
routeName,
);
const requestParamsSchema = createRequestParamsSchema({
queryParams: routeParams.query,
pathArgsSchemas: routeParams.path,
queryObjectSchema,
extractRequestParams,
routeName,
});
const queryType = routeParams.query.length
? getInlineParseContent(queryObjectSchema)
: null;
const pathType = routeParams.path.length ? getInlineParseContent(pathObjectSchema) : null;
const headersType = routeParams.header.length
? getInlineParseContent(headersObjectSchema)
: null;
const nameResolver = new SpecificArgNameResolver(pathArgsNames);
const specificArgs = {
query: queryType
? {
name: nameResolver.resolve(RESERVED_QUERY_ARG_NAMES),
optional: parseSchema(queryObjectSchema, null).allFieldsAreOptional,
type: queryType,
}
: void 0,
body: requestBodyInfo.type
? {
name: nameResolver.resolve([
requestBodyInfo.paramName,
...RESERVED_BODY_ARG_NAMES,
]),
optional: !requestBodyInfo.required,
type: requestBodyInfo.type,
}
: void 0,
pathParams: pathType
? {
name: nameResolver.resolve(RESERVED_PATH_ARG_NAMES),
optional: parseSchema(pathObjectSchema, null).allFieldsAreOptional,
type: pathType,
}
: void 0,
headers: headersType
? {
name: nameResolver.resolve(RESERVED_HEADER_ARG_NAMES),
optional: parseSchema(headersObjectSchema, null).allFieldsAreOptional,
type: headersType,
}
: void 0,
};
let routeArgs = _.compact([...pathArgs, specificArgs.query, specificArgs.body]);
if (routeArgs.some((pathArg) => pathArg.optional)) {
const { optionalArgs, requiredArgs } = _.reduce(
[...routeArgs],
(acc, pathArg) => {
if (pathArg.optional) {
acc.optionalArgs.push(pathArg);
} else {
acc.requiredArgs.push(pathArg);
}
return acc;
},
{
optionalArgs: [],
requiredArgs: [],
},
);
routeArgs = [...requiredArgs, ...optionalArgs];
}
const routeData = {
id: routeId,
namespace: _.replace(moduleName, /^(\d)/, "v$1"),
routeName,
routeParams,
requestBodyInfo,
responseBodyInfo,
specificArgs,
queryObjectSchema,
pathObjectSchema,
headersObjectSchema,
responseBodySchema: responseBodyInfo.success.schema,
requestBodySchema: requestBodyInfo.schema,
specificArgNameResolver: nameResolver,
request: {
contentTypes: requestBodyInfo.contentTypes,
parameters: pathArgs,
path: route,
formData: requestBodyInfo.contentKind === CONTENT_KIND.FORM_DATA,
isQueryBody: requestBodyInfo.contentKind === CONTENT_KIND.URL_ENCODED,
security: hasSecurity,
method: method,
requestParams: requestParamsSchema,
payload: specificArgs.body,
query: specificArgs.query,
pathParams: specificArgs.pathParams,
headers: specificArgs.headers,
},
response: {
contentTypes: responseBodyInfo.contentTypes,
type: responseBodyInfo.success.type,
errorType: responseBodyInfo.error.type,
fullTypes: responseBodyInfo.full.types,
},
raw: rawRouteInfo,
};
const usageRouteData = config.hooks.onCreateRoute(routeData);
return usageRouteData === false ? null : usageRouteData || routeData;
}),
),
];
}, []);
};
const groupRoutes = (routes) => {
return _.reduce(
routes.reduce(
(modules, route) => {
if (route.namespace) {
if (!modules[route.namespace]) {
modules[route.namespace] = [];
}
modules[route.namespace].push(route);
} else {
modules.$outOfModule.push(route);
}
return modules;
},
{
$outOfModule: [],
},
),
(acc, packRoutes, moduleName) => {
if (moduleName === "$outOfModule") {
acc.outOfModule = packRoutes;
} else {
if (!acc.combined) acc.combined = [];
acc.combined.push({
moduleName,
routes: _.map(packRoutes, (route) => {
const { original: originalName, usage: usageName } = route.routeName;
// TODO: https://github.com/acacode/swagger-typescript-api/issues/152
// TODO: refactor
if (
packRoutes.length > 1 &&
usageName !== originalName &&
!_.some(
packRoutes,
({ routeName, id }) => id !== route.id && originalName === routeName.original,
)
) {
return {
...route,
routeName: {
...route.routeName,
usage: originalName,
},
};
}
return route;
}),
});
}
return acc;
},
{},
);
};
module.exports = {
parseRoutes,
groupRoutes,
};