@nestia/sdk
Version:
Nestia SDK and Swagger generator
187 lines (169 loc) • 6.72 kB
text/typescript
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",
);
}