The data layer can store and fetch; now we expose it over HTTP as a clean contract. This module applies The API Surface chapter plus two deep dives directly in code: idempotency on create, and keyset pagination on list. (Auth comes next module — for now, treat the owner as a fixed test user so we can focus on the API shape.)
Goal
POST /snippets— create, with input validation and an idempotency key.GET /s/:publicId— read one (respectingis_public; full enforcement lands with auth).GET /snippets— list the owner's snippets, newest first, with keyset pagination.- Consistent error shapes and correct status codes throughout.
Step 1: Validate at the boundary
Data from the network is untrusted — it can be any shape, any type, missing fields, or hostile. Validate it once, at the edge, and let the rest of your code trust it. Use a schema validator (Zod) so the validated result is also correctly typed.
import { z } from "zod";
const CreateSnippet = z.object({
body: z.string().min(1).max(100_000),
title: z.string().max(200).optional(),
language: z.string().max(40).default("text"),
isPublic: z.boolean().default(false),
});app.post("/snippets", async (c) => {
const parsed = CreateSnippet.safeParse(await c.req.json());
if (!parsed.success) {
return c.json({ error: "invalid_body", details: parsed.error.issues }, 400);
}
const input = parsed.data; // fully typed and trusted from here
// …
});Validate at the boundary, trust inside
The system-boundary rule from the chapter, made concrete: untrusted input is validated exactly once, at the HTTP edge. Past that line, your repository and business logic receive a known, typed shape and don't re-check it. This is also the single place to enforce limits (a 100KB body cap) that protect everything downstream — your database, your memory, your bill.
Step 2: Make create idempotent
A client creates a snippet, the network drops the response, the client retries — and now there are two identical snippets. The idempotency deep dive's fix: the client sends an Idempotency-Key header, and you claim it atomically before doing the work.
Add an idempotency_keys table
A migration:
key text primary key, response jsonb, created_at timestamptz. The primary key gives you the atomic claim.Claim the key before creating
insert into idempotency_keys (key) values ($1)inside a transaction. If it succeeds, this is the first time — do the work. If it fails on the primary-key conflict, a previous request already handled this key — return the stored response.Store the response against the key
After creating the snippet, save the response body to the key's row, so the retry replays the same result (same
public_id), not a new one.
const key = c.req.header("Idempotency-Key");
if (!key) return c.json({ error: "idempotency_key_required" }, 400);
const replay = await getIdempotentResponse(key);
if (replay) return c.json(replay, 200); // retry → same answer, no new snippet
const publicId = generateShortId(); // e.g. nanoid(7)
const snippet = await createSnippetWithKey(key, { ...input, publicId, ownerId });
return c.json(toResponse(snippet), 201);The "claim atomically, not check-then-act" point from the deep dive is the whole game: do the key insert inside the same transaction as the snippet insert, so two concurrent retries can't both slip through.
Step 3: Read one, with the public/private rule
app.get("/s/:publicId", async (c) => {
const snippet = await getByPublicId(c.req.param("publicId"));
if (!snippet) return c.json({ error: "not_found" }, 404);
if (!snippet.is_public) {
// private: only the owner may read. Full check arrives with auth (Module 4).
return c.json({ error: "not_found" }, 404); // 404, not 403 — don't leak existence
}
return c.json(toResponse(snippet));
});Note the deliberate 404 for a private snippet you don't own, not 403 — the existence-leak point from the authorization deep dive. We'll wire the real owner check in Module 4.
Step 4: List with keyset pagination
The naive list is ORDER BY created_at DESC LIMIT 20 OFFSET 40. The pagination deep dive showed why that rots: deep offsets get slow (the database walks and discards every skipped row) and can show duplicates when new rows arrive between pages. Keyset pagination fixes both by paginating on a cursor — "give me the 20 rows after this one" — instead of an offset.
// First page: no cursor. Next page: cursor = the last row's (created_at, id).
app.get("/snippets", async (c) => {
const limit = Math.min(Number(c.req.query("limit") ?? 20), 100);
const cursor = c.req.query("cursor"); // base64 of "created_at,id" or undefined
const rows = await listOwnerSnippets({ ownerId, limit, cursor });
const nextCursor =
rows.length === limit ? encodeCursor(rows[rows.length - 1]) : null;
return c.json({ items: rows.map(toResponse), nextCursor });
});-- The keyset query. Tuple comparison gives a stable, index-friendly "after".
select * from snippets
where owner_id = $1
and (created_at, id) < ($2, $3) -- the cursor; omit on the first page
order by created_at desc, id desc
limit $4;This needs the right index, or it's still a scan. Add it now:
create index snippets_owner_feed
on snippets (owner_id, created_at desc, id desc);Why the index columns are in that exact order
The index leads with owner_id (the equality filter), then created_at desc, id desc (the sort and the tiebreaker). That order lets Postgres jump straight to this owner's rows and read them already in feed order — the query plan becomes an index range scan with no sort step, fast at any page depth. Reverse the columns and the index can't serve the query. This is the reading-query-plans skill applied at design time instead of during an incident.
The id tiebreaker isn't optional
Two snippets can share a created_at (same millisecond). Paginating on created_at alone would let one fall in the crack between pages or appear on both. Adding id as a tiebreaker makes the sort total — every row has a unique position — so the cursor is unambiguous. This is the subtle correctness point most pagination tutorials miss.
Acceptance check
# create is idempotent: same key twice → one snippet, same id, 201 then 200
KEY=$(uuidgen)
curl -XPOST localhost:3000/snippets -H "Idempotency-Key: $KEY" \
-d '{"body":"console.log(1)","isPublic":true}' # 201, public_id "X"
curl -XPOST localhost:3000/snippets -H "Idempotency-Key: $KEY" \
-d '{"body":"console.log(1)","isPublic":true}' # 200, SAME public_id "X"
# invalid body → 400 with details
curl -XPOST localhost:3000/snippets -H "Idempotency-Key: $(uuidgen)" -d '{}'
# list returns items + nextCursor; following the cursor returns the next page
curl "localhost:3000/snippets?limit=2"You're done when a repeated idempotency key yields one snippet (not two), invalid input returns 400, and the list pages forward via nextCursor. Commit it.
What you just internalised
A good API validates untrusted input at the boundary and trusts it inside, makes writes safe to retry so the network's normal weather can't double-charge anyone, and paginates on a cursor so it stays fast and correct as data grows. Those three properties — and consistent status codes — are what separate an API people can build on from one that bites them.
Comments
Loading comments…