Request parameters
In the last lesson, we looked at the paths section of the generated spec and saw parameters listed under each operation. But we glossed over the details. In this lesson, we are going to zoom into those parameters and understand how they work. There are two kinds we will focus on: query parameters (data in the URL after the ?) and path parameters (data embedded in the URL itself). Both come directly from your Zod schemas.
Query parameters
In the paths-and-operations lesson, you saw the generated JSON for GET /v2/books. Let’s look at just the parameters:
curl http://localhost:3000/openapi.json | jq '.paths["/v2/books"].get.parameters' [
{
"in": "query",
"name": "genre",
"schema": {
"type": "string",
"enum": ["fiction", "science-fiction", "fantasy", "non-fiction", "other"]
}
},
{
"in": "query",
"name": "sort",
"schema": {
"type": "string",
"enum": ["title", "createdAt", "rating"],
"default": "title"
}
},
{
"in": "query",
"name": "page",
"schema": { "type": "integer", "default": 1 }
},
{
"in": "query",
"name": "limit",
"schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }
}
] Four parameters. Every single one came from the BookQuerySchema you wrote in src/schemas.ts. Let’s trace the connection.
Here is the Zod schema:
export const BookQuerySchema = z.object({
genre: z.enum(["fiction", "science-fiction", "fantasy", "non-fiction", "other"]).optional(),
sort: z.enum(["title", "createdAt", "rating"]).default("title"),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
}); And here is the route that uses it:
route.get("/v2/books", {
request: { query: BookQuerySchema },
// ...
}); When @hectoday/openapi sees request.query, it takes each field from the Zod object and generates a parameter with "in": "query". Each field name becomes the parameter name. The Zod type becomes the JSON Schema type. And every constraint you added maps to a property in the schema.
Let’s walk through the fields one by one.
genre is a z.enum() with .optional(). In the generated spec, it has "type": "string" and an "enum" array listing the valid values. Because it is optional, the parameter does not have a required field, which means clients can omit it. A client reading the spec knows: I can filter by genre, it has to be one of these five values, and I do not have to include it.
sort is a z.enum() with .default("title"). It is not marked .optional(), but it has a default. The "default": "title" tells clients what they get if they leave it out. Since the server can handle a missing value, the parameter is not marked as required.
page is a z.coerce.number().int().min(1).default(1). The .int() makes it "type": "integer" instead of "number". The .default(1) becomes "default": 1. The z.coerce part is a runtime concern only. It tells Zod to convert the string "2" from the URL to the number 2 before validation, but it does not affect the generated spec.
limit works the same way, with both an explicit minimum and maximum. The .min(1) and .max(100) become "minimum": 1 and "maximum": 100 because you set those bounds explicitly. A client reading the spec sees: page size between 1 and 100, defaults to 20. No guessing.
What do you think would happen if you removed .optional() from genre and did not add a .default()? The field would be required. @hectoday/openapi would add "required": true to the parameter, and clients would be forced to include a genre parameter on every request. The Zod schema drives the spec.
Path parameters
Now let’s look at the other kind of parameter. Check the spec for GET /v2/books/{id}:
curl http://localhost:3000/openapi.json | jq '.paths["/v2/books/{id}"].get.parameters' [
{
"in": "path",
"name": "id",
"required": true,
"schema": { "type": "string", "format": "uuid" }
}
] This came from the route definition:
route.get("/v2/books/:id", {
request: { params: z.object({ id: z.uuid() }) },
// ...
}); When @hectoday/openapi sees request.params, it generates parameters with "in": "path". The :id in the route path was converted to {id} in the spec automatically.
Path parameters are always "required": true. That makes sense if you think about it. The path is /v2/books/{id}. If the client does not fill in the id, the URL would be /v2/books/, which is a completely different endpoint (the list endpoint). You cannot have an optional path parameter because the URL structure would break.
The z.uuid() became "type": "string", "format": "uuid". The format field tells clients what kind of string to expect. It is not just any string, it is a UUID like 550e8400-e29b-41d4-a716-446655440001.
How Zod constraints become parameter properties
Here is a quick reference for how common Zod methods map to parameter schema properties:
| Zod method | Generated property |
|---|---|
.optional() | No required field on the parameter (defaults to not required) |
.default(value) | "default": value (parameter is not required) |
.min(n) on numbers | "minimum": n |
.max(n) on numbers | "maximum": n |
.int() | "type": "integer" |
.min(n) on strings | "minLength": n |
.max(n) on strings | "maxLength": n |
z.uuid() | "type": "string", "format": "uuid" |
z.email() | "type": "string", "format": "email" |
z.enum([...]) | "enum": [...] |
Every constraint you express in Zod shows up in the spec. This is the core idea: write your validation rules once, and the documentation reflects them automatically.
In the next lesson, we will look at request bodies, the JSON payloads that clients send to POST and PUT endpoints.
Exercises
Exercise 1: Run curl http://localhost:3000/openapi.json | jq '.paths["/v2/books"].get.parameters' and compare each parameter to the corresponding field in BookQuerySchema. Verify that every Zod constraint appears in the spec.
Exercise 2: Add a new query parameter to BookQuerySchema, like search: z.string().min(1).max(100).optional(). Restart the server and check whether the new parameter appears in the generated spec.
Exercise 3: Remove .optional() from the genre field in BookQuerySchema. Check the spec again. Does required change?
Why include 'default' values for optional query parameters in the spec?