ADR 0008: HTTP routing & API structure¶
- Status: Accepted
- Date: 2026-08-24
Context¶
The current src/index.ts is a single fetch handler that calls a Durable Object. TomeTrove needs a substantial set of REST endpoints, one per business object, each with its own route group.
Constraints:
- Workers' default handler is
ExportedHandler.fetch(request, env, ctx). A router keeps the switch-on-URL.pathnameout of hand-rolled code. - The author knows PHP routing (Laravel/Symfony controllers) — a router with a similar mental model lowers the barrier.
- Whatever we pick must be TS-first and Workers-compatible.
- Route handlers should use anonymous wrappers that call service methods (ADR 0012).
- Authentication is via JWT (ADR 0006) — most endpoints require auth; public list sharing is the exception.
- The UI consumes these REST endpoints and only these. The frontend is a multi-page application — each page is a separate HTML document with its own JavaScript that fetches from the REST API. No SPA (no client-side routing, no single-page shell), no SSR (the server returns JSON, not pre-rendered HTML with data), no BFF layer (no intermediate layer that pre-shapes data for a specific view). The REST API is the sole contract between frontend and backend. This means: the API must be complete (every piece of data the UI needs has an endpoint); the API is the contract (versioning and backward compatibility matter); response shapes must be consistent across endpoints so the UI can have generic handling.
API design¶
Conventions¶
- Plural route prefixes for collections:
/api/books,/api/authors,/api/wishes.GET /api/bookslists books;GET /api/books/:idreturns one book. The:iddisambiguates list vs. single. - Singular route prefixes for singletons:
/api/user/preferences(one per user). No:id— the resource is scoped to the authenticated user. - Search is a query param on the list endpoint:
GET /api/books?q=hamlet,GET /api/authors?q=poe. Same endpoint for listing and searching — search is just a filter. - Non-CRUD actions are POST to a sub-resource:
POST /api/editions/:id/prices(on-demand fetch),POST /api/wishes/import(CSV import). Actions are modeled as creates on a sub-resource. - Child resources are nested under their parent: editions under books, price quotes under editions. Direct lookup by ID is also supported via a flat route when the parent ID is not needed.
Endpoints¶
| Business object | Route | Method | Purpose |
|---|---|---|---|
| Books | /api/books |
GET | List (with optional ?q= search) |
/api/books |
POST | Create | |
/api/books/:id |
GET | Get one | |
/api/books/:id |
PUT | Update | |
/api/books/:id |
DELETE | Delete | |
| Editions | /api/books/:bookId/editions |
GET | List editions for a book |
/api/books/:bookId/editions |
POST | Add an edition | |
/api/editions/:id |
GET | Get one edition (flat, by ID) | |
| Prices | /api/editions/:editionId/prices |
GET | List prices for an edition |
/api/editions/:editionId/prices |
POST | On-demand price fetch | |
| Authors | /api/authors |
GET | List/search (with ?q=, min 3 chars — ADR 0016) |
/api/authors |
POST | Create (user-created author not found in the pre-loaded database) | |
/api/authors/:id |
GET | Get one | |
/api/authors/:id |
PUT | Update (e.g. fix name, add aliases) | |
/api/authors/:id |
DELETE | Delete (only if no books reference this author) | |
| Wishes | /api/wishes |
GET | List user's wish list |
/api/wishes |
POST | Add a book to wish list | |
/api/wishes/:id |
DELETE | Remove from wish list | |
/api/wishes/:id |
PATCH | Update (e.g. toggle is_monitored) |
|
/api/wishes/import |
POST | CSV import | |
| Lists | /api/lists |
GET | List user's lists |
/api/lists |
POST | Create a list | |
/api/lists/:id |
GET | Get one (owner) | |
/api/lists/:id |
DELETE | Delete a list | |
/api/lists/:token |
GET | Public list view (no auth — ADR 0017) | |
| Preferences | /api/user/preferences |
GET | Get user preferences |
/api/user/preferences |
PUT | Update user preferences | |
/api/user/preferences/languages |
GET | List user's readable languages | |
/api/user/preferences/languages |
POST | Add a language | |
/api/user/preferences/languages/:id |
DELETE | Remove a language | |
| Stores | /api/stores |
GET | List all stores |
/api/user/stores |
GET | List stores applicable to the user (filtered by country/currency/format) | |
| Languages | /api/languages |
GET | List all languages |
| Types | /api/types |
GET | List the 9 Types |
| Genres | /api/genres |
GET | List genres (optionally filtered by ?type_id=) |
| Ontology | /api/ontology |
GET | List ontology nodes (optionally filtered by ?type_id=&genre_id=) |
| Alerts | /api/alerts |
GET | List alerts |
/api/alerts/:id |
PATCH | Mark as read |
Auth scoping¶
All endpoints under /api/ require JWT authentication (ADR 0006) except:
GET /api/lists/:token— public list sharing, no auth (ADR 0017).
User-scoped resources (wishes, lists, preferences, alerts) are automatically filtered to the authenticated user — no :userId in the path. The user ID is extracted from the JWT.
Options¶
- Hono — the de-facto Workers router; TS-first; middleware chain (auth, error handling, logging); tiny; large ecosystem; route groups for per-resource organization; path parameters (
/api/books/:id) with type inference. The frontend is a multi-page application that consumes these REST endpoints (ADR 0007) — Hono serves the API, static assets serve the pages. - itty-router — even smaller; functional style; less middleware ecosystem; no built-in route groups.
- Native
URLswitching — no dependency; most boilerplate; closest to raw Workers; impractical for 30+ routes.
Decision¶
Adopt option 1: Hono.
Response conventions¶
Since the UI consumes only these endpoints, response shapes must be consistent and predictable:
- Single resource: the resource object as the JSON body (e.g.
GET /api/books/:id→{ "book_id": 1, "book_original_title": "Hamlet", ... }). - Collections: a JSON object with
data(array of resources) and_linksfor pagination (HATEOAS). Example:
{
"data": [
{ "book_id": 1, "book_original_title": "Hamlet", ... },
{ "book_id": 2, "book_original_title": "Macbeth", ... }
],
"_links": {
"self": { "href": "/api/books?cursor=eyJpZCI6MjB9&per_page=20" },
"first": { "href": "/api/books?per_page=20" },
"prev": { "href": "/api/books?cursor=eyJpZCI6MTB9&per_page=20" },
"next": { "href": "/api/books?cursor=eyJpZCI6NDB9&per_page=20" },
"last": { "href": "/api/books?cursor=eyJpZCI6OTkwfQ&per_page=20" }
}
}
self— the current page.first— the first page (no cursor).prev— the previous page (omitted if on the first page).next— the next page (omitted if on the last page).last— the last page.- Changing
per_pageresets to the first page — cursor positions are relative to the page size, so a differentper_pageinvalidates the current cursor. Thefirstlink uses the newper_pagevalue. - The UI follows these links — it does not construct URLs or know cursor encoding.
- No total count — the UI shows "first/prev/next/last" navigation, not "page 2 of 10". This avoids the expensive
COUNT(*)query on large tables. - Errors: a JSON object with
error(machine-readable code) andmessage(human-readable) (e.g.{ "error": "not_found", "message": "Book not found" }). HTTP status code matches the error (400, 404, 409, 500). - Empty mutations:
204 No Contentfor DELETE and PATCH that don't return a body. - Created resources:
201 Createdwith the resource body.
Pagination¶
All list endpoints use cursor-based pagination with HATEOAS links. The cursor is an opaque, URL-safe string encoding the position in the result set (typically the last seen PK). The UI passes per_page (default 20, max 100) and follows next/prev links from the response — it never constructs cursor values.
Cursor pagination is used uniformly on all list endpoints for consistency, even where offset pagination would be sufficient (small per-user collections like wishes and alerts). This gives the UI one generic pagination handler across all endpoints.
The ontology tree (GET /api/ontology) is an exception — it returns the full tree for a (type, genre) pair, not paginated. The tree is small (hundreds of nodes) and is rendered client-side as a whole.
Route organization¶
Each business object gets its own route file under src/routes/, exporting a Hono instance with its routes. The main app composes them:
// src/routes/books.ts — pseudocode, see ADR 0012 for method binding conventions
import { Hono } from "hono";
import { bookService } from "../services/book-service";
export const books = new Hono();
books.get("/", (c) => bookService.list(c.req.query("q"), c.req.param()));
books.post("/", (c) => bookService.create(c.req.json()));
books.get("/:id", (c) => bookService.get(c.req.param("id")));
books.put("/:id", (c) => bookService.update(c.req.param("id"), c.req.json()));
books.delete("/:id", (c) => bookService.delete(c.req.param("id")));
// src/index.ts
import { Hono } from "hono";
import { books } from "./routes/books";
import { editions } from "./routes/editions";
// ... other route modules
const app = new Hono();
app.route("/api/books", books);
app.route("/api/editions", editions);
// ... mount other route modules
app.route("/api", api);
export default app;
Middleware¶
Hono's middleware chain handles cross-cutting concerns:
- Auth middleware — validates the JWT (ADR 0006), extracts the user ID, attaches it to the context. Applied to all
/api/routes except the public list endpoint. - Error handler middleware — catches service errors, maps them to HTTP status codes (400 for validation, 404 for not found, 409 for conflicts, 500 for internal errors).
- Logging middleware — request method, path, status, duration.
Consequences¶
- Positive: Hono is the de-facto Workers router — well-documented, TS-first, large ecosystem; route groups per business object keep the codebase organized — each resource is a self-contained file; path parameters with type inference reduce boilerplate; middleware chain handles auth, error handling, and logging uniformly; the REST conventions (plural collections, singular singletons, query-param search, POST-to-sub-resource actions) are standard and predictable; search reuses the list endpoint — no extra routes; non-CRUD actions are RESTful (POST to sub-resource).
- Negative: a dependency on Hono — but it's tiny, Workers-native, and widely used; the number of route files grows with the number of business objects (acceptable — each is small and self-contained); nested routes (editions under books, price quotes under editions) require careful path parameter handling.
- Neutral: the frontend is a multi-page application consuming these REST endpoints (ADR 0007) — Hono serves the API, static assets serve the HTML pages; the public list endpoint (
GET /api/lists/:token) bypasses auth middleware — this is an explicit exception, not a gap.