my-library

The API

One resource, five verbs, a bearer token — and one design decision about null that the three clients all had to reckon with.

Endpoints

MethodPathBodyReturns
GET/api/v1/books—{ books: [...] } — all of them, newest-updated first
POST/api/v1/booksa book, or an array of books{ books: [...] } at 201 — always an array
GET/api/v1/books/:id—{ book: {...} }
PUT / PATCH/api/v1/books/:ida partial book{ book: {...} }
DELETE/api/v1/books/:id—{ deleted: <id> }

PUT and PATCH are the same handler. Both apply a partial update — PUT does not replace the document. That is a deviation from strict REST, done because there is no second behaviour worth writing and a client that reaches for PUT should not silently wipe fields it didn’t send.

Errors

{ "error": "title: title is required", "code": 422 }
StatusWhen
401Missing, malformed, unknown or revoked token.
404No such book — or it belongs to someone else.
422Unparseable JSON, or a body zod rejected.

A book owned by another user is 404, not 403. Answering “that exists but is not yours” tells an unauthorised caller something true about the database; answering “not found” does not. Every query is scoped by (id, userId), so this falls out of the query rather than being a check somebody has to remember.

Auth

lib/auth.ts
Authorization: Bearer <token>
        │
        ├─ sha-256 ──▶ api_tokens.tokenHash
        │
        └─ revoked? ──▶ null ──▶ 401

Tokens are stored as hashes and shown once, at mint time. A lost token is not recoverable — mint another. There is no sign-up endpoint; npm run create:user and npm run create:token are the whole account system, which is honest for a two-digit user count and removes password reset, email verification and their attack surface from the project entirely.

Batch create is the same endpoint

POST accepts either shape and always answers with an array:

POST /api/v1/books   { "title": "Dune", "author": "Frank Herbert" }
POST /api/v1/books   [ { ... }, { ... } ]

→ 201 { "books": [ ... ] }

The alternative — a separate /books/batch — would have doubled the validation and given clients a second code path for what is one operation. Normalising to an array on the way in costs one line and makes the response shape unconditional.

The null problem

A partial update has three intentions, and JSON only obviously expresses two of them:

WireMeans
key absentleave the column alone
key with a valuewrite it
key: nullclear it — the caller means it, and zod's .nullish() allows it

The server side is trivial: whatever survives bookUpdateSchema.safeParse is spread into .set(), so an absent key never appears in the UPDATE. The cost lands on the clients, because most languages collapse “absent” and “null” into one value. Each of the three solved it differently, and Mobile clients shows how.

The edit screens depend on this

All three clients diff the form against the book they loaded and send only what changed — clearing a field sends an explicit null, and an untouched field is omitted entirely. Without the three-state distinction, opening an edit form and saving would rewrite every column with whatever the form happened to hold.

What it does not do

  • No pagination, no filtering, no sort. It returns everything. Clients that need those do them locally; the website doesn’t use this API at all.
  • No ETag, no 304. kanji-buddy has them because it clones a spec that requires them. Here there was no such pressure.
  • No rate limiting. One user, one token.

The first of these will eventually have to change; 3,367 books is a large response to hand a phone on a bad connection. Cursor pagination is the obvious addition, and the stable (userId, id) index is already the right shape for it.