Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plugin-route-query-input.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 30 additions & 1 deletion docs/src/content/docs/plugins/creating-plugins/api-routes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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/<slug>/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: <your value> }`) and serves it as `application/json`.
Expand Down
23 changes: 10 additions & 13 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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);
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/plugins/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
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<string, string | string[]> = {};
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
*/
Expand Down
75 changes: 75 additions & 0 deletions packages/core/tests/unit/plugins/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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" } });
});
});
Loading