@compas/code-gen
Version:
Generate various boring parts of your server
493 lines (429 loc) • 12.1 kB
JavaScript
import { readFileSync } from "fs";
import { environment, isNil, merge, pathJoin } from "@compas/stdlib";
import { upperCaseFirst } from "../../utils.js";
import { fileContextCreateGeneric } from "../file/context.js";
import { fileWrite } from "../file/write.js";
import { referenceUtilsGetProperty } from "../processors/reference-utils.js";
import { structureRoutes } from "../processors/routes.js";
import { structureResolveReference } from "../processors/structure.js";
/**
* Generate the open api specification for the provided structure.
*
* @param {import("../generate").GenerateContext} generateContext
*/
export function openApiGenerate(generateContext) {
if (isNil(generateContext.options.generators.openApi)) {
return;
}
const file = fileContextCreateGeneric(
generateContext,
"common/openapi.json",
{
addGeneratedByComment: false,
},
);
fileWrite(file, JSON.stringify(openApiBuildFile(generateContext), null, 2));
}
/**
* @param {import("../generate").GenerateContext} generateContext
*/
function openApiBuildFile(generateContext) {
const openApiSpec = merge(
{
openapi: "3.0.3",
info: {
title: environment.APP_NAME,
description: "",
version: "0.0.0",
},
servers: [],
tags: [],
paths: {},
components: {
schemas: {
AppError: {
type: "object",
properties: {
info: {
type: "object",
},
key: {
type: "string",
},
status: {
type: "number",
},
requestId: {
type: "string",
},
},
},
},
},
},
generateContext.options.generators.openApi?.openApiExtensions ?? {},
);
// determine compas version
const compasVersion = openApiGetCompasVersion();
const groups = new Set();
for (const route of structureRoutes(generateContext)) {
groups.add(route.group);
openApiTransformRoute(generateContext, openApiSpec, route);
}
openApiSpec.tags = [...groups].map((it) => ({
name: it,
description: "",
}));
openApiSpec[
"x-generator"
] = `Compas (https://compasjs.com) v${compasVersion}`;
return openApiSpec;
}
/**
* @param {import("../generate").GenerateContext} generateContext
* @param {any} openApiSpec
* @param { import("../types").NamedType<import("../generated/common/types").ExperimentalRouteDefinition>} route
*/
function openApiTransformRoute(generateContext, openApiSpec, route) {
const method = route.method.toLowerCase();
const path = openApiTransformPath(route.path);
const uniqueName = upperCaseFirst(route.group) + upperCaseFirst(route.name);
openApiSpec.paths[path] ??= {};
openApiSpec.paths[path][method] = {
tags: [route.group],
description: route.docString,
operationId: uniqueName,
...openApiTransformParams(generateContext, openApiSpec, route),
...openApiTransformBody(generateContext, openApiSpec, route),
responses: openApiTransformResponse(generateContext, openApiSpec, route),
...(generateContext.options.generators.openApi?.openApiRouteExtensions?.[
uniqueName
] ?? {}),
};
}
/**
* Transform path params to {} notation and append leading slash
*
* @param {string} path
* @returns {string}
*/
function openApiTransformPath(path) {
return `/${path
.split("/")
.filter((it) => it.length > 0)
.map((it) => {
if (it.startsWith(":")) {
return `{${it.substring(1)}}`;
}
return it;
})
.join("/")}`;
}
/**
* @param {import("../generate").GenerateContext} generateContext
* @param {any} openApiSpec
* @param { import("../types").NamedType<import("../generated/common/types").ExperimentalRouteDefinition>} route
*/
function openApiTransformParams(generateContext, openApiSpec, route) {
if (isNil(route.query) && isNil(route.params)) {
return {};
}
const parameters = [];
if (route.params) {
const paramsObject = structureResolveReference(
generateContext.structure,
route.params,
);
// @ts-expect-error
for (const [key, param] of Object.entries(paramsObject.keys)) {
parameters.push(inlineTransform(key, param, "path"));
}
}
if (route.query) {
const queryObject = structureResolveReference(
generateContext.structure,
route.query,
);
// @ts-expect-error
for (const [key, param] of Object.entries(queryObject.keys)) {
parameters.push(inlineTransform(key, param, "query"));
}
}
return { parameters };
function inlineTransform(key, param, paramType) {
let schema = {};
switch (param.type) {
case "string":
schema.type = "string";
schema.enum = param?.oneOf;
schema.minLength = param.validator?.min;
schema.maxLength = param.validator?.max;
break;
case "file":
schema.type = "string";
schema.format = "binary";
break;
case "uuid":
schema.type = "string";
schema.format = "uuid";
break;
case "date":
schema.type = "string";
schema.format = "date-time";
break;
case "number":
schema.type = param.validator.floatingPoint ? "number" : "integer";
schema.minimum = param.validator?.min;
schema.maximum = param.validator?.max;
break;
case "reference":
schema = transformType(generateContext, openApiSpec, param);
break;
default:
schema.type = param.type;
break;
}
return {
name: key,
// @ts-ignore
description: param.docString,
// @ts-ignore
required: !param.isOptional,
in: paramType,
schema,
};
}
}
/**
* @param {import("../generate").GenerateContext} generateContext
* @param {any} openApiSpec
* @param { import("../types").NamedType<import("../generated/common/types").ExperimentalRouteDefinition>} route
*/
function openApiTransformBody(generateContext, openApiSpec, route) {
const field = route.body ?? route.files;
if (!field) {
return {};
}
const content = {
// @ts-expect-error
schema: transformType(generateContext, openApiSpec, field),
};
return {
requestBody: {
description: referenceUtilsGetProperty(generateContext, field, [
"docString",
]),
content: {
[route.files ? "multipart/form-data" : "application/json"]: content,
},
required: true,
},
};
}
/**
* @param {import("../generate").GenerateContext} generateContext
* @param {any} openApiSpec
* @param { import("../types").NamedType<import("../generated/common/types").ExperimentalRouteDefinition>} route
*/
function openApiTransformResponse(generateContext, openApiSpec, route) {
const contentAppError = {
"application/json": {
schema: {
$ref: "#/components/schemas/AppError",
},
},
};
// document all non 200 status codes, controlled by compas itself
const defaultResponses = {
400: {
description: "Validation Error",
content: contentAppError,
},
401: {
description: "Unauthorized Error",
content: contentAppError,
},
404: {
description: "Not Found Error",
content: contentAppError,
},
405: {
description: "Not Implemented Error",
content: contentAppError,
},
500: {
description: "Internal Server Error",
content: contentAppError,
},
};
const response = {
description: route.response
? referenceUtilsGetProperty(
generateContext,
route.response,
["docString"],
"",
)
: "",
content: {
"application/json": {
schema: route.response
? // @ts-expect-error
transformType(generateContext, openApiSpec, route.response)
: {},
},
},
};
return {
200: response,
...defaultResponses,
};
}
/**
* Docs: https://swagger.io/docs/specification/data-models/data-types/
*
* @param {import("../generate").GenerateContext} generateContext
* @param {any} openApiSpec
* @param { import("../types").NamedType<import("../generated/common/types").ExperimentalTypeSystemDefinition>} type
*/
function transformType(generateContext, openApiSpec, type) {
const property = {};
if (type.docString) {
property.description = type.docString;
}
if (type.group && type.name) {
const uniqueName = upperCaseFirst(type.group) + upperCaseFirst(type.name);
if (openApiSpec.components.schemas[uniqueName]) {
return {
$ref: `#/components/schemas/${uniqueName}`,
};
}
openApiSpec.components.schemas[uniqueName] = property;
}
switch (type.type) {
case "string":
Object.assign(property, {
type: "string",
minLength: type.validator.min,
maxLength: type.validator.max,
enum: type.oneOf,
});
break;
case "file":
Object.assign(property, {
type: "string",
format: "binary",
});
break;
case "uuid":
Object.assign(property, {
type: "string",
format: "uuid",
});
break;
case "date":
Object.assign(property, {
type: "string",
format: "date-time",
});
break;
case "boolean":
Object.assign(property, {
type: "boolean",
});
break;
case "number":
Object.assign(property, {
type: type.validator.floatingPoint ? "number" : "integer",
minimum: type.validator.min,
maximum: type.validator.max,
});
break;
case "object":
Object.assign(property, {
type: "object",
description: type.docString,
properties: Object.entries(type.keys).reduce(
(curr, [key, property]) => {
// @ts-ignore
curr[key] = transformType(generateContext, openApiSpec, property);
return curr;
},
{},
),
required: Object.entries(type.keys).reduce((curr, [key, property]) => {
// @ts-ignore
if (
!referenceUtilsGetProperty(generateContext, property, [
"isOptional",
])
) {
if (!curr) {
// @ts-ignore
curr = [];
}
// @ts-ignore
curr.push(key);
}
return curr;
}, undefined),
});
break;
case "generic":
Object.assign(property, {
type: "object",
additionalProperties: true,
});
break;
case "array":
Object.assign(property, {
type: "array",
// @ts-expect-error
items: transformType(generateContext, openApiSpec, type.values),
});
break;
case "reference":
return transformType(
generateContext,
openApiSpec,
// @ts-expect-error
structureResolveReference(generateContext.structure, type),
);
case "anyOf":
Object.assign(property, {
type: "object",
anyOf: type.values.map((it) =>
// @ts-expect-error
transformType(generateContext, openApiSpec, it),
),
});
break;
}
if (type.group && type.name) {
const uniqueName = upperCaseFirst(type.group) + upperCaseFirst(type.name);
return {
$ref: `#/components/schemas/${uniqueName}`,
};
}
return property;
}
/**
* Interpret package version number of compas code-gen package.
* Node_modules path is used for current "installed" version.
*
* @returns {string}
*/
function openApiGetCompasVersion() {
const localPackageJson = JSON.parse(
readFileSync(
// take on of the packages for reference
pathJoin(process.cwd(), "./package.json"),
"utf-8",
),
);
return (
localPackageJson.dependencies?.["@compas/code-gen"] ??
localPackageJson.dependencies?.["@compas/code-gen"] ??
"0.0.1"
);
}