OpenAPI tutorial: document and validate a REST API
By Flavio Copes
Learn OpenAPI by building a books API with reusable schemas, request validation, interactive documentation, and a generated TypeScript client.
An API becomes much easier to use when it has a contract.
That contract should answer precise questions:
- which endpoints exist?
- which parameters do they accept?
- what does a valid request body look like?
- which responses can come back?
- which fields are required?
OpenAPI is a machine-readable format for describing an HTTP API.
The same document can generate reference documentation, validate requests, create clients, and detect accidental breaking changes.
In this tutorial we’ll build a small books API using Hono, Zod, and OpenAPI.
The API will have three endpoints:
GET /books
GET /books/{id}
POST /books
We’ll define each route once and use that definition for both runtime validation and the OpenAPI document.
Create the project
Start with a Hono Node.js app:
npm create hono@latest books-api
cd books-api
Choose the Node.js template, then install the OpenAPI packages:
npm install @hono/zod-openapi @scalar/hono-api-reference
@hono/zod-openapi combines:
- Hono routes
- Zod validation
- OpenAPI metadata
Scalar renders the resulting document as an interactive API reference.
Create the first schema
Open the main application file created by Hono and replace it with:
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'
import { Scalar } from '@scalar/hono-api-reference'
const app = new OpenAPIHono()
const BookSchema = z
.object({
id: z.string().openapi({ example: 'book_1' }),
title: z.string().openapi({ example: 'The Hobbit' }),
author: z.string().openapi({ example: 'J. R. R. Tolkien' }),
year: z.number().int().openapi({ example: 1937 }),
})
.openapi('Book')
const books = [
{
id: 'book_1',
title: 'The Hobbit',
author: 'J. R. R. Tolkien',
year: 1937,
},
]
A schema describes the shape of data.
The .openapi('Book') call gives it a reusable name in the generated document. The examples make the API reference more useful, but they do not change validation.
Describe GET /books
Create a route definition:
const listBooksRoute = createRoute({
method: 'get',
path: '/books',
summary: 'List all books',
responses: {
200: {
description: 'A list of books',
content: {
'application/json': {
schema: z.object({
books: z.array(BookSchema),
}),
},
},
},
},
})
Then attach its handler:
app.openapi(listBooksRoute, (c) => {
return c.json({ books }, 200)
})
The route says that a successful response is JSON with a books array.
OpenAPI response keys are HTTP status codes. They describe possibilities; they do not send the response for us.
Add a path parameter
GET /books/{id} needs an ID from the URL.
Define the parameter schema:
const BookParamsSchema = z.object({
id: z.string().openapi({
param: {
name: 'id',
in: 'path',
},
example: 'book_1',
}),
})
Now define success and error responses:
const ErrorSchema = z
.object({
error: z.string(),
})
.openapi('Error')
const getBookRoute = createRoute({
method: 'get',
path: '/books/{id}',
summary: 'Get one book',
request: {
params: BookParamsSchema,
},
responses: {
200: {
description: 'The requested book',
content: {
'application/json': {
schema: BookSchema,
},
},
},
404: {
description: 'Book not found',
content: {
'application/json': {
schema: ErrorSchema,
},
},
},
},
})
The braces in /books/{id} are OpenAPI syntax for a path parameter.
Implement it:
app.openapi(getBookRoute, (c) => {
const { id } = c.req.valid('param')
const book = books.find((item) => item.id === id)
if (!book) {
return c.json({ error: 'Book not found' }, 404)
}
return c.json(book, 200)
})
c.req.valid('param') returns the validated value. The handler does not need to parse the URL manually.
Validate a request body
The create endpoint should accept a title, author, and year. The client must not choose the ID.
Create a separate input schema:
const NewBookSchema = z
.object({
title: z.string().min(1),
author: z.string().min(1),
year: z.number().int().min(1000).max(2100),
})
.openapi('NewBook')
Then define the route:
const createBookRoute = createRoute({
method: 'post',
path: '/books',
summary: 'Create a book',
request: {
body: {
required: true,
content: {
'application/json': {
schema: NewBookSchema,
},
},
},
},
responses: {
201: {
description: 'The created book',
content: {
'application/json': {
schema: BookSchema,
},
},
},
},
})
Add the handler:
app.openapi(createBookRoute, (c) => {
const input = c.req.valid('json')
const book = {
id: `book_${books.length + 1}`,
...input,
}
books.push(book)
return c.json(book, 201)
})
Try sending an invalid year or omitting the author. The request is rejected before the handler runs.
That is the important difference between a comment and a contract. A comment says what we hope to receive. Validation checks what actually arrived.
Generate the OpenAPI document
Add this after the API routes:
app.doc('/openapi.json', {
openapi: '3.1.0',
info: {
title: 'Books API',
version: '1.0.0',
description: 'A small API for managing books',
},
})
Run the development server and open:
http://localhost:3000/openapi.json
The exact port depends on the Hono template.
You will see a JSON document containing:
openapi, the specification versioninfo, metadata about this APIpaths, the available endpointscomponents.schemas, reusable data shapes
OpenAPI 3.2 is the newest specification version at the time of writing, but tooling support usually arrives gradually. We are emitting 3.1 here because the libraries in this project support it well.
The API contract version in info.version is different from the OpenAPI format version. 1.0.0 is our API’s version.
Add interactive documentation
JSON is useful to tools but tiring for people.
Mount Scalar:
app.get(
'/docs',
Scalar({
url: '/openapi.json',
pageTitle: 'Books API',
})
)
Open /docs.
You can browse every operation, inspect schemas, see examples, and send requests from the browser.
The reference page is not the source of truth. It is a view generated from the OpenAPI document.
Add error responses for invalid input
Validation errors are also part of the public behavior of an API.
By default, the integration returns a validation response, but a serious API should document its exact error shape.
One approach is to provide a default validation hook:
const app = new OpenAPIHono({
defaultHook: (result, c) => {
if (!result.success) {
return c.json(
{
error: 'Invalid request',
issues: result.error.issues,
},
422
)
}
},
})
Then add a 422 response to routes that accept input.
Keep errors predictable. A client should not need three different parsers for three endpoints.
Generate TypeScript types
An OpenAPI document can produce client-side types without sharing server source code.
Install openapi-typescript:
npm install --save-dev openapi-typescript
With the server running:
npx openapi-typescript http://localhost:3000/openapi.json \
--output src/api-types.d.ts
The generated file contains TypeScript types for paths, operations, and schemas.
Add a script to package.json:
{
"scripts": {
"generate:api": "openapi-typescript http://localhost:3000/openapi.json --output src/api-types.d.ts"
}
}
Do not edit the generated file. Change the API schema and regenerate it.
For a separate frontend repository, publish the document at a stable URL or save it as a CI artifact. The frontend can generate its client from that contract.
Test the contract
The document and implementation are connected because our routes come from the same definitions. We should still test behavior.
Hono can call the app without starting a network server:
import { describe, expect, test } from 'vitest'
import app from './index'
describe('POST /books', () => {
test('rejects an invalid book', async () => {
const response = await app.request('/books', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
title: '',
author: 'Someone',
year: 20,
}),
})
expect(response.status).toBe(422)
})
})
Add tests for every documented status code, especially errors.
Response validation is also worth considering in development and tests. Request validation protects your application. Response validation protects your clients from your application returning the wrong shape.
Detect breaking changes
Suppose we rename author to authorName.
That looks like a small refactor inside the server. To clients, it is a breaking change.
Save the released OpenAPI document in the repository or download it from production. In CI, compare it with the new document using an OpenAPI diff tool.
Changes that usually break clients include:
- removing an endpoint
- removing a response field
- making an optional field required
- changing a field’s type
- removing an accepted enum value
Adding an optional response field is usually safe, provided clients ignore unknown fields.
The diff should be one CI check, not the only check. A technically non-breaking change can still change semantics.
Design the contract first or generate it from code?
There are two common workflows.
In a design-first workflow, you write openapi.yaml before implementing the API. Teams review the contract and generate server stubs and clients from it.
In a code-first workflow, route definitions generate the document. That is what we did.
Neither is automatically better.
Design-first works well when several teams must agree before implementation begins. Code-first reduces duplication in a smaller TypeScript project.
The bad workflow is keeping a handwritten document that nobody checks against the running API. It becomes stale and teaches clients the wrong behavior.
What to put in a production document
Our document is intentionally small. A public API usually also describes:
- authentication under
components.securitySchemes - pagination parameters and metadata
- rate-limit responses
- idempotency keys for retryable writes
- examples for important requests and errors
- tags that group related operations
- server URLs for development and production
Descriptions should explain meaning, not repeat field names.
This is not helpful:
author:
description: The author
This is better:
author:
description: The name printed as the book's primary author
OpenAPI is most valuable when it becomes part of the development loop:
- define the route and schemas
- validate real traffic
- generate the reference and types
- test documented responses
- compare contracts in CI
At that point the document is not extra paperwork. It is the shared contract between the server, its clients, its tests, and the people using the API.
Related posts about network: