diff --git a/.changeset/plugin-route-query-input.md b/.changeset/plugin-route-query-input.md new file mode 100644 index 0000000000..8bb1d05e09 --- /dev/null +++ b/.changeset/plugin-route-query-input.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Plugin API routes with an input schema now work over GET, HEAD, and DELETE. These methods carry no request body, so `request.json()` resolved to `undefined` and every such request failed validation. Route input is now parsed from the URL query string for bodyless methods (repeated keys become arrays) while POST/PUT/PATCH continue to parse the JSON body. diff --git a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx index 6f1f89d272..a3b59c605c 100644 --- a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx @@ -113,7 +113,7 @@ The header is applied only to **successful GET responses of public routes**. Err ## Input validation -`input` accepts a Zod schema. The dispatcher parses the request body (POST/PUT/PATCH) or query string (GET/DELETE), validates it, and passes the typed result to your handler as `routeCtx.input`. Invalid input returns a 400 before your handler runs. +`input` accepts a Zod schema. The dispatcher parses the request body (POST/PUT/PATCH) or the URL query string (GET/HEAD/DELETE), validates it, and passes the typed result to your handler as `routeCtx.input`. Invalid input returns a 400 before your handler runs. ```typescript routes: { @@ -141,6 +141,35 @@ routes: { }, ``` +### Query-string input (GET/HEAD/DELETE) + +Bodyless methods have no request body, so their input comes from the URL query +string. The dispatcher turns the query into an object before validation: + +- A repeated key becomes an array — `?tag=a&tag=b` parses to `{ tag: ["a", "b"] }`, + so an `z.array(z.string())` field works. +- A single key stays a scalar — `?tag=a` parses to `{ tag: "a" }`. + +Every query value is a **string**, so schemas for these routes should either accept +strings or coerce. Use `z.coerce` for non-string fields: + +```typescript +routes: { + list: { + // GET /_emdash/api/plugins//list?status=open&limit=20&tag=a&tag=b + input: z.object({ + status: z.enum(["open", "closed"]).optional(), + limit: z.coerce.number().int().max(100).default(20), + tag: z.array(z.string()).optional(), + }), + handler: async (routeCtx, ctx) => { + const { status, limit, tag } = routeCtx.input; + // ... + }, + }, +}, +``` + ## Return values Return any JSON-serialisable value. The dispatcher wraps it in EmDash's standard envelope (`{ success: true, data: }`) and serves it as `application/json`. diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 7aaa058fd5..1de6439af4 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -180,7 +180,12 @@ import { } from "./plugins/hooks.js"; import { normalizeManifestRoute } from "./plugins/manifest-schema.js"; import { extractRequestMeta, sanitizeHeadersForSandbox } from "./plugins/request-meta.js"; -import { buildRouteMeta, PluginRouteRegistry, type RouteMeta } from "./plugins/routes.js"; +import { + buildRouteMeta, + parseRouteInput, + PluginRouteRegistry, + type RouteMeta, +} from "./plugins/routes.js"; import type { CronScheduler } from "./plugins/scheduler/types.js"; import { PluginStateRepository } from "./plugins/state.js"; import { normalizeRegistryConfig } from "./registry/config.js"; @@ -3356,12 +3361,8 @@ export class EmDashRuntime { const routeKey = path.replace(LEADING_SLASH_PATTERN, ""); - let body: unknown = undefined; - try { - body = await request.json(); - } catch { - // No body or not JSON - } + // Body methods parse JSON; GET/HEAD/DELETE parse the query string (#2146). + const body = await parseRouteInput(request); return routeRegistry.invoke(pluginId, routeKey, { request, body }); } @@ -3631,12 +3632,8 @@ export class EmDashRuntime { }> { const routeName = path.replace(LEADING_SLASH_PATTERN, ""); - let body: unknown = undefined; - try { - body = await request.json(); - } catch { - // No body or not JSON - } + // Body methods parse JSON; GET/HEAD/DELETE parse the query string (#2146). + const body = await parseRouteInput(request); try { const headers = sanitizeHeadersForSandbox(request.headers); diff --git a/packages/core/src/plugins/routes.ts b/packages/core/src/plugins/routes.ts index 5a0f6c8590..2a5a1d74e3 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -76,6 +76,40 @@ export function buildRouteMeta(route: { public?: boolean; cacheControl?: string return meta; } +/** + * HTTP methods that carry a request body. Everything else (GET, HEAD, DELETE) + * takes its route input from the URL query string. + */ +const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]); + +/** + * Parse a plugin route's input from the request, by method. + * + * Body methods (POST/PUT/PATCH) parse the JSON body as before. Bodyless + * methods (GET/HEAD/DELETE) have no body, so `request.json()` resolves to + * undefined and fails schema validation (#2146) — parse the query string into + * an object instead. Repeated keys (`?tag=a&tag=b`) become an array so array + * schemas work; a single key stays a scalar. + */ +export async function parseRouteInput(request: Request): Promise { + if (BODY_METHODS.has(request.method.toUpperCase())) { + try { + return await request.json(); + } catch { + // No body or not JSON + return undefined; + } + } + + const params = new URL(request.url).searchParams; + const input: Record = {}; + for (const key of new Set(params.keys())) { + const values = params.getAll(key); + input[key] = values.length > 1 ? values : values[0]; + } + return input; +} + /** * Result from a route invocation */ diff --git a/packages/core/tests/unit/plugins/routes.test.ts b/packages/core/tests/unit/plugins/routes.test.ts index 13b6d83946..1bd016d8d8 100644 --- a/packages/core/tests/unit/plugins/routes.test.ts +++ b/packages/core/tests/unit/plugins/routes.test.ts @@ -15,6 +15,7 @@ import type { PluginContextFactoryOptions } from "../../../src/plugins/context.j import { EmailPipeline } from "../../../src/plugins/email.js"; import { HookPipeline } from "../../../src/plugins/hooks.js"; import { + parseRouteInput, PluginRouteHandler, PluginRouteRegistry, PluginRouteError, @@ -597,3 +598,77 @@ describe("createRouteRegistry helper", () => { expect(registry).toBeInstanceOf(PluginRouteRegistry); }); }); + +describe("parseRouteInput (#2146)", () => { + it("parses the query string for GET requests", async () => { + const input = await parseRouteInput( + new Request("http://test.com/plugins/p/search?limit=20&q=hello", { method: "GET" }), + ); + expect(input).toEqual({ limit: "20", q: "hello" }); + }); + + it("parses the query string for HEAD and DELETE requests", async () => { + expect( + await parseRouteInput(new Request("http://test.com/x?id=7", { method: "HEAD" })), + ).toEqual({ id: "7" }); + expect( + await parseRouteInput(new Request("http://test.com/x?id=7", { method: "DELETE" })), + ).toEqual({ id: "7" }); + }); + + it("collapses a repeated query key into an array", async () => { + const input = await parseRouteInput( + new Request("http://test.com/x?tag=a&tag=b&one=z", { method: "GET" }), + ); + expect(input).toEqual({ tag: ["a", "b"], one: "z" }); + }); + + it("returns an empty object for a GET with no query params", async () => { + expect(await parseRouteInput(new Request("http://test.com/x", { method: "GET" }))).toEqual({}); + }); + + it("parses the JSON body for POST/PUT/PATCH", async () => { + for (const method of ["POST", "PUT", "PATCH"]) { + const input = await parseRouteInput( + new Request("http://test.com/x?ignored=1", { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Ada" }), + }), + ); + expect(input).toEqual({ name: "Ada" }); + } + }); + + it("returns undefined for a body method with no/invalid JSON", async () => { + expect( + await parseRouteInput(new Request("http://test.com/x", { method: "POST" })), + ).toBeUndefined(); + }); + + it("lets a GET route with an input schema validate query params end to end", async () => { + const plugin = createTestPlugin({ + routes: { + search: { + input: z.object({ + limit: z.coerce.number().min(1).default(50), + q: z.string().optional(), + }), + handler: async (ctx) => ({ received: ctx.input }), + }, + }, + }); + const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); + const request = new Request("http://test.com/plugins/p/search?limit=20&q=hello", { + method: "GET", + }); + + const result = await handler.invoke("search", { + request, + body: await parseRouteInput(request), + }); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ received: { limit: 20, q: "hello" } }); + }); +});