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
2 changes: 2 additions & 0 deletions .changeset/request-params-openapi-regression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ updates:
dependency-type: "production"
dev-deps:
dependency-type: "development"
- package-ecosystem: "npm"
directory: "/template"
schedule:
interval: "weekly"
groups:
template-deps:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
Expand Down
10 changes: 10 additions & 0 deletions docs/endpoints/request-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ Similar to request bodies, Chanfana infers the types of query parameters based o

Path parameters are dynamic segments in the URL path, denoted by colons (e.g., `/users/:userId`). Chanfana validates path parameters using Zod schemas defined in `schema.request.params`.

Each key in `request.params` must match a dynamic route segment (for example, `id` for `/users/:id`). When generating the OpenAPI document, Chanfana converts `:id` to `{id}` in the path. Ordinary Zod object properties are exported automatically as OpenAPI parameters, so `.openapi({ param: ... })` is not required merely to make them appear. Use `param` metadata only for Parameter Object fields such as a parameter-level description, deprecation status, examples, or serialization options. Do not override `name` or `in`; they are derived from the object key and `request.params`.

Path-parameter schemas must not be optional or nullable: OpenAPI requires every path parameter to have `required: true`.

::: tip Chanfana / Zod versions
Chanfana v2 uses Zod 3. Projects on Zod 4 must use Chanfana v3. See the [Migration to Chanfana v3](/migration-to-chanfana-3) guide.
:::

### Defining Path Parameter Schema with Zod

Use `z.object({})` within `schema.request.params` to define the expected path parameters and their validation rules.
Expand Down Expand Up @@ -178,6 +186,8 @@ class GetProductEndpoint extends OpenAPIRoute {
}
```

Register the endpoint on a matching route such as `/products/:productId`. The generated OpenAPI path will be `/products/{productId}` and will include a required `productId` path parameter derived from the Zod schema.

In this example:

* We define a Zod object schema for `schema.request.params` with a single path parameter `productId`.
Expand Down
2 changes: 2 additions & 0 deletions template/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

A starter Cloudflare Worker using Chanfana (OpenAPI), Hono, D1, and Zod v4. This is a copyable template — users clone it and build on top.

Requires **Node.js 22 or later** (Wrangler engines constraint).

## Commands

```bash
Expand Down
4 changes: 4 additions & 0 deletions template/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Scaffold a new project with the `create-cloudflare` CLI:
npm create cloudflare@latest -- --template https://github.com/cloudflare/chanfana/tree/main/template
```

## Prerequisites

- **Node.js 22 or later** (required by Wrangler)

## Setup

```bash
Expand Down
5 changes: 4 additions & 1 deletion template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"name": "chanfana-template",
"version": "0.0.0",
"private": true,
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
Expand All @@ -20,6 +23,6 @@
"@cloudflare/workers-types": "^4.20260303.0",
"typescript": "^5.9.0",
"vitest": "^3.2.0",
"wrangler": "^4.68.0"
"wrangler": "4.107.1"
}
}
140 changes: 139 additions & 1 deletion tests/integration/openapi-schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,73 @@
import { Hono } from "hono";
import { AutoRouter } from "itty-router";
import { describe, expect, it } from "vitest";
import { fromIttyRouter } from "../../src";
import { z } from "zod";
import { fromHono, fromIttyRouter, OpenAPIRoute } from "../../src";
import { ToDoGet, todoRouter } from "../router";
import { buildRequest } from "../utils";

const userIdParamsSchema = z.object({
id: z.string().describe("User ID"),
});

class GetUserByIdEndpoint extends OpenAPIRoute {
schema = {
tags: ["Users"],
summary: "Get user by ID",
request: {
params: userIdParamsSchema,
},
responses: {
"200": {
description: "User found",
content: {
"application/json": {
schema: z.object({
id: z.string(),
name: z.string(),
}),
},
},
},
},
};

async handle() {
return { id: "1", name: "John Doe" };
}
}

class GetUserWithQueryEndpoint extends OpenAPIRoute {
schema = {
tags: ["Users"],
summary: "Get user by ID with query",
request: {
params: z.object({
id: z.string().describe("User ID"),
}),
query: z.object({
include: z.string().optional().describe("Related resources to include"),
}),
},
responses: {
"200": {
description: "User found",
content: {
"application/json": {
schema: z.object({
id: z.string(),
}),
},
},
},
},
};

async handle() {
return { id: "1" };
}
}

describe("openapi schema", () => {
it("custom content type", async () => {
const request = await todoRouter.fetch(buildRequest({ method: "GET", path: "/openapi.json" }));
Expand Down Expand Up @@ -31,4 +95,78 @@ describe("openapi schema", () => {

expect(Object.keys(resp.paths)[0]).toEqual("/api/todo");
});

it.each([
{ openapiVersion: "3.1" as const, openapi: "3.1.0" },
{ openapiVersion: "3" as const, openapi: "3.0.3" },
])("exports request.params as OpenAPI path parameters ($openapi)", async ({ openapiVersion, openapi }) => {
const router = fromHono(new Hono(), { openapiVersion });
router.get("/users/:id", GetUserByIdEndpoint);

const first = await (await router.fetch(new Request("http://localhost/openapi.json"))).json();
const second = await (await router.fetch(new Request("http://localhost/openapi.json"))).json();

expect(first.openapi).toBe(openapi);
expect(Object.keys(first.paths)).toContain("/users/{id}");

const parameters = first.paths["/users/{id}"].get.parameters;
expect(parameters).toHaveLength(1);
expect(parameters[0]).toMatchObject({
name: "id",
in: "path",
required: true,
schema: {
type: "string",
description: "User ID",
},
});

// Repeated generation stays deterministic and preserves schema metadata.
expect(second.paths["/users/{id}"].get.parameters).toEqual(parameters);
expect(userIdParamsSchema.shape.id.description).toBe("User ID");
});

it("keeps query parameters alongside path parameters without duplicates", async () => {
const router = fromHono(new Hono());
router.get("/users/:id", GetUserWithQueryEndpoint);

const schema = await (await router.fetch(new Request("http://localhost/openapi.json"))).json();
const parameters = schema.paths["/users/{id}"].get.parameters;

expect(parameters).toHaveLength(2);
expect(parameters.filter((parameter: { name: string }) => parameter.name === "id")).toHaveLength(1);
expect(parameters).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "id",
in: "path",
required: true,
schema: expect.objectContaining({ type: "string" }),
}),
expect.objectContaining({
name: "include",
in: "query",
schema: expect.objectContaining({ type: "string" }),
}),
]),
);
});

it("exports request.params for itty-router through the shared registration layer", async () => {
const router = fromIttyRouter(AutoRouter());
router.get("/users/:id", GetUserByIdEndpoint);

const schema = await (await router.fetch(buildRequest({ method: "GET", path: "/openapi.json" }))).json();
const parameters = schema.paths["/users/{id}"].get.parameters;

expect(parameters).toHaveLength(1);
expect(parameters[0]).toMatchObject({
name: "id",
in: "path",
required: true,
schema: {
type: "string",
},
});
});
});
Loading