UNPKG

@nestia/sdk

Version:

Nestia SDK and Swagger generator

187 lines (169 loc) 6.72 kB
import fs from "fs"; import NodePath from "path"; import { INestiaConfig } from "../INestiaConfig"; import { IReflectOperationError } from "../structures/IReflectOperationError"; import { IReflectType } from "../structures/IReflectType"; import { ITypedApplication } from "../structures/ITypedApplication"; import { ITypedHttpRoute } from "../structures/ITypedHttpRoute"; import { ITypedMcpRoute } from "../structures/ITypedMcpRoute"; import { CloneGenerator } from "./CloneGenerator"; import { SdkDistributionComposer } from "./internal/SdkDistributionComposer"; import { SdkFileProgrammer } from "./internal/SdkFileProgrammer"; import { SdkHttpParameterProgrammer } from "./internal/SdkHttpParameterProgrammer"; export namespace SdkGenerator { export const generate = async (app: ITypedApplication): Promise<void> => { if (app.project.config.output === undefined) throw new Error("Output directory is not defined."); // PREPARE NEW DIRECTORIES console.log("Generating SDK Library"); await fs.promises.mkdir(app.project.config.output, { recursive: true }); // BUNDLING await bundle(app.project.config.output); // STRUCTURES if (app.project.config.clone === true) await CloneGenerator.write(app); // FUNCTIONAL await SdkFileProgrammer.generate(app); // DISTRIBUTION if (app.project.config.distribute !== undefined) await SdkDistributionComposer.compose({ config: app.project.config, mcp: app.routes.some((r) => r.protocol === "mcp"), websocket: app.routes.some((r) => r.protocol === "websocket"), }); }; export const validate = ( app: ITypedApplication, ): IReflectOperationError[] => { const errors: IReflectOperationError[] = []; validateMcpDuplicates(errors)(app.routes); validateMcpAccessors(errors)(app.routes); if (app.project.config.clone === true) return errors; for (const route of app.routes) if (route.protocol === "http") validateImplicit({ config: app.project.config, errors, route, }); return errors; }; const validateMcpDuplicates = (errors: IReflectOperationError[]) => (routes: ITypedApplication["routes"]): void => { const dict: Map<string, ITypedMcpRoute[]> = new Map(); for (const route of routes) if (route.protocol === "mcp") { const array = dict.get(route.toolName) ?? []; array.push(route); dict.set(route.toolName, array); } for (const [toolName, list] of dict) if (list.length > 1) for (const route of list) errors.push({ file: route.controller.file, class: route.controller.class.name, function: route.function.name || route.name, from: `@McpRoute(${JSON.stringify(toolName)})`, contents: [ `Duplicate MCP tool name ${JSON.stringify(toolName)} is not allowed.`, ], }); }; const validateMcpAccessors = (errors: IReflectOperationError[]) => (routes: ITypedApplication["routes"]): void => { const dict: Map<string, ITypedMcpRoute[]> = new Map(); for (const route of routes) if (route.protocol === "mcp") { const accessor = route.accessor.join("."); const array = dict.get(accessor) ?? []; array.push(route); dict.set(accessor, array); } for (const [accessor, list] of dict) if (list.length > 1) for (const route of list) errors.push({ file: route.controller.file, class: route.controller.class.name, function: route.function.name || route.name, from: `@McpRoute(${JSON.stringify(route.toolName)})`, contents: [ `MCP tool name ${JSON.stringify(route.toolName)} conflicts on generated SDK accessor "api.functional.${accessor}".`, ], }); }; const validateImplicit = (props: { config: INestiaConfig; errors: IReflectOperationError[]; route: ITypedHttpRoute; }): void => { for (const p of SdkHttpParameterProgrammer.getAll(props.route)) { if (isImplicitType(p.type)) props.errors.push({ file: props.route.controller.file, class: props.route.controller.class.name, function: props.route.name, from: `parameter ${JSON.stringify(p.name)}`, contents: [`implicit (unnamed) parameter type.`], }); } if (props.config.propagate === true) for (const [key, value] of Object.entries(props.route.exceptions)) if (isImplicitType(value.type)) props.errors.push({ file: props.route.controller.file, class: props.route.controller.class.name, function: props.route.name, from: `exception ${JSON.stringify(key)}`, contents: [`implicit (unnamed) exception type.`], }); if ( props.route.success.binary === false && isImplicitType(props.route.success.type) ) props.errors.push({ file: props.route.controller.file, class: props.route.controller.class.name, function: props.route.name, from: "success", contents: [`implicit (unnamed) return type.`], }); }; const isImplicitType = (type: IReflectType): boolean => type.name === "__type" || type.name === "__object" || type.name.startsWith("__type.") || type.name.startsWith("__object.") || type.name.includes("readonly [") || (!!type.typeArguments?.length && type.typeArguments.some(isImplicitType)); /** * Emplace the static bundle files (`index.ts`, `module.ts`, ...) into the SDK * output directory. * * Files the user already has are never touched, so that hand-written * customizations (e.g. extra re-exports in `module.ts`) survive regeneration. * Only missing files are filled in from the bundle. */ export const bundle = async (output: string): Promise<void> => { const files: string[] = await fs.promises.readdir(BUNDLE_PATH); for (const file of files) { const current: string = `${BUNDLE_PATH}/${file}`; const target: string = `${output}/${file}`; const stats: fs.Stats = await fs.promises.stat(current); if (stats.isFile() === false) continue; if (fs.existsSync(target) === true) continue; const content: string = await fs.promises.readFile(current, "utf8"); await fs.promises.writeFile(target, content, "utf8"); } }; export const BUNDLE_PATH = NodePath.join( __dirname, "..", "..", "assets", "bundle", "api", ); }