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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,27 @@
|
|||||||
import { defineEndpoint } from "@wrnexus/core";
|
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: "Ajay", designation: "UI" },
|
||||||
{ name: "Asha", designation: "Backend" },
|
{ name: "Asha", designation: "Backend" },
|
||||||
{ name: "Chen", designation: "UI" },
|
{ name: "Chen", designation: "UI" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const POST = async (ctx: Context) => {
|
/**
|
||||||
const body = (await ctx.req.json().catch(() => ({}))) as { name?: string };
|
* Schema-typed handler: input resolves to `{ name?: string }` from
|
||||||
const needle = String(body.name ?? "").toLowerCase();
|
* SearchDirectorySchema, so the block's request shape is checked for real
|
||||||
return Response.json({ users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) });
|
* (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)) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ page ApiBlockDemo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response {
|
response {
|
||||||
return data.users
|
return data.data.users
|
||||||
}
|
}
|
||||||
|
|
||||||
error {
|
error {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { v } from "@wrnexus/validation";
|
||||||
|
|
||||||
|
export const SearchDirectorySchema = v.object({
|
||||||
|
name: v.string().trim().optional(),
|
||||||
|
});
|
||||||
@@ -96,7 +96,18 @@ export function defineEndpoint(
|
|||||||
if (definition.auth === "required" && !ctx.user) {
|
if (definition.auth === "required" && !ctx.user) {
|
||||||
throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required.");
|
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 rawOutput = await definition.handler(input, ctx);
|
||||||
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
|
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
|
||||||
return output instanceof Response ? output : json({ data: output });
|
return output instanceof Response ? output : json({ data: output });
|
||||||
|
|||||||
Reference in New Issue
Block a user