diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3e7460..c84b1cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +- Fixed `defineEndpoint` (`@wrnexus/core`) so routes invoked through the real HTTP router + (which calls handlers as `handler(ctx)`, with no second argument) actually receive their + request input: it now parses query parameters for GET/HEAD and the JSON body otherwise + when no input is passed explicitly. Previously such endpoints silently validated + `undefined`, so an `input` schema with only optional fields passed vacuously regardless of + what was sent. **Behavior change for downstream apps:** a request that previously passed + vacuous validation on a `defineEndpoint` route can now legitimately fail (400 + `VALIDATION_ERROR`) if it does not actually satisfy the schema. Explicitly passing a second + argument (e.g. from a unit test or an internal caller) is unaffected and still takes + priority over reading the request. + ## 0.8.8 - Added the framework request context to `.wrn` language-server type environments. diff --git a/packages/core/test/endpoint-schema.test.ts b/packages/core/test/endpoint-schema.test.ts index a3eeb05a..3735ffc3 100644 --- a/packages/core/test/endpoint-schema.test.ts +++ b/packages/core/test/endpoint-schema.test.ts @@ -29,3 +29,86 @@ test("typed endpoints unwrap official validation schemas and return bounded vali const valid = await endpoint(ctx, { name: "Ada", email: "ada@example.test" }); expect(await valid.json()).toEqual({ data: { name: "Ada", email: "ada@example.test" } }); }); + +// The real HTTP router (packages/dev-server/src/runtime.ts handleApi) invokes route +// handlers as `handler(ctx)` — it never supplies a second argument. Every test above +// passes rawInput explicitly, so it never exercises that calling convention. These +// tests call the endpoint with only a context, matching what actually happens in +// production, to guard against silently validating `undefined` again. +const search = v.object({ name: v.string().trim().optional() }); +const searchEndpoint = defineEndpoint<{ name?: string }, { name: string | null }>({ + input: search, + handler(input) { + return { name: input.name ?? null }; + }, +}); + +test("with no second argument, a GET request reads input from the URL's query string", async () => { + const request = new Request("https://example.test/api/search?name=Ada"); + const ctx = createContext(request, new URL(request.url)); + const response = await searchEndpoint(ctx); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ data: { name: "Ada" } }); +}); + +test("with no second argument, a POST request reads input from the parsed JSON body", async () => { + const request = new Request("https://example.test/api/search", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Ada" }), + }); + const ctx = createContext(request, new URL(request.url)); + const response = await searchEndpoint(ctx); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ data: { name: "Ada" } }); +}); + +test("with no second argument, a malformed or absent POST body falls back without throwing, and schema validation decides the outcome", async () => { + const malformedRequest = new Request("https://example.test/api/search", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not json", + }); + const malformedCtx = createContext(malformedRequest, new URL(malformedRequest.url)); + const malformedResponse = await searchEndpoint(malformedCtx); + // `name` is optional, so an empty resolved input ({}) still validates and succeeds — + // the point is that the malformed body did not throw an unhandled parse error. + expect(malformedResponse.status).toBe(200); + expect(await malformedResponse.json()).toEqual({ data: { name: null } }); + + const requiredField = v.object({ name: v.string().min(1) }); + const requiredEndpoint = defineEndpoint({ + input: requiredField, + handler(input) { + return input; + }, + }); + const emptyRequest = new Request("https://example.test/api/search", { method: "POST" }); + const emptyCtx = createContext(emptyRequest, new URL(emptyRequest.url)); + const emptyResponse = await requiredEndpoint(emptyCtx); + // With no body at all, resolved input is {} — the schema's own required-field + // validation is what turns that into a 400, not a thrown parse error. + expect(emptyResponse.status).toBe(400); + expect(await emptyResponse.json()).toEqual({ + error: { + code: "VALIDATION_ERROR", + message: "Endpoint validation failed.", + details: { name: "Required" }, + }, + }); +}); + +test("an explicit rawInput argument still wins and the request is never read", async () => { + // A request whose body has already been consumed: if the endpoint tried to read it + // again (rather than trusting the explicit rawInput), this would throw. + const request = new Request("https://example.test/api/search", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "ignored-body" }), + }); + await request.json(); // drain the body so a second .json() call would reject + const ctx = createContext(request, new URL(request.url)); + const response = await searchEndpoint(ctx, { name: "Explicit" }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ data: { name: "Explicit" } }); +});