feat(cli): generate type assertions for api blocks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
|
|||||||
// Keep the CLI checker sourced from the package contract so typecheck fixes are
|
// Keep the CLI checker sourced from the package contract so typecheck fixes are
|
||||||
// included in each published CLI bundle.
|
// included in each published CLI bundle.
|
||||||
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
|
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
|
||||||
import { parse } from "@wrnexus/syntax";
|
import { parse, type PageAst } from "@wrnexus/syntax";
|
||||||
import { generate, generateTargets } from "@wrnexus/compiler";
|
import { generate, generateTargets } from "@wrnexus/compiler";
|
||||||
import { regenerateRoutes } from "./routes.ts";
|
import { regenerateRoutes } from "./routes.ts";
|
||||||
import { loadAppConfig } from "@wrnexus/styles";
|
import { loadAppConfig } from "@wrnexus/styles";
|
||||||
@@ -99,6 +99,42 @@ function writePluginArtifacts(root: string, contributions?: PluginContributions)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type assertions for sectioned api blocks.
|
||||||
|
*
|
||||||
|
* Enforcement lives here rather than in the compiler because this file is under
|
||||||
|
* `app/` and is therefore compiled by the project's own tsc, while generated
|
||||||
|
* build artifacts are not type-checked at all.
|
||||||
|
*/
|
||||||
|
function apiBlockAssertions(pages: { path: string; ast: PageAst }[], apiContracts: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
for (const block of page.ast.dataApis) {
|
||||||
|
if (!block.sections) continue;
|
||||||
|
|
||||||
|
if (!apiContracts.includes(JSON.stringify(block.path))) {
|
||||||
|
console.warn(
|
||||||
|
`[wrnexus] api block "${block.name}" targets ${block.path}, which has no defineEndpoint contract — its declared types are not checked.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = [...block.sections.parameters, ...block.sections.body];
|
||||||
|
const shape = fields.length
|
||||||
|
? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`
|
||||||
|
: "Record<string, never>";
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
` type __wrn_api_check_${block.name} = __wrn_expect_true<AssertAssignable<${shape}, ApiInput<${JSON.stringify(
|
||||||
|
block.path,
|
||||||
|
)}, ${JSON.stringify(block.method)}>>>;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export function generateApplicationTypes(
|
export function generateApplicationTypes(
|
||||||
appRoot: string,
|
appRoot: string,
|
||||||
pluginContributions?: PluginContributions,
|
pluginContributions?: PluginContributions,
|
||||||
@@ -145,6 +181,10 @@ export function generateApplicationTypes(
|
|||||||
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
|
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
const pageAsts = files(app, (path) => extname(path) === ".wrn").map((file) => ({
|
||||||
|
path: file,
|
||||||
|
ast: parse(readFileSync(file, "utf8")),
|
||||||
|
}));
|
||||||
const typeDir = join(app, "types");
|
const typeDir = join(app, "types");
|
||||||
const apiContracts = router.api
|
const apiContracts = router.api
|
||||||
.map((route) => {
|
.map((route) => {
|
||||||
@@ -218,6 +258,11 @@ declare namespace WRNexusGenerated {
|
|||||||
${generatedContractMap("RealtimeMessages", realtimeContracts)}
|
${generatedContractMap("RealtimeMessages", realtimeContracts)}
|
||||||
${generatedContractMap("QueuePayloads", queueContracts)}
|
${generatedContractMap("QueuePayloads", queueContracts)}
|
||||||
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
|
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
|
||||||
|
type AssertAssignable<Actual, Expected> = [Actual] extends [Expected] ? true : false;
|
||||||
|
type __wrn_expect_true<T extends true> = T;
|
||||||
|
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||||
|
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||||
|
${apiBlockAssertions(pageAsts, apiContracts)}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
mkdirSync(typeDir, { recursive: true });
|
mkdirSync(typeDir, { recursive: true });
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { generateApplicationTypes } from "../src/types.ts";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Minimal app with one typed endpoint and one page that calls it. */
|
||||||
|
function fixture(block: string): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/api/users.ts"),
|
||||||
|
`export const POST = async () => Response.json({ users: [] });\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/pages/search.wrn"),
|
||||||
|
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||||
|
);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK = ` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
age?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
test("emits the ApiInput, ApiOutput and AssertAssignable helpers", () => {
|
||||||
|
const root = fixture(BLOCK);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(generated).toContain("type AssertAssignable<");
|
||||||
|
expect(generated).toContain('type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"]');
|
||||||
|
expect(generated).toContain(
|
||||||
|
'type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits one assertion per sectioned block, naming its route and method", () => {
|
||||||
|
const root = fixture(BLOCK);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(generated).toContain("__wrn_api_check_searchUsers");
|
||||||
|
expect(generated).toContain('ApiInput<"/api/users", "POST">');
|
||||||
|
expect(generated).toContain("name?: string");
|
||||||
|
expect(generated).toContain("age?: number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy bare-body block produces no assertion", () => {
|
||||||
|
const root = fixture(` api legacyUsers GET /api/users {
|
||||||
|
return users.length
|
||||||
|
}`);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(generated).not.toContain("__wrn_api_check_legacyUsers");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user