238 lines
12 KiB
TypeScript
238 lines
12 KiB
TypeScript
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
import { buildRouter } from "@wrnexus/router";
|
|
|
|
export type SdkLanguage = "typescript" | "javascript" | "java" | "go" | "python";
|
|
export interface ApiOperation {
|
|
id: string;
|
|
method: string;
|
|
path: string;
|
|
source: string;
|
|
summary?: string;
|
|
description?: string;
|
|
webhook?: { event: string; payloadSchema?: string; signatureHeader?: string };
|
|
}
|
|
export interface ApiArtifacts {
|
|
operations: ApiOperation[];
|
|
files: string[];
|
|
}
|
|
|
|
function methods(file: string): string[] {
|
|
const source = readFileSync(file, "utf8");
|
|
const values = [
|
|
...source.matchAll(
|
|
/export\s+(?:async\s+)?(?:const|function)\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g,
|
|
),
|
|
].map((match) => match[1]!);
|
|
if (file.endsWith(".wrn"))
|
|
for (const match of source.matchAll(/\bapi\s+(GET|POST|PUT|PATCH|DELETE)\s+/g))
|
|
values.push(match[1]!);
|
|
return [...new Set(values.length ? values : ["GET"])];
|
|
}
|
|
function openapiPath(path: string): string {
|
|
return path
|
|
.replace(/\[\.\.\.([A-Za-z_$][\w$]*)\]/g, "{$1}")
|
|
.replace(/\[([A-Za-z_$][\w$]*)\]/g, "{$1}")
|
|
.replace(/:([A-Za-z_$][\w$]*)/g, "{$1}")
|
|
.replace(/\*([A-Za-z_$][\w$]*)/g, "{$1}");
|
|
}
|
|
function operationId(method: string, path: string): string {
|
|
const words =
|
|
`${method.toLowerCase()}-${path.replace(/^\/api\/?/, "").replace(/[^A-Za-z0-9]+/g, "-") || "root"}`
|
|
.split("-")
|
|
.filter(Boolean);
|
|
return (
|
|
words[0]! +
|
|
words
|
|
.slice(1)
|
|
.map((word) => word[0]!.toUpperCase() + word.slice(1))
|
|
.join("")
|
|
);
|
|
}
|
|
|
|
export function inspectApi(appRoot: string): ApiOperation[] {
|
|
const root = resolve(appRoot);
|
|
const router = buildRouter(join(root, "app"));
|
|
return router.api.flatMap((route) => {
|
|
const sourceText = readFileSync(route.file, "utf8");
|
|
const field = (name: string) =>
|
|
new RegExp(`${name}\\s*:\\s*["']([^"']+)["']`).exec(sourceText)?.[1];
|
|
const webhook = /\b(?:defineWebhook\s*\(|webhook\s*=)\s*\{/.test(sourceText)
|
|
? {
|
|
event: field("event") ?? operationId("event", route.raw),
|
|
payloadSchema: field("payloadSchema"),
|
|
signatureHeader: field("signatureHeader"),
|
|
}
|
|
: undefined;
|
|
return methods(route.file).map((method) => ({
|
|
id: operationId(method, route.raw),
|
|
method,
|
|
path: openapiPath(route.raw),
|
|
source: relative(root, route.file).replace(/\\/g, "/"),
|
|
summary: field("summary"),
|
|
description: field("description"),
|
|
webhook,
|
|
}));
|
|
});
|
|
}
|
|
|
|
export function createOpenApi(operations: ApiOperation[], title = "WRNexus API") {
|
|
const paths: Record<string, Record<string, unknown>> = {};
|
|
for (const operation of operations) {
|
|
const parameters = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => ({
|
|
name: match[1],
|
|
in: "path",
|
|
required: true,
|
|
schema: { type: "string" },
|
|
}));
|
|
(paths[operation.path] ??= {})[operation.method.toLowerCase()] = {
|
|
operationId: operation.id,
|
|
summary: operation.summary ?? `${operation.method} ${operation.path}`,
|
|
...(operation.description ? { description: operation.description } : {}),
|
|
tags: ["API"],
|
|
parameters,
|
|
responses: {
|
|
"200": {
|
|
description: "Successful response",
|
|
content: { "application/json": { schema: {} } },
|
|
},
|
|
"400": { description: "Invalid request" },
|
|
"500": { description: "Internal error" },
|
|
},
|
|
"x-wrnexus-source": operation.source,
|
|
};
|
|
}
|
|
const webhooks = Object.fromEntries(
|
|
operations
|
|
.filter((operation) => operation.webhook)
|
|
.map((operation) => [
|
|
operation.webhook!.event,
|
|
{
|
|
post: {
|
|
summary: operation.summary ?? operation.webhook!.event,
|
|
description: operation.description,
|
|
parameters: operation.webhook!.signatureHeader
|
|
? [
|
|
{
|
|
name: operation.webhook!.signatureHeader,
|
|
in: "header",
|
|
required: true,
|
|
schema: { type: "string" },
|
|
},
|
|
]
|
|
: [],
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
"application/json": {
|
|
schema: operation.webhook!.payloadSchema
|
|
? { $ref: operation.webhook!.payloadSchema }
|
|
: {},
|
|
},
|
|
},
|
|
},
|
|
responses: { "200": { description: "Webhook accepted" } },
|
|
"x-wrnexus-source": operation.source,
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
return {
|
|
openapi: "3.1.0",
|
|
info: { title, version: "0.8.0" },
|
|
paths,
|
|
...(Object.keys(webhooks).length ? { webhooks } : {}),
|
|
};
|
|
}
|
|
|
|
function sdk(language: SdkLanguage, operations: ApiOperation[]): string {
|
|
const route = (operation: ApiOperation) =>
|
|
operation.path.replace(/\{([^}]+)\}/g, "${encodeURIComponent(params.$1)}");
|
|
if (language === "typescript" || language === "javascript")
|
|
return `${language === "typescript" ? "export type RequestOptions = { baseUrl?: string; headers?: HeadersInit };\ntype ApiEnvelope = { data?: unknown; error?: { message?: string } };\n" : ""}const request = async (${language === "typescript" ? "method: string, path: string, body: unknown, options: RequestOptions = {}" : "method, path, body, options = {}"}) => { const response = await globalThis.fetch((options.baseUrl || "") + path, { method, headers: { "content-type": "application/json", ...(options.headers || {}) }, body: body === undefined ? undefined : JSON.stringify(body) }); const value${language === "typescript" ? ": ApiEnvelope" : ""} = await response.json(); if (!response.ok) throw Object.assign(new Error(value?.error?.message || "API request failed"), { status: response.status, body: value }); return value.data ?? value; };\n${operations.map((operation) => `export const ${operation.id} = (params${language === "typescript" ? ": Record<string, string> = {}" : " = {}"}, body${language === "typescript" ? ": unknown" : ""}, options${language === "typescript" ? ": RequestOptions = {}" : " = {}"}) => { ${operation.path.includes("{") ? "" : "void params; "}return request("${operation.method}", \`${route(operation)}\`, body, options); };`).join("\n")}\n`;
|
|
if (language === "python")
|
|
return `import json, urllib.request\n\nclass WrnexusApi:\n def __init__(self, base_url): self.base_url = base_url.rstrip('/')\n def request(self, method, path, body=None):\n data = None if body is None else json.dumps(body).encode()\n request = urllib.request.Request(self.base_url + path, data=data, method=method, headers={'content-type':'application/json'})\n with urllib.request.urlopen(request) as response: return json.load(response)\n${operations.map((operation) => ` def ${operation.id}(self, path, body=None): return self.request('${operation.method}', path, body)`).join("\n")}\n`;
|
|
if (language === "go")
|
|
return `package wrnexussdk\n\nimport ("bytes"; "encoding/json"; "fmt"; "net/http")\ntype Client struct { BaseURL string; HTTP *http.Client }\nfunc (c *Client) Request(method, path string, body any) (map[string]any, error) { data,_:=json.Marshal(body); req,_:=http.NewRequest(method,c.BaseURL+path,bytes.NewReader(data)); req.Header.Set("content-type","application/json"); client:=c.HTTP;if client==nil{client=http.DefaultClient};res,err:=client.Do(req);if err!=nil{return nil,err};defer res.Body.Close();if res.StatusCode>=400{return nil,fmt.Errorf("API status %d",res.StatusCode)};var out map[string]any;err=json.NewDecoder(res.Body).Decode(&out);return out,err }\n`;
|
|
return `package dev.wrnexus.sdk;\nimport java.net.URI; import java.net.http.*;\npublic final class WrnexusApi { private final String baseUrl; private final HttpClient http = HttpClient.newHttpClient(); public WrnexusApi(String baseUrl){this.baseUrl=baseUrl;} public String request(String method,String path,String json)throws Exception{var request=HttpRequest.newBuilder(URI.create(baseUrl+path)).header("content-type","application/json").method(method,HttpRequest.BodyPublishers.ofString(json==null?"":json)).build();var response=http.send(request,HttpResponse.BodyHandlers.ofString());if(response.statusCode()>=400)throw new IllegalStateException("API status "+response.statusCode());return response.body();} }\n`;
|
|
}
|
|
|
|
function write(path: string, value: string, files: string[]): void {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, value);
|
|
files.push(path);
|
|
}
|
|
const escapeHtml = (value: unknown) =>
|
|
String(value ?? "").replace(
|
|
/[&<>"']/g,
|
|
(character) =>
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!,
|
|
);
|
|
export function generateApiArtifacts(
|
|
appRoot: string,
|
|
languages: SdkLanguage[] = ["typescript"],
|
|
): ApiArtifacts {
|
|
const root = resolve(appRoot);
|
|
const output = join(root, "generated", "api");
|
|
const operations = inspectApi(root);
|
|
const files: string[] = [];
|
|
const spec = createOpenApi(operations, `${basename(root)} API`);
|
|
write(join(output, "openapi.json"), JSON.stringify(spec, null, 2) + "\n", files);
|
|
write(
|
|
join(output, "postman.json"),
|
|
JSON.stringify(
|
|
{
|
|
info: {
|
|
name: spec.info.title,
|
|
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
|
},
|
|
item: operations.map((operation) => ({
|
|
name: operation.id,
|
|
request: { method: operation.method, url: `{{baseUrl}}${operation.path}` },
|
|
})),
|
|
variable: [{ key: "baseUrl", value: "http://localhost:3000" }],
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
files,
|
|
);
|
|
write(
|
|
join(output, "index.html"),
|
|
`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(spec.info.title)}</title><style>body{font:16px system-ui;max-width:960px;margin:auto;padding:2rem}code,pre{background:#f4f4f5;padding:.2rem .4rem}article{border-bottom:1px solid #ddd;padding:1rem 0}</style><h1>${escapeHtml(spec.info.title)}</h1><p>OpenAPI 3.1 · ${operations.length} operations · <a href="openapi.json">specification</a></p>${operations.map((operation) => `<article><h2><code>${escapeHtml(operation.method)}</code> ${escapeHtml(operation.path)}</h2><p>${escapeHtml(operation.summary ?? operation.id)}</p>${operation.description ? `<p>${escapeHtml(operation.description)}</p>` : ""}${operation.webhook ? `<p>Webhook event: <code>${escapeHtml(operation.webhook.event)}</code>${operation.webhook.signatureHeader ? ` · signature: <code>${escapeHtml(operation.webhook.signatureHeader)}</code>` : ""}</p>` : ""}<small>${escapeHtml(operation.source)}</small></article>`).join("")}`,
|
|
files,
|
|
);
|
|
write(
|
|
join(output, "examples.md"),
|
|
`# API examples\n\n${operations.map((operation) => `## ${operation.id}\n\n\`\`\`bash\ncurl -X ${operation.method} "http://localhost:3000${operation.path}"\n\`\`\`\n`).join("\n")}`,
|
|
files,
|
|
);
|
|
const extensions = {
|
|
typescript: "ts",
|
|
javascript: "js",
|
|
java: "java",
|
|
go: "go",
|
|
python: "py",
|
|
} as const;
|
|
for (const language of languages)
|
|
write(
|
|
join(output, "sdk", language, `wrnexus-api.${extensions[language]}`),
|
|
sdk(language, operations),
|
|
files,
|
|
);
|
|
return { operations, files };
|
|
}
|
|
|
|
export function runApiCommand(appRoot: string, kind: "api" | "sdk", args: string[]): ApiArtifacts {
|
|
const supported: SdkLanguage[] = ["typescript", "javascript", "java", "go", "python"];
|
|
const language = args.find((value) => supported.includes(value as SdkLanguage)) as
|
|
SdkLanguage | undefined;
|
|
if (kind === "sdk" && !language)
|
|
throw new Error("WRN-SDK-LANGUAGE: choose typescript, javascript, java, go, or python.");
|
|
const result = generateApiArtifacts(appRoot, kind === "sdk" ? [language!] : supported);
|
|
console.log(
|
|
`✓ Generated ${result.operations.length} API operations and ${result.files.length} artifacts in generated/api`,
|
|
);
|
|
return result;
|
|
}
|