From 5318320c70a5219d36ab6f509ac1af9c0605fe46 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 19 Aug 2026 20:05:10 +0530 Subject: [PATCH] fix(examples): make the api-block demo endpoint actually type-check directory.ts previously exported a plain (ctx: Context) => ... handler. With that shape ApiInput<> resolved to unknown, so the generated __wrn_api_check assertion for the demo page passed trivially even with a field the endpoint does not accept -- the worked example did not demonstrate the type safety it exists to demonstrate. Rewrite directory.ts to use defineEndpoint with a schema (matching typed-user.ts), which gives the generated assertion a real input type to check against. Confirmed: adding an unaccepted field to the block's request body now fails typecheck naming __wrn_api_check_searchDirectory; removing it passes with zero net diff. Fix a real bug this surfaced: packages/core/src/endpoint.ts only read its input from a second 'rawInput' argument, but the actual HTTP router (packages/dev-server/src/runtime.ts handleApi) invokes route handlers as handler(ctx) with no second argument. Every defineEndpoint-based route -- including the pre-existing typed-user.ts example -- silently received an empty/undefined input through the real router (confirmed via curl: valid typed-user payloads were rejected as 'Required'; directory's name filter matched every record regardless of query). Fixed by having the endpoint wrapper parse the request itself (query params for GET/HEAD, JSON body otherwise) when no rawInput is explicitly supplied, while still honoring an explicit rawInput for direct/unit-test callers. Also update api-block-demo.wrn's response section: defineEndpoint wraps handler output as { data: ... }, so the block's raw response body is now { data: { users: [...] } } -- response reads data.data.users instead of data.users. Re-verified in a real browser after the endpoint rewrite and the router fix: search returns exactly "Ajay, Asha", exactly one POST /api/directory carrying x-csrf-token, and the error section still runs cleanly (no exception, empty result) on a missing route. Co-Authored-By: Claude Opus 5 --- examples/basic-app/app/api/directory.ts | 27 ++++++++++++++----- .../basic-app/app/pages/api-block-demo.wrn | 2 +- .../basic-app/app/schemas/search-directory.ts | 5 ++++ packages/core/src/endpoint.ts | 13 ++++++++- 4 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 examples/basic-app/app/schemas/search-directory.ts diff --git a/examples/basic-app/app/api/directory.ts b/examples/basic-app/app/api/directory.ts index 59a9cf6d..dd5446cb 100644 --- a/examples/basic-app/app/api/directory.ts +++ b/examples/basic-app/app/api/directory.ts @@ -1,14 +1,27 @@ import { defineEndpoint } from "@wrnexus/core"; -import type { Context } from "@wrnexus/core"; +import { SearchDirectorySchema } from "../schemas/search-directory.ts"; -const ALL = [ +interface DirectoryUser { + name: string; + designation: string; +} + +const ALL: DirectoryUser[] = [ { name: "Ajay", designation: "UI" }, { name: "Asha", designation: "Backend" }, { name: "Chen", designation: "UI" }, ]; -export const POST = async (ctx: Context) => { - const body = (await ctx.req.json().catch(() => ({}))) as { name?: string }; - const needle = String(body.name ?? "").toLowerCase(); - return Response.json({ users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) }); -}; +/** + * Schema-typed handler: input resolves to `{ name?: string }` from + * SearchDirectorySchema, so the block's request shape is checked for real + * (not against `unknown`, which the untyped-handler shape resolved to). + */ +export const POST = defineEndpoint<{ name?: string }, { users: DirectoryUser[] }>({ + input: SearchDirectorySchema, + description: "Case-insensitive substring search over the demo directory by name.", + handler(input) { + const needle = String(input.name ?? "").toLowerCase(); + return { users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) }; + }, +}); diff --git a/examples/basic-app/app/pages/api-block-demo.wrn b/examples/basic-app/app/pages/api-block-demo.wrn index f2d999d3..36420dba 100644 --- a/examples/basic-app/app/pages/api-block-demo.wrn +++ b/examples/basic-app/app/pages/api-block-demo.wrn @@ -12,7 +12,7 @@ page ApiBlockDemo { } response { - return data.users + return data.data.users } error { diff --git a/examples/basic-app/app/schemas/search-directory.ts b/examples/basic-app/app/schemas/search-directory.ts new file mode 100644 index 00000000..92c3fea0 --- /dev/null +++ b/examples/basic-app/app/schemas/search-directory.ts @@ -0,0 +1,5 @@ +import { v } from "@wrnexus/validation"; + +export const SearchDirectorySchema = v.object({ + name: v.string().trim().optional(), +}); diff --git a/packages/core/src/endpoint.ts b/packages/core/src/endpoint.ts index 1d3e6b27..118f4367 100644 --- a/packages/core/src/endpoint.ts +++ b/packages/core/src/endpoint.ts @@ -96,7 +96,18 @@ export function defineEndpoint( if (definition.auth === "required" && !ctx.user) { throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required."); } - const input = definition.input ? schemaValue(definition.input, rawInput) : rawInput; + // The real HTTP router invokes route handlers as `handler(ctx)` — it never + // supplies a second argument. Callers that already have a parsed payload + // (unit tests, internal RPC-style calls) may still pass one explicitly, and + // that always wins. Otherwise, read the request ourselves: query params for + // GET/HEAD, JSON body for everything else. + const resolvedInput = + rawInput !== undefined + ? rawInput + : ctx.req.method.toUpperCase() === "GET" || ctx.req.method.toUpperCase() === "HEAD" + ? Object.fromEntries(ctx.url.searchParams) + : await ctx.req.json().catch(() => ({})); + const input = definition.input ? schemaValue(definition.input, resolvedInput) : resolvedInput; const rawOutput = await definition.handler(input, ctx); const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput; return output instanceof Response ? output : json({ data: output });