Server-Side Applications & Databases

Resource APIs & Data Lifecycle


Learning Objectives

  • You can design resource operations with explicit HTTP and field contracts.
  • You can check resource relationships and choose deletion behavior that preserves required history.
  • You can express lifecycle rules through explicit commands.
  • You can reject stale edits and preserve fields omitted from a partial update.
  • You can test lifecycle effects rather than only successful status codes.

An API is not simply a list of database tables exposed over HTTP. It offers operations with contracts: what a caller can ask for, which representation comes back, which errors are meaningful, and how records change over time.

The previous chapters established routes, input validation, repositories, and services. Here we use those pieces to define permitted changes to a resource. The following chapters will protect these decisions across multiple database writes, competing requests, and time-dependent transitions.

We use resource-oriented HTTP as the main course design style.

Other styles exist, including RPC-like commands and GraphQL.

CRUD is a useful starting point

For a neutral library catalogue:

OperationExample requestSuccessful result
CreatePOST /api/books201 with the created representation
Read collectionGET /api/books200 with a bounded collection
Read oneGET /api/books/42200 with the book
Replace editable representationPUT /api/books/42Documented replacement result
Partially updatePATCH /api/books/42Documented changed representation
DeleteDELETE /api/books/42204, or another documented success representation

Create, read, update, and delete (CRUD) describe common data operations. The response status code and body shape are part of the contract.

REST and resource-oriented HTTP

REST (Representational State Transfer) is an architectural style described in Roy Fielding’s doctoral dissertation. It includes constraints such as stateless interactions, cacheable responses, and a uniform interface, where links in representations guide clients to available actions.

Resource URLs and CRUD operations alone do not establish that an API follows REST. This course uses resource-oriented HTTP to describe its approach.

For optional further reading, see Chapter 5 of Fielding’s dissertation.

Make an editable-field contract

A client creating a book might supply title and description, but it should not choose other fields such as ownership or database-generated IDs. The way how data in the request maps to the database is a service decision, not a caller’s choice.

Request methods that are used to update existing resources such as PUT and PATCH also need contracts. Does an omitted description mean remove it, preserve it, or reject the request? For a partial update, distinguish absent values from explicit null; they are not always the same instruction.

For example, suppose a book currently has description: "First edition". A PATCH contract can define {} as no requested field change (rejected here), { "title": "New title" } as preserving the description, and { "description": null } as explicitly clearing it. An empty string is a supplied value, not an omitted field.

The schema describes permitted input shapes; the service enforces what this caller and this record’s current state permit.

Work through partial-update fields

Add api/src/services/book-edits.ts to the practice walking skeleton. A book is just a value here: no map, HTTP routes, or database migration are needed to observe omission, replacement, and clearing.

import { z } from "zod";
export type Book = { title: string; description: string | null };
export const BookEditSchema = z.object({
title: z.string().trim().min(1).max(80).optional(),
description: z.string().max(500).nullable().optional(),
}).strict().refine((edit) => edit.title !== undefined || edit.description !== undefined);
type BookEdit = z.infer<typeof BookEditSchema>;
export const applyBookEdit = (book: Book, edit: BookEdit): Book => ({
title: edit.title === undefined ? book.title : edit.title,
description: edit.description === undefined ? book.description : edit.description,
});

Here a named type is useful because applyBookEdit needs to describe its edit parameter. type BookEdit = z.infer<typeof BookEditSchema> gives a compile-time name to the schema’s parsed output shape. typeof BookEditSchema refers to the schema’s TypeScript type, and z.infer derives the shape from it. This keeps the parameter type in step with the schema without copying its fields into a separate handwritten type.

The alias BookEdit is local because only this module uses it.

A type annotation does not run validation. The caller must still parse untrusted input before passing the result to applyBookEdit, as the test below demonstrates. optional() permits omission; nullable() permits an explicit null. An empty edit or an unknown field is rejected.

To test this, create api/tests/book-edits_test.ts:

import { assertEquals } from "@std/assert";
import { applyBookEdit, BookEditSchema } from "@src/services/book-edits.ts";
Deno.test("an edit distinguishes omission, replacement, and clearing", () => {
const book = { title: "Reading together", description: "A guide" };
assertEquals(applyBookEdit(book, BookEditSchema.parse({ title: "New title" })),
{ title: "New title", description: "A guide" });
assertEquals(applyBookEdit(book, BookEditSchema.parse({ description: "New text" })).description,
"New text");
assertEquals(applyBookEdit(book, BookEditSchema.parse({ description: null })).description, null);
assertEquals(book.description, "A guide");
assertEquals(BookEditSchema.safeParse({}).success, false);
});

Run from practice/:

Terminal window
docker compose run --build --rm --no-deps api deno test --cached-only tests/book-edits_test.ts

Replace the description expression temporarily with edit.description ?? book.description. The clearing assertion should fail: ?? treats explicit null like omission. Restore the explicit comparison before continuing.

Detect a stale edit

Two editors can read revision 3 of a book record and then submit different updates. An allowlist does not tell us which version each person edited. Add an integer version field to the stored resource and require the expected version with an update.

For an entry in a database table, there could as well be a column called updated_at with a timestamp. The service can compare the caller’s timestamp with the stored one to detect a stale edit. A version number is simpler to illustrate.

Figure 1 shows why an update must compare the caller’s version with the stored version. Both editors start with revision 3, but only the first accepted edit can change it to revision 4.

Course diagram
Figure 1. A stale edit is rejected without overwriting the first accepted change.

Extend the same module, rather than creating another application. Add version: number to Book and version: z.number().int().positive() to the schema’s object fields. Keep the refinement requiring an editable field. Replace applyBookEdit with:

export const applyBookEdit = (book: Book, edit: BookEdit): Book => {
if (edit.version !== book.version) throw new Error("STALE_VERSION");
return {
title: edit.title === undefined ? book.title : edit.title,
description: edit.description === undefined ? book.description : edit.description,
version: book.version + 1,
};
};

Update the existing test’s initial book to include version: 1, add version: 1 to each parsed edit, and include version: 2 in its expected complete result. The empty-edit assertion can now use { version: 1 }. Import assertThrows alongside assertEquals, then append:

Deno.test("a stale edit cannot replace the accepted value", () => {
const book = { title: "Reading together", description: "A guide", version: 1 };
const updated = applyBookEdit(book, BookEditSchema.parse({ version: 1, title: "Accepted" }));
assertEquals(updated.version, 2);
assertThrows(() => applyBookEdit(updated,
BookEditSchema.parse({ version: 1, title: "Stale" })), Error, "STALE_VERSION");
assertEquals(updated.title, "Accepted");
});

Run the same test command. This checks the version decision on values. An HTTP handler can map the stale outcome to 409. A pure test does not establish database concurrency safety: a real repository must compare and write as one atomic operation.

For a persisted book, this illustrative SQL expresses a conditional write. Do not run it in the practice database; there’s no books table or migration in this chapter.

UPDATE books
SET title = $1, version = version + 1
WHERE id = $2 AND version = $3
RETURNING id, title, version;

If no row is returned, do not report success. The resource may be absent or another edit may have changed its version; decide how the API distinguishes those outcomes. A 409 conflict with a stable code lets the UI retain the user’s text while fetching the new state.

A path does not enforce a relationship

Suppose the catalogue also stores book reviews. The following path and SQL illustrate a relationship check; they do not require adding a table to the practice application:

/api/books/12/reviews/90

The path claims that review 90 belongs to book 12. Looking up review 90 alone does not check that claim. The query or service must verify the relationship:

SELECT id, text
FROM reviews
WHERE id = $1 AND book_id = $2;

Otherwise a caller can change the book portion of the URL without changing which record they access. Hierarchical URLs organize an interface; they do not create security or referential integrity automatically.

Deletion is a domain decision

Choose what should happen to related records before exposing a delete operation. A foreign key can enforce that choice: CASCADE deletes dependent rows, RESTRICT prevents deletion while references remain, and SET NULL preserves related rows by clearing a nullable reference.

For example, an unpublished review draft could be removed with its book, while a borrowing record may need to retain its link to the book as history. In the latter case, the API can reject deletion with a conflict and the database can enforce the restriction. A rejected deletion should leave both records unchanged. See PostgreSQL’s foreign-key documentation for the available actions.

Another policy is to retain a row and mark it with deleted_at. Queries must explicitly filter that marker to hide the row. This preserves stored data and its references; it does not erase them.

Model lifecycle transitions as commands

Some operations express a transition: confirm a booking, publish an announcement, or cancel a reservation. Give these operations explicit contracts instead of allowing callers to assign any value to a status field.

For example, a booking might permit these decisions:

Current stateRequested operationResult
PendingConfirmChange to confirmed
CancelledConfirmReject and preserve the cancelled booking

A route such as POST /api/bookings/:id/confirm communicates the requested action. The service checks the current state and decides whether it is permitted; the route translates that outcome into an HTTP response. Ordinary field edits can also depend on state: a booking might allow changing its dates only while pending. Input validation alone cannot establish whether such an edit is allowed.

When an operation depends on both state and version, check both before changing the resource. For database-backed updates, include the required state alongside the expected version in the conditional write. A separate state check followed by an unconditional update leaves a gap in which another request can change the state. Chapters 7 and 8 develop database coordination further.

Create and edit versioned documents

0 / 20 points

Files in the editor

The editor below contains all 5 files for this exercise, although only one opens initially. Use the Files panel to expand api/src/ and its folders, then select a filename to open it. If the panel is collapsed, choose Show file tree in the editor toolbar.

Edit:

  • api/src/routes/document-routes.ts
  • api/src/services/document-service.ts

These supporting files are also in the editor. You may read them; keep their supplied implementations unchanged:

  • api/src/repositories/document-repository.ts
  • api/src/schemas/document-schema.ts
  • api/src/middleware/validation-errors.ts

Task

Complete api/src/routes/document-routes.ts and api/src/services/document-service.ts for a document API. Schemas, the synchronous in-memory repository, and error hooks are supplied under api/src/. Export the Hono instance as app; do not start a listener. Put HTTP handling in the route module and stored-state decisions in the service.

Contract

  • POST /api/documents accepts strict JSON {title, text?}. Trim the title to 1-80 characters; text has at most 4000 characters and defaults to empty. Return 201 {document} with its generated ID, normalized fields, draft status, and version 1.
  • GET /api/documents/:id returns 200 {document} or 404 NOT_FOUND.
  • PATCH /api/documents/:id accepts a positive integer version and at least one of title or text. Use the same field limits. Preserve omitted fields; reject null and unknown fields. Check existence, draft state, then expected version. Return {document: <changed document>} with 200 and increment its version once. Non-drafts give 409 NOT_DRAFT; stale versions give 409 STALE_VERSION.

Use the supplied Zod schemas with sValidator for paths and bodies, reading parsed values with c.req.valid. Validate the path first. IDs use complete positive decimal syntax without a leading zero, in the range 1-2147483647. Invalid syntax gives 400 INVALID_ID; invalid bodies give 400 INVALID_INPUT; malformed JSON gives 400 INVALID_JSON. Missing or unsupported JSON content type gives 400 INVALID_INPUT. Errors use {error:{code,message}}; wording is not graded. Rejections leave the document unchanged. Keep the synchronous check and write together without await.

Check and submit

Choose Submit solution below the editor to run the grading checks and read their feedback. All 5 editor files are submitted together, including supporting files and files that are not open as tabs. This editor has no Run control.

The service exports createDocument(value), getDocument(id), and editDocument(id, value); use DocumentRuleError(code) for known failures. @src/ maps to api/src/; Hono, Zod, and sValidator imports are supplied. Creation/read/validation and partial edits/version preservation each earn 10 points. Submission and comments are the next exercise; they are not required here.

A parent resource’s state does not automatically determine every operation on its children. A library might close a borrowing record to further edits while still allowing a librarian to add a follow-up note. The contract should distinguish changing the parent from adding a related record, and each operation should check its own rules and the parent relationship.

Tests should observe these effects: an accepted operation makes only the intended changes, and a rejected operation preserves both the parent and its related records. Checking a response status alone does not establish either property.

Submit documents and preserve comments

0 / 20 points

Files in the editor

The editor below contains all 5 files for this exercise, although only one opens initially. Use the Files panel to expand api/src/ and its folders, then select a filename to open it. If the panel is collapsed, choose Show file tree in the editor toolbar.

Edit:

  • api/src/routes/document-routes.ts
  • api/src/services/document-service.ts

These supporting files are also in the editor. You may read them; keep their supplied implementations unchanged:

  • api/src/repositories/document-repository.ts
  • api/src/schemas/document-schema.ts
  • api/src/middleware/validation-errors.ts

Task

The starter includes working create/read/edit routes and an in-memory store. POST /api/documents with JSON {title, text?} returns 201 {document}; title is trimmed to 1-80 characters, text defaults to empty and is at most 4000 characters. A document has id, title, text, status: "draft", and version: 1. GET /api/documents/:id returns 200 {document}. PATCH takes {version, title?, text?}, requires at least one editable field, and increments the version once on a successful draft edit. Keep those routes and add submission and comments in the existing route and service modules. The repository and schemas are supplied.

Contract

  • POST /api/documents/:id/submit accepts strict {version}. Check existence, draft state, then the positive expected version. Change draft to submitted, increment version once, and return 200 {document}. A later edit or submission gives 409 NOT_DRAFT. A stale draft version gives 409 STALE_VERSION.
  • POST /api/documents/:id/comments accepts strict {text}: trim to 1-500 characters. Accept comments on an existing document in either state. Return 201 {comment} containing generated id, documentId, and normalized text.
  • GET /api/documents/:id/comments/:commentId returns 200 {comment} only when the comment belongs to the requested document; otherwise return 404 NOT_FOUND.

Complete submitDocument(id, value), addComment(id, value), and getComment(id, commentId) in api/src/services/document-service.ts; connect them in api/src/routes/document-routes.ts, which exports app. Use the supplied sValidator middleware and DocumentRuleError(code). IDs must match [1-9][0-9]* and be at most 2147483647. Invalid IDs give 400 INVALID_ID; invalid bodies (including absent/unsupported JSON content type) give 400 INVALID_INPUT; malformed JSON gives 400 INVALID_JSON; missing documents/comments give 404 NOT_FOUND. Errors are JSON {error:{code,message}}, with no exact wording requirement. Validate both IDs before parsing a body. Rejected operations preserve the document and all comments. HTTP handlers call service decisions; the synchronous repository does not decide statuses. Keep each state check and write together without await. No deletion or database connection is required.

Check and submit

Choose Submit solution below the editor to run the grading checks and read their feedback. All 5 editor files are submitted together, including supporting files and files that are not open as tabs. This editor has no Run control.

@src/ maps to api/src/; the grader provides Hono, Zod, and sValidator. No listener is needed. Submission/version/error behavior and comment-parent/history behavior each earn 10 points. Consider how your solution handles a stale submission, a repeated submission, the wrong comment parent, and a comment added after submission.

Check Your Understanding

  1. Why is a resource API not necessarily a direct table wrapper?
  2. What must be defined before a client can interpret an omitted PATCH field?
  3. Why does a nested URL fail to establish the underlying relationship by itself?
  4. When would CASCADE fit a deletion policy, and when should deletion be rejected to preserve related history?
  5. When is an explicit command clearer than allowing arbitrary status updates?
  6. An update must match the stored version to succeed. How does that check prevent a stale edit, and what should a test inspect after rejection?