UNPKG

winterspec

Version:

Write Winter-CG compatible routes with filesystem routing and tons of features

171 lines (170 loc) 7.78 kB
import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { OpenApiBuilder, } from "openapi3-ts/oas31"; import { bundle } from "../../../bundle/bundle.js"; import { BaseCommand } from "../../base-command.js"; import { loadBundle } from "../../../helpers.js"; import { generateSchema } from "@anatine/zod-openapi"; import camelcase from "camelcase"; import { pathToFileURL } from "node:url"; const replaceFirstCharToLowercase = (str) => { if (str.length === 0) { return str; } const firstChar = str.charAt(0).toLowerCase(); return firstChar + str.slice(1); }; const transformPathToOperationId = (path) => { const parts = path .replace(/-/g, "_") .split("/") .filter((part) => part !== ""); const transformedParts = parts.map((part) => { if (part.startsWith("[") && part.endsWith("]")) { // Convert [param] to ByParam const serviceName = part.slice(1, -1); const words = serviceName.split("_"); const capitalizedWords = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)); return `By${capitalizedWords.join("")}`; } else { // Convert api_path to ApiPath const words = part.split("_"); const capitalizedWords = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)); return capitalizedWords.join(""); } }); return replaceFirstCharToLowercase(transformedParts.join("")); }; const HTTP_METHODS_WITHOUT_BODY = ["get", "head"]; export class CodeGenOpenAPI extends BaseCommand { register() { this.program .command("codegen-openapi") .description("Generate OpenAPI specification from route definitions") .requiredOption("-o, --output <path>", "Path to the output file") .option("--root <path>", "Path to your project root") .option("--tsconfig <path>", "Path to your tsconfig.json") .option("--routes-directory <path>", "Path to your routes directory") .option("--platform <platform>", "The platform to bundle for") .action(async (options) => { const config = await this.loadConfig(options); const tempBundlePath = path.join(os.tmpdir(), `${randomUUID()}.mjs`); const bundleResult = await bundle(config); await fs.writeFile(tempBundlePath, bundleResult.code); const runtimeBundle = await loadBundle(pathToFileURL(tempBundlePath).href); const globalRouteSpec = Object.values(runtimeBundle.routeMapWithHandlers).find((r) => Boolean(r._globalSpec))?._globalSpec; if (!globalRouteSpec) { throw new Error("You must have at least one route that uses the wrapper provided by createWithWinterSpec()."); } const builder = new OpenApiBuilder({ openapi: "3.0.0", info: { title: globalRouteSpec.openapi?.apiName ?? "WinterSpec API", version: "1.0.0", // todo }, ...(globalRouteSpec.openapi?.productionServerUrl ? { servers: [ { url: globalRouteSpec.openapi.productionServerUrl, }, ], } : {}), }); for (const [path, { _routeSpec }] of Object.entries(runtimeBundle.routeMapWithHandlers)) { if (!_routeSpec) { continue; } const pathItemObject = {}; for (const method of _routeSpec.methods) { let { commonParams } = _routeSpec; const areCommonParamsRequiredInQuery = HTTP_METHODS_WITHOUT_BODY.includes(method.toLowerCase()); if (!areCommonParamsRequiredInQuery) { if (commonParams?._def.typeName === "ZodObject") { commonParams = commonParams.partial(); } else { commonParams = commonParams?.optional(); } } let requestJsonBody = null; if (_routeSpec.jsonBody && !HTTP_METHODS_WITHOUT_BODY.includes(method.toLowerCase())) { requestJsonBody = _routeSpec.jsonBody; } if (commonParams) { requestJsonBody = requestJsonBody ? requestJsonBody.and(commonParams) : commonParams; } let requestQuery = null; if (_routeSpec.queryParams) { requestQuery = _routeSpec.queryParams; } if (commonParams) { requestQuery = requestQuery ? requestQuery.and(commonParams) : commonParams; } const operation = { summary: path, responses: { 200: { description: "OK", }, 400: { description: "Bad Request", }, // todo: omit when auth: "none" 401: { description: "Unauthorized", }, }, }; if (requestJsonBody) { operation.requestBody = { content: { "application/json": { schema: generateSchema(requestJsonBody), }, }, }; } if (requestQuery) { const schema = generateSchema(requestQuery); if (schema.properties) { const parameters = Object.keys(schema.properties).map((name) => ({ name, in: "query", schema: schema.properties?.[name], required: schema.required?.includes(name), })); operation.parameters = parameters; } } if (_routeSpec.jsonResponse) { // todo: responses other than 200 operation.responses[200].content = { "application/json": { schema: generateSchema(_routeSpec.jsonResponse), }, }; } pathItemObject[method.toLowerCase()] = { ...operation, operationId: `${transformPathToOperationId(path)}${camelcase(method, { pascalCase: true, })}`, }; } // Handle routes with multiple methods builder.addPath(path, pathItemObject); } await fs.writeFile(options.output, builder.getSpecAsJson(undefined, 2)); }); } }