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 { WRN_RUNTIME_TARGETS } from "./spec.ts";
|
||||||
|
import { parseApiSections, hasRequestSection, type ApiSections } from "./api-sections.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursive-descent parser for `.wrn`, producing a small AST.
|
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||||
@@ -148,7 +149,10 @@ export interface DataApiBlock {
|
|||||||
name: string;
|
name: string;
|
||||||
method: string;
|
method: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
/** Legacy bare body. Empty string when `sections` is set. */
|
||||||
body: string;
|
body: string;
|
||||||
|
/** Present only for the sectioned, typed form. */
|
||||||
|
sections?: ApiSections;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModeFunctionsBlock {
|
export interface ModeFunctionsBlock {
|
||||||
@@ -696,7 +700,20 @@ export function parse(source: string): PageAst {
|
|||||||
const method = expect("ident").value.toUpperCase();
|
const method = expect("ident").value.toUpperCase();
|
||||||
const path = lx.readPath();
|
const path = lx.readPath();
|
||||||
const body = lx.readBalancedBraces();
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "functions": {
|
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