Paths and operations
We have a running API with three endpoints and a generated spec at /openapi.json. But we never looked at what that spec actually contains. In this lesson, we are going to open it up and walk through the most important section: paths. This is where OpenAPI describes every endpoint your API exposes. Understanding this section is the foundation for everything that follows.
Looking at the generated paths
Make sure your server is running from the previous lesson, then pull down just the paths section of the spec:
curl http://localhost:3000/openapi.json | jq '.paths' You will see something like this:
{
"/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 }
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"title": { "type": "string" }
// ... other Book 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" }
// ... other Book fields
},
"required": ["id", "title", "author", "genre", "ratings", "createdAt"],
"additionalProperties": false
}
}
}
}
}
}
},
"/v2/books/{id}": {
"get": {
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": { "type": "string", "format": "uuid" }
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"title": { "type": "string" }
// ... other Book fields
},
"required": ["id", "title", "author", "genre", "ratings", "createdAt"],
"additionalProperties": false
}
}
}
}
}
}
}
} That is a lot of JSON, but the structure is straightforward once you see the pattern. You will notice that all schemas are inlined directly in each operation. The Book schema, for example, appears in full in both the GET /v2/books response (inside the array items), the GET /v2/books/{id} response, and the POST /v2/books response. This means there is some duplication in the raw JSON, but each operation is self-contained. We will talk more about this in the component schemas lesson.
Let’s break it down.
Paths and operations
The top-level keys are URL paths: "/v2/books" and "/v2/books/{id}". These are the addresses where your API lives. Under each path, the next level of keys are HTTP methods: "get", "post". Each method under a path is called an operation.
So GET /v2/books is one operation. POST /v2/books is a different operation, even though they share the same path. GET /v2/books/{id} is a third operation on a different path. Every unique combination of path and method is its own operation.
Where did all of this come from? Look back at the routes in src/app.ts:
route.get("/v2/books", { ... }) // → paths["/v2/books"].get
route.post("/v2/books", { ... }) // → paths["/v2/books"].post
route.get("/v2/books/:id", { ... }) // → paths["/v2/books/{id}"].get Each route.get() or route.post() call became a path and operation in the spec. You did not write any of this JSON by hand. @hectoday/openapi read your route definitions and produced it.
Notice one small difference: your code uses :id (the Express/Hono convention for path parameters), but the spec uses {id} (the OpenAPI convention). @hectoday/openapi converts between these formats automatically.
A note about operation IDs
You might notice that the generated spec does not include operationId fields on the operations. Some OpenAPI specs include them, and they can be useful for client SDK generation (tools like openapi-typescript use operation IDs to create method names). The spec we generate works without them because client generation tools can also derive method names from the path and HTTP method. We will see how this works in the client generation lesson.
If you look at other APIs’ OpenAPI specs, you will often see operationId fields like getBooks or createBook. Those are added manually or by tools that support them. For now, the important thing is that every operation in our spec is uniquely identified by its path and method combination.
What the spec captured from your code
Go back and compare the generated JSON to the route definitions in src/app.ts. Look at what @hectoday/openapi extracted:
From the request.query schema on GET /v2/books, it generated four query parameters. Each one has the correct type, the enum values from z.enum(), the minimum and maximum from z.min() and z.max(), and the default from z.default(). Every Zod constraint you wrote is reflected in the spec.
From the request.params schema on GET /v2/books/:id, it generated a path parameter with "in": "path", "required": true, and "format": "uuid". Path parameters are always required because the client cannot call /v2/books/ and leave the ID blank.
From the request.body schema on POST /v2/books, it generated a requestBody with the full inlined JSON schema. We will look at request bodies in detail in a later lesson.
From the response field on each route, it generated responses entries with the correct status codes (200 and 201) and inlined response schemas.
What do you think would happen if you added a new field to BookQuerySchema, say a search string parameter? The next time the server starts, /openapi.json would include that new parameter automatically. You would not need to update the spec by hand. This is exactly what we talked about in the first lesson: the docs cannot drift because they come from the code.
How routes become paths
Here is the full mapping between your TypeScript routes and the generated spec, so you can see the pattern clearly:
| Route definition | Generated spec location |
|---|---|
route.get("/v2/books", ...) | paths["/v2/books"].get |
route.post("/v2/books", ...) | paths["/v2/books"].post |
route.get("/v2/books/:id", ...) | paths["/v2/books/{id}"].get |
request.query (Zod schema) | parameters with "in": "query" |
request.params (Zod schema) | parameters with "in": "path" |
request.body (Zod schema) | requestBody.content["application/json"].schema |
response (status code → Zod schema) | responses[statusCode].content["application/json"].schema |
Every piece of information in the spec came from something you already wrote in your route definitions. The HTTP method, the path, the Zod schemas for validation, and the response schemas for documentation. @hectoday/openapi just translated it into the OpenAPI format.
The spec we have right now is functional but bare. It has paths, operations, parameters, request bodies, and response schemas. What it does not have yet is descriptions that explain what things mean, examples that show what real data looks like, or error responses for when things go wrong. We will start adding those in the coming lessons. Next up: a closer look at how request parameters and request bodies appear in the spec, and what happens when your Zod schemas get more complex.
Exercises
Exercise 1: Run curl http://localhost:3000/openapi.json | jq '.paths' and compare the output to the route definitions in src/app.ts. Match each route to its generated spec entry.
Exercise 2: Add a DELETE /v2/books/:id route to your src/app.ts (it can return a simple { deleted: true } response). Restart the server and check /openapi.json again. Verify the new operation appears in the spec without any extra work.
Exercise 3: Add a new optional field to BookQuerySchema, like search: z.string().optional(). Restart the server and check whether the new query parameter shows up in the generated spec.
What uniquely identifies an operation in an OpenAPI spec?