Request bodies
Parameters cover data that comes from the URL. But when a client creates or updates a resource, they send a JSON body. And that body needs to be documented just as thoroughly as the parameters. If a client does not know which fields to include, what types they should be, or which ones are required, they are going to get a lot of 400 errors.
Let’s look at how @hectoday/openapi generates the request body section of the spec from your Zod schemas.
The generated request body
Pull up the POST /v2/books operation from the spec:
curl http://localhost:3000/openapi.json | jq '.paths["/v2/books"].post.requestBody' {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"title": { "type": "string", "minLength": 1, "maxLength": 200 },
"authorId": { "type": "string", "format": "uuid" },
"genre": {
"type": "string",
"enum": ["fiction", "science-fiction", "fantasy", "non-fiction", "other"]
},
"description": { "type": "string", "maxLength": 5000 }
},
"required": ["title", "authorId", "genre"],
"additionalProperties": false
}
}
}
} The schema is inlined directly in the request body. All of this was generated from the CreateBookSchema in src/schemas.ts. Let’s trace the connection.
How the Zod schema becomes a request body
Here is the Zod schema:
export const CreateBookSchema = z.object({
title: z.string().trim().min(1).max(200),
authorId: z.uuid(),
genre: z.enum(["fiction", "science-fiction", "fantasy", "non-fiction", "other"]),
description: z.string().max(5000).optional(),
}); And the route that uses it:
route.post("/v2/books", {
request: { body: CreateBookSchema },
response: { 201: BookSchema },
resolve: (c) => { ... },
}); When @hectoday/openapi sees request.body, it converts the Zod schema into a JSON Schema and inlines it directly in the requestBody section. The content type is application/json. Notice that additionalProperties: false is included on the object, which means clients cannot send extra fields beyond what the schema defines.
Let’s walk through how each Zod field became a property in the generated schema.
title is z.string().trim().min(1).max(200). In the spec, it became "type": "string", "minLength": 1, "maxLength": 200. The .trim() is a runtime concern. It strips whitespace before validation, but it does not affect the spec. The constraints .min(1) and .max(200) are what show up as minLength and maxLength.
authorId is z.uuid(). It became "type": "string", "format": "uuid". A client reading the spec knows this is not just any string, it is a UUID. They need to get a valid author ID from somewhere (like a GET /v2/authors endpoint) before they can create a book.
genre is z.enum(["fiction", "science-fiction", ...]). It became "type": "string" with an "enum" array. The client can only send one of the five listed values. Anything else fails validation.
description is z.string().max(5000).optional(). In the spec, it has "maxLength": 5000 and, critically, it is not listed in the "required" array. That is how OpenAPI represents optional fields. In Zod, you use .optional(). In OpenAPI, the field is omitted from the required array. Every field that does not have .optional() ends up in required.
Required vs optional: Zod and OpenAPI think differently
This is worth emphasizing because it catches people off guard. In Zod, fields are required by default. You add .optional() to make them optional. In OpenAPI, it works the other way around. Fields listed in the required array are required. Everything else is optional.
So @hectoday/openapi collects all the non-optional fields and puts them in the required array. The result is the same behavior, just expressed differently. title, authorId, and genre are in required. description is not.
Nested objects and arrays
Zod schemas can contain nested objects and arrays, and they map cleanly to JSON Schema. Consider a schema like this:
const CreateReviewSchema = z.object({
bookId: z.uuid(),
rating: z.number().int().min(1).max(5),
content: z.string().min(10).max(2000),
tags: z.array(z.string().min(1)).max(5).optional(),
}); This would generate:
{
"type": "object",
"properties": {
"bookId": { "type": "string", "format": "uuid" },
"rating": { "type": "integer", "minimum": 1, "maximum": 5 },
"content": { "type": "string", "minLength": 10, "maxLength": 2000 },
"tags": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"maxItems": 5
}
},
"required": ["bookId", "rating", "content"],
"additionalProperties": false
} The tags field is an array of non-empty strings, with a maximum of 5 items. Since it has .optional(), it is not in the required array. The nested array type (z.array(z.string().min(1))) became "items": { "type": "string", "minLength": 1 }. Each level of nesting in Zod produces a corresponding level in the JSON Schema.
Request and response shapes are different
Notice that CreateBookSchema and BookSchema are different. When creating a book, the client sends authorId (a UUID string). But the response contains a full author object with id and name. The client sends no id, no ratings, no createdAt, because those are generated by the server.
This is normal in real APIs. The shape of what you send in is almost never the same as the shape of what you get back. That is why we have separate Zod schemas for requests and responses, and the spec reflects both.
In the next lesson, we will look at the other side: how @hectoday/openapi generates response documentation from the response field on your routes.
Exercises
Exercise 1: Run curl http://localhost:3000/openapi.json | jq '.paths["/v2/books"].post.requestBody' and compare every property in the schema to the corresponding field in CreateBookSchema. Make sure you can trace each Zod constraint to its JSON Schema equivalent.
Exercise 2: Add a tags field to CreateBookSchema: tags: z.array(z.string()).max(5).optional(). Restart the server and check whether the array type appears correctly in the generated spec.
Exercise 3: Remove .optional() from the description field in CreateBookSchema. Check the spec. Does description now appear in the required array?
How does OpenAPI indicate that a field is optional?