feat(syntax): parse sectioned api blocks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Parse the sectioned form of an `api` block body.
|
||||
*
|
||||
* Returns null when no section keyword is present, which is how the legacy
|
||||
* bare-body form stays valid: the caller keeps treating the body as the
|
||||
* response expression.
|
||||
*/
|
||||
export interface ApiFieldDecl {
|
||||
name: string;
|
||||
optional: boolean;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ApiSections {
|
||||
parameters: ApiFieldDecl[];
|
||||
body: ApiFieldDecl[];
|
||||
response: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const SECTION_NAMES = ["request", "response", "error"] as const;
|
||||
|
||||
/** Slice the balanced `{ ... }` that follows `keyword`, or null when absent. */
|
||||
function sectionBody(source: string, keyword: string): string | null {
|
||||
const match = new RegExp(`(^|[^A-Za-z0-9_$])${keyword}\\s*\\{`).exec(source);
|
||||
if (!match) return null;
|
||||
|
||||
const open = source.indexOf("{", match.index + match[1]!.length);
|
||||
let depth = 0;
|
||||
|
||||
for (let index = open; index < source.length; index++) {
|
||||
const character = source[index];
|
||||
if (character === "{") depth++;
|
||||
else if (character === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return source.slice(open + 1, index);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unclosed "${keyword}" section in an api block`);
|
||||
}
|
||||
|
||||
/** `name?: string` -> { name, optional, type }. Blank lines and comments are skipped. */
|
||||
function parseFields(source: string): ApiFieldDecl[] {
|
||||
const fields: ApiFieldDecl[] = [];
|
||||
|
||||
for (const rawLine of source.split("\n")) {
|
||||
const line = rawLine.trim().replace(/,$/, "");
|
||||
if (!line || line.startsWith("//")) continue;
|
||||
|
||||
const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*(.+)$/.exec(line);
|
||||
if (!match) {
|
||||
throw new Error(`Expected "name: type" in an api request section, got "${line}"`);
|
||||
}
|
||||
|
||||
fields.push({ name: match[1]!, optional: match[2] === "?", type: match[3]!.trim() });
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function parseApiSections(source: string): ApiSections | null {
|
||||
const present = SECTION_NAMES.some((name) => sectionBody(source, name) !== null);
|
||||
if (!present) return null;
|
||||
|
||||
const request = sectionBody(source, "request");
|
||||
|
||||
return {
|
||||
parameters: request ? parseFields(sectionBody(request, "parameters") ?? "") : [],
|
||||
body: request ? parseFields(sectionBody(request, "body") ?? "") : [],
|
||||
response: sectionBody(source, "response") ?? "",
|
||||
error: sectionBody(source, "error") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the block declares a `request` section. */
|
||||
export function hasRequestSection(source: string): boolean {
|
||||
return sectionBody(source, "request") !== null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WRN_RUNTIME_TARGETS } from "./spec.ts";
|
||||
import { parseApiSections, hasRequestSection, type ApiSections } from "./api-sections.ts";
|
||||
|
||||
/**
|
||||
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||
@@ -148,7 +149,10 @@ export interface DataApiBlock {
|
||||
name: string;
|
||||
method: string;
|
||||
path: string;
|
||||
/** Legacy bare body. Empty string when `sections` is set. */
|
||||
body: string;
|
||||
/** Present only for the sectioned, typed form. */
|
||||
sections?: ApiSections;
|
||||
}
|
||||
|
||||
export interface ModeFunctionsBlock {
|
||||
@@ -696,7 +700,20 @@ export function parse(source: string): PageAst {
|
||||
const method = expect("ident").value.toUpperCase();
|
||||
const path = lx.readPath();
|
||||
const body = lx.readBalancedBraces();
|
||||
dataApis.push({ mode, name, method, path, body });
|
||||
const sections = parseApiSections(body);
|
||||
if (sections && mode !== "client" && hasRequestSection(body)) {
|
||||
throw new ParseError(
|
||||
`An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`,
|
||||
);
|
||||
}
|
||||
dataApis.push({
|
||||
mode,
|
||||
name,
|
||||
method,
|
||||
path,
|
||||
body: sections ? "" : body,
|
||||
...(sections ? { sections } : {}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "functions": {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { parse } from "../src/index.ts";
|
||||
|
||||
const page = (inner: string) => `page Repro {
|
||||
client {
|
||||
${inner}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("parses a sectioned api block into request, response and error", () => {
|
||||
const ast = parse(
|
||||
page(` api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
age?: number
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
|
||||
error {
|
||||
return []
|
||||
}
|
||||
}`),
|
||||
);
|
||||
|
||||
const block = ast.dataApis[0]!;
|
||||
expect(block.name).toBe("searchUsers");
|
||||
expect(block.method).toBe("POST");
|
||||
expect(block.path).toBe("/api/users");
|
||||
expect(block.sections?.body).toEqual([
|
||||
{ name: "name", optional: true, type: "string" },
|
||||
{ name: "age", optional: true, type: "number" },
|
||||
]);
|
||||
expect(block.sections?.response.trim()).toBe("return data.users");
|
||||
expect(block.sections?.error.trim()).toBe("return []");
|
||||
});
|
||||
|
||||
test("a bare body still parses as the legacy response body", () => {
|
||||
const ast = parse(
|
||||
page(` api legacyUsers GET /api/users {
|
||||
return users.length
|
||||
}`),
|
||||
);
|
||||
|
||||
const block = ast.dataApis[0]!;
|
||||
expect(block.sections).toBeUndefined();
|
||||
expect(block.body.trim()).toBe("return users.length");
|
||||
});
|
||||
|
||||
test("GET parameters are parsed as required when not marked optional", () => {
|
||||
const ast = parse(
|
||||
page(` api listUsers GET /api/users {
|
||||
request {
|
||||
parameters {
|
||||
team: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
}`),
|
||||
);
|
||||
|
||||
expect(ast.dataApis[0]!.sections?.parameters).toEqual([
|
||||
{ name: "team", optional: false, type: "string" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("request inside an ssr block is rejected with a message naming the restriction", () => {
|
||||
const source = `page Repro {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users {
|
||||
request {
|
||||
parameters {
|
||||
team: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(() => parse(source)).toThrow(/ssr[\s\S]*request/i);
|
||||
});
|
||||
Reference in New Issue
Block a user