Project setup
We know why documentation matters and we know what OpenAPI is. Now it is time to get our hands dirty. In this lesson, we will set up the book catalog API that we will work with for the rest of the course. The API already works. It has routes, Zod validation, and real endpoints that return data. The one thing it does not have? Documentation. Nobody outside the team would know how to use it. Let’s fix that.
Create the project
Create a new directory, initialize it, and install the dependencies:
mkdir documented-catalog
cd documented-catalog
npm init -y
npm install @hectoday/http @hectoday/openapi zod srvx
npm install -D typescript @types/node tsx We are installing two Hectoday packages here. @hectoday/http is the HTTP framework for defining routes and handling requests. @hectoday/openapi is the new one. It reads your route definitions and Zod schemas, then generates an OpenAPI spec from them automatically. We also install srvx, a minimal HTTP server, and tsx so we can run TypeScript directly during development.
Add "type": "module" and a dev script to package.json:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts"
}
} Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "./src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src"]
} The schemas
Before we write any routes, we need Zod schemas that describe what our API accepts and returns. Create src/schemas.ts:
// src/schemas.ts
import { z } from "zod/v4";
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(),
});
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),
});
export const BookSchema = z.object({
id: z.uuid(),
title: z.string(),
author: z.object({
id: z.uuid(),
name: z.string(),
}),
genre: z.enum(["fiction", "science-fiction", "fantasy", "non-fiction", "other"]),
ratings: z.object({
average: z.number().nullable(),
count: z.number().int(),
}),
createdAt: z.string().datetime(),
});
export const BookListSchema = z.object({
data: z.array(BookSchema),
meta: z.object({
page: z.number().int(),
limit: z.number().int(),
total: z.number().int(),
}),
}); There are four schemas here, and each one plays a different role.
CreateBookSchema validates what clients send when creating a book. It requires a title (trimmed, between 1 and 200 characters), an author ID (must be a UUID), a genre (must be one of the allowed values), and an optional description.
BookQuerySchema validates query parameters for listing books. Every field has a default, so clients can call GET /v2/books with no parameters at all and still get a valid response. The z.coerce.number() calls are important here. Query parameters always arrive as strings in the URL, like ?page=2. Coercion converts that string "2" into the number 2 before validation runs.
BookSchema describes what a book looks like in API responses. Notice it is different from CreateBookSchema. The response includes an id, a nested author object (not just an authorId), a ratings object, and a createdAt timestamp. This is common in real APIs. The shape of what you send in is rarely the same as the shape of what you get back.
BookListSchema wraps an array of books with pagination metadata. The meta object tells clients what page they are on, the page size, and how many total books match their query.
The routes
Now for the actual API. Create src/app.ts. We will build it in two steps: first the routes, then the OpenAPI wiring.
Start with the imports, some seed data, and the route definitions:
// src/app.ts
import { setup, route } from "@hectoday/http";
import { openapi } from "@hectoday/openapi";
import { z } from "zod/v4";
import { CreateBookSchema, BookQuerySchema, BookSchema, BookListSchema } from "./schemas.js";
type Book = z.infer<typeof BookSchema>;
const books: Book[] = [
{
id: "550e8400-e29b-41d4-a716-446655440001",
title: "Kindred",
author: {
id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
name: "Octavia Butler",
},
genre: "fiction",
ratings: { average: 4.7, count: 2841 },
createdAt: "2024-01-15T10:30:00Z",
},
{
id: "550e8400-e29b-41d4-a716-446655440002",
title: "Dune",
author: {
id: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
name: "Frank Herbert",
},
genre: "science-fiction",
ratings: { average: 4.6, count: 5432 },
createdAt: "2024-02-20T14:00:00Z",
},
{
id: "550e8400-e29b-41d4-a716-446655440003",
title: "The Hobbit",
author: {
id: "c3d4e5f6-a7b8-9012-cdef-123456789012",
name: "J.R.R. Tolkien",
},
genre: "fantasy",
ratings: { average: 4.8, count: 9102 },
createdAt: "2024-03-10T09:15:00Z",
},
];
const routes = [
route.get("/v2/books", {
request: { query: BookQuerySchema },
response: { 200: BookListSchema },
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { genre, page, limit } = c.input.query;
const filtered = genre ? books.filter((b) => b.genre === genre) : books;
const start = (page - 1) * limit;
const paged = filtered.slice(start, start + limit);
return Response.json({
data: paged,
meta: { page, limit, total: filtered.length },
});
},
}),
route.get("/v2/books/:id", {
request: { params: z.object({ id: z.uuid() }) },
response: { 200: BookSchema },
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { id } = c.input.params;
const book = books.find((b) => b.id === id);
if (!book) {
return Response.json(
{ error: { code: "NOT_FOUND", message: "Book not found" } },
{ status: 404 },
);
}
return Response.json(book);
},
}),
route.post("/v2/books", {
request: { body: CreateBookSchema },
response: { 201: BookSchema },
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { title, authorId, genre } = c.input.body;
const newBook = {
id: crypto.randomUUID(),
title,
author: { id: authorId, name: "Unknown Author" },
genre,
ratings: { average: null, count: 0 },
createdAt: new Date().toISOString(),
};
books.push(newBook);
return Response.json(newBook, { status: 201 });
},
}),
]; At the top, we define a books array with some seed data. This is our in-memory database. In a real app, this would be SQLite or Postgres, but for learning OpenAPI, an array is all we need. We type it as Book[] using z.infer so TypeScript knows the shape.
Each route follows the same pattern. It has a request object with Zod schemas that validate the incoming data, a response object that describes what the endpoint returns, and a resolve function that handles the actual logic.
The response field is new compared to what you have seen in previous courses. It does not validate outgoing responses at runtime. Its only job is to tell @hectoday/openapi what each status code returns so the generated spec includes that information. Think of it as metadata for documentation, not a runtime check.
Look at the resolve functions. Every handler starts with if (!c.input.ok). When @hectoday/http validates the request against your Zod schemas and the validation fails, c.input.ok is false and c.input.issues contains the specific validation errors. If validation passes, c.input.ok is true and the validated data is available on c.input.query, c.input.params, or c.input.body, depending on what you defined in request.
What do you think happens if you send a request to POST /v2/books with an empty title? Zod catches it. The min(1) constraint on the title field fails, c.input.ok is false, and the handler returns a 400 with the validation issues. The client knows exactly what went wrong.
Wiring up OpenAPI
The routes work, but they have no documentation. This is where @hectoday/openapi comes in. Add the following at the bottom of src/app.ts, after the routes array:
const api = openapi(routes, {
info: {
title: "Book Catalog API",
version: "2.0.0",
description: "A catalog of books, authors, and reviews.",
},
});
export const app = setup({
routes: [...routes, api.spec(route), api.docs(route)],
}); This is the key part of the entire lesson. The openapi() function takes our routes array and a config object. It reads every route’s request and response Zod schemas and generates a complete OpenAPI spec from them.
It returns two helpers. api.spec(route) creates a GET /openapi.json endpoint that serves the generated spec as JSON. api.docs(route) creates a GET /docs endpoint that serves an interactive documentation UI powered by Scalar. We spread both into the routes array alongside our actual API routes.
That is it. Two function calls, and your API now serves its own documentation.
The server
Create src/server.ts:
// src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";
serve({ fetch: app.fetch, port: 3000 }); srvx takes the app.fetch function from @hectoday/http and serves it on port 3000. That is the entire server file.
Start it up:
npm run dev Try it out
With the server running, open another terminal and test the endpoints:
# List all books
curl http://localhost:3000/v2/books | jq
# Filter by genre
curl "http://localhost:3000/v2/books?genre=fiction" | jq
# Get a single book
curl http://localhost:3000/v2/books/550e8400-e29b-41d4-a716-446655440001 | jq
# Create a book
curl -X POST http://localhost:3000/v2/books \
-H "Content-Type: application/json" \
-d '{"title":"Neuromancer","authorId":"d4e5f6a7-b8c9-0123-defa-234567890123","genre":"science-fiction"}' \
| jq Every endpoint works. You get back real JSON, properly validated, with correct status codes. But here is the question: how would someone who has never seen your source code figure all of this out? Which endpoints exist? What query parameters does the list endpoint accept? What fields does the create endpoint require? What does a book look like in the response?
Right now, the answer to all of those questions is “read the code.” And that is exactly the problem we are solving.
The generated spec
Try this:
curl http://localhost:3000/openapi.json | jq You will get back a JSON document that describes the entire API. Here is what part of it looks like (the full output is longer):
{
"openapi": "3.1.0",
"info": {
"title": "Book Catalog API",
"version": "2.0.0",
"description": "A catalog of books, authors, and reviews."
},
"paths": {
"/v2/books": {
"get": {
"parameters": [
{
"in": "query",
"name": "genre",
"schema": {
"type": "string",
"enum": ["fiction", "science-fiction", "fantasy", "non-fiction", "other"]
}
},
{
"in": "query",
"name": "page",
"schema": { "type": "integer", "default": 1 }
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"title": { "type": "string" },
"genre": {
"type": "string",
"enum": [
"fiction",
"science-fiction",
"fantasy",
"non-fiction",
"other"
]
}
// ... other fields
},
"required": ["id", "title", "author", "genre", "ratings", "createdAt"],
"additionalProperties": false
}
},
"meta": {
"type": "object",
"properties": {
"page": { "type": "integer" },
"limit": { "type": "integer" },
"total": { "type": "integer" }
},
"required": ["page", "limit", "total"],
"additionalProperties": false
}
},
"required": ["data", "meta"],
"additionalProperties": false
}
}
}
}
}
},
"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
}
}
}
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"title": { "type": "string" },
"genre": {
"type": "string",
"enum": ["fiction", "science-fiction", "fantasy", "non-fiction", "other"]
}
// ... other fields
},
"required": ["id", "title", "author", "genre", "ratings", "createdAt"],
"additionalProperties": false
}
}
}
}
}
}
}
}
} [!NOTE] The actual output includes additional fields like regex patterns on UUID fields and safe-integer bounds on number types. The examples in this course show simplified versions for clarity. Run the curl commands yourself to see the full output.
Look at what happened. The BookQuerySchema became a list of parameters with types, enums, and defaults. The CreateBookSchema became an inlined requestBody schema with all the field constraints. The BookSchema and BookListSchema became inlined response schemas. Every Zod constraint you wrote (the enum values, the minimum of 1 on page, the default of 1) is reflected in the generated spec. Notice that all schemas are inlined directly in each operation rather than stored in a shared components section.
And all of it is JSON. This is the OpenAPI 3.1 format. It is machine-readable, so tools can consume it to generate documentation UIs, typed clients, mock servers, and contract tests. But it is also human-readable enough that you can open it in a browser and understand what your API does. You will notice that the schemas are inlined in each operation. The same Book schema appears in both the GET response and the POST response. This is how @hectoday/openapi generates specs: every operation contains its full schema rather than referencing a shared definition.
Now open http://localhost:3000/docs in your browser. You will see an interactive documentation page where you can browse every endpoint, see the full schemas, and try requests directly. This is exactly what production APIs serve to their consumers. And it came from two lines of code: api.spec(route) and api.docs(route).
We just went from zero documentation to a complete, interactive API reference. But we are not done. The generated spec right now is bare-bones. It has no summaries, no descriptions, no examples, no authentication info, and no error documentation. In the next lesson, we will start enriching it by learning how OpenAPI describes endpoints using paths and operations.
Exercises
Exercise 1: Start the server and hit all three endpoints with curl. Make sure each one returns the expected response.
Exercise 2: Send an invalid request to POST /v2/books. Try omitting the title field, or sending a genre value that is not in the allowed list. Look at the validation error that comes back.
Exercise 3: Open http://localhost:3000/openapi.json in your browser and find the entry for GET /v2/books/{id}. Compare what you see in the spec to the route definition in the code.
Exercise 4: Open http://localhost:3000/docs and browse through the generated documentation. Try making a request from the interactive UI.
What is the main benefit of generating docs from code instead of writing them separately?