@compas/code-gen
Version:
Generate various boring parts of your server
205 lines (187 loc) • 4.93 kB
JavaScript
import { readFileSync } from "fs";
import { merge, pathJoin } from "@compas/stdlib";
import {
transformBody,
transformParams,
transformResponse,
} from "./transform.js";
/**
* @type {any}
*/
const OPENAPI_SPEC_TEMPLATE = {
openapi: "3.0.3",
info: {
title: process.env.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",
},
},
},
},
},
};
/**
* @typedef GenerateOpenApiFileOpts
* @property {import("./index.js").OpenApiExtensions} openApiExtensions
* @property {import("./index.js").OpenApiRouteExtensions} openApiRouteExtensions
* @property {string[]} enabledGroups
* @property {boolean} verbose
*/
/**
* @param {import("../../generated/common/types").CodeGenStructure} structure
* @param {GenerateOpenApiFileOpts} options
* @returns {string}
*/
export function generateOpenApiFile(structure, options) {
const openApiSpec = merge(
{},
OPENAPI_SPEC_TEMPLATE,
options.openApiExtensions,
);
// transform CodeGenRouteTypes to endpoints/paths
for (const [group, groupStructure] of Object.entries(structure)) {
/**
* @type {import("../../generated/common/types").CodeGenRouteType[]}
*/
// @ts-ignore
const groupRoutes = Object.values(groupStructure).filter(
(it) => it.type === "route",
);
// ensure tag
if (groupRoutes.length > 0) {
openApiSpec.tags.push({
name: group,
description: "",
});
}
for (const route of groupRoutes) {
// define endpoint
const method = route.method.toLowerCase();
const path = transformRoutePath(route.path);
// ensure parent group is present for child methods
if (!openApiSpec.paths[path]) {
openApiSpec.paths[path] = {};
}
openApiSpec.paths[path][method] = {
tags: [route.group],
description: route.docString,
operationId: route.uniqueName,
...transformParams(structure, route, openApiSpec.components.schemas),
...transformBody(structure, route, openApiSpec.components.schemas),
responses: constructResponse(
structure,
route,
openApiSpec.components.schemas,
), // @ts-ignore
...(options.openApiRouteExtensions?.[route.uniqueName] ?? {}),
};
}
}
// determine compas version
const compasVersion = parseCompasVersionNumber();
openApiSpec[
"x-generator"
] = `Compas (https://compasjs.com) v${compasVersion}`;
return openApiSpec;
}
/**
* Transform routes to responses but wrapped with possible compas
* error (http status codes) states (and explanation)
*
* @param {import("../../generated/common/types").CodeGenStructure} structure
* @param {import("../../generated/common/types").CodeGenRouteType} route
* @param {Record<string, any>} existingSchemas
* @returns {object}
*/
function constructResponse(structure, route, existingSchemas) {
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,
},
};
// 200 behaviour
const response = transformResponse(structure, route, existingSchemas);
return {
200: response,
...defaultResponses,
};
}
/**
* Interpret package version number of compas code-gen package.
* Node_modules path is used for current "installed" version.
*
* @returns {string}
*/
function parseCompasVersionNumber() {
const { version } = JSON.parse(
readFileSync(
// take on of the packages for reference
pathJoin(process.cwd(), "./node_modules/@compas/code-gen/package.json"),
"utf-8",
),
);
return version ?? "0.0.1";
}
/**
* Transform path params to {} notation and append leading slash
*
* @param {string} path
* @returns {string}
*/
function transformRoutePath(path) {
return `/${path
.split("/")
.filter((it) => it.length > 0)
.map((it) => {
if (it.startsWith(":")) {
return `{${it.substring(1)}}`;
}
return it;
})
.join("/")}`;
}