Project setup
The scenario
Here’s the situation. You built a book catalog API six months ago. A mobile app and a partner integration both call it daily. It works. People depend on it.
Now you need to evolve the API. You want to change the author field from a flat string to a nested object. You want to restructure ratings. You want to rename fields from snake_case to camelCase. But the existing clients can’t update right away. They need both the old and new formats available at the same time.
Let’s set up the project so we can see exactly what we’re working with.
Create the project
mkdir versioned-catalog
cd versioned-catalog
npm init -y
npm install @hectoday/http zod srvx better-sqlite3
npm install -D typescript @types/node @types/better-sqlite3 tsx Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "./src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src"]
} Add "type": "module" and a dev script to package.json:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts"
}
} The database
We need some data to work with. Let’s create a SQLite database with authors, books, and reviews:
// src/db.ts
import Database from "better-sqlite3";
const db = new Database("catalog.db");
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS authors (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
bio TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS books (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
author_id TEXT NOT NULL,
genre TEXT NOT NULL,
description TEXT,
published_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (author_id) REFERENCES authors(id)
);
CREATE TABLE IF NOT EXISTS reviews (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
user_id TEXT NOT NULL,
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
body TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (book_id) REFERENCES books(id)
);
`);
// Seed data (same as the Caching course)
const authorCount = (db.prepare("SELECT COUNT(*) AS c FROM authors").get() as any).c;
if (authorCount === 0) {
db.prepare("INSERT INTO authors (id, name, bio) VALUES (?, ?, ?)").run(
"author-1",
"Ursula K. Le Guin",
"American novelist",
);
db.prepare("INSERT INTO authors (id, name, bio) VALUES (?, ?, ?)").run(
"author-2",
"Jorge Luis Borges",
"Argentine writer",
);
db.prepare("INSERT INTO books (id, title, author_id, genre) VALUES (?, ?, ?, ?)").run(
"book-1",
"The Left Hand of Darkness",
"author-1",
"science-fiction",
);
db.prepare("INSERT INTO books (id, title, author_id, genre) VALUES (?, ?, ?, ?)").run(
"book-2",
"Ficciones",
"author-2",
"fiction",
);
db.prepare("INSERT INTO reviews (id, book_id, user_id, rating) VALUES (?, ?, ?, ?)").run(
"rev-1",
"book-1",
"user-1",
5,
);
db.prepare("INSERT INTO reviews (id, book_id, user_id, rating) VALUES (?, ?, ?, ?)").run(
"rev-2",
"book-1",
"user-2",
4,
);
}
export default db; This gives us two authors, two books, and two reviews. Enough to demonstrate everything we need.
The v1 API (the one clients already depend on)
This is the API that has been running for six months. This is what the mobile app and the partner integration call every day:
// src/v1/routes.ts
import { route } from "@hectoday/http";
import { z } from "zod/v4";
import db from "../db.js";
export const v1Routes = [
route.get("/v1/books", {
resolve: () => {
const books = db
.prepare(
`
SELECT books.id, books.title, authors.name AS author_name,
books.genre, books.created_at
FROM books JOIN authors ON books.author_id = authors.id
ORDER BY books.title
`,
)
.all();
return Response.json(books);
},
}),
route.get("/v1/books/:id", {
request: { params: z.object({ id: z.string() }) },
resolve: (c) => {
if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
const { id } = c.input.params;
const book = db
.prepare(
`
SELECT books.id, books.title, authors.name AS author_name,
books.genre, books.description, books.created_at,
(SELECT AVG(rating) FROM reviews WHERE book_id = books.id) AS rating
FROM books JOIN authors ON books.author_id = authors.id
WHERE books.id = ?
`,
)
.get(id);
if (!book) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(book);
},
}),
]; Now we wire everything up:
// src/app.ts
import { setup } from "@hectoday/http";
import { v1Routes } from "./v1/routes.js";
export const app = setup({ routes: [...v1Routes] }); Notice something important: the routes already use a /v1/ prefix. That’s intentional. Starting with a version prefix from day one makes adding v2 later much cleaner. If we had started with just /books, adding versioning later would mean either breaking that URL or maintaining both /books and /v2/books forever.
Now create the server file. srvx is a small library that starts an HTTP server from a fetch function:
// src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";
serve({ fetch: app.fetch, port: 3000 }); The serve function takes the fetch handler from our app and starts listening on port 3000. Start the server and try it out:
npm run dev curl http://localhost:3000/v1/books You should see a list of books. Now try a single book:
curl http://localhost:3000/v1/books/book-1 The response looks like this:
{
"id": "book-1",
"title": "The Left Hand of Darkness",
"author_name": "Ursula K. Le Guin",
"genre": "science-fiction",
"rating": 4.5,
"created_at": "2024-01-15T10:30:00"
} Notice the shape. Fields are snake_case. The author is a flat string, just the name, with no ID or other details. The rating is a single number, with no count of how many reviews contributed to it. This is the contract that existing clients depend on.
By the end of this course, this API will serve v1 and v2 simultaneously, with deprecation headers on v1 and a sunset timeline. But first, we need to understand the different ways you can put version numbers into an API. That’s what the next section covers.
Exercises
Exercise 1: Start the server. Call GET /v1/books and GET /v1/books/book-1. Note the response shapes. These are the contracts.
Exercise 2: Write a client function that reads author_name and rating from the v1 response. This is what would break if we made v2 changes.
Exercise 3: List the changes you want for v2: nested author object, ratings object with average and count, camelCase field names.
Why does the project start with a v1 prefix on routes?