Skip to content
academia.sh

Lesson 17 / 34

Connection-Based Responses

The response carrying navigation and action links within itself: walking pages by following only the next link, action links that open and close based on a resource's state, and measuring the client's address-building burden.

Contents

Across nine lessons, the client was assumed to build addresses itself. It knew the collection address, the page cursor, the field list, even the address of a newly created record. This has a cost: the contract’s shape gets embedded into the client. If the cursor parameter’s name changes, if the method used for a return changes, or if an operation becomes conditional, every client has to be updated separately.

This lesson loosens that dependency. The response carries within itself the addresses the client will use in its next request; the client knows only the entry address and reads the rest. The second, more important consequence is this: which operations a resource is open to is also read from the response. A loan record that has been returned no longer offers a return link.

A link must carry two pieces of information: which address to go to and with which method. A link that carries only an address makes read and write indistinguishable. Action links need a third piece: which fields are expected in the body.

Which links a response carries depends on the resource’s kind. A collection response carries navigation links: itself, and the next page if there is one. A singular resource response carries its own address, the addresses of related resources, and the operations applicable at that moment.

// link-server.mjs — a service whose responses carry navigation and action links inside them
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const readBody = (request) => new Promise((resolve) => {
  let data = ""; request.on("data", (p) => (data += p));
  request.on("end", () => resolve(data ? JSON.parse(data) : {}));
});
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

// A record's current state determines which operations are open.
const recordView = (o) => ({
  id: o.id, member: o.member, isbn: o.isbn, issuedAt: o.issuedAt, return: o.return,
  links: {
    self:   { path: `/loans/${o.id}`, method: "GET" },
    member: { path: `/members/${o.member}`, method: "GET" },
    ...(o.return === null
      ? { return: { path: `/loans/${o.id}`, method: "PATCH", fields: ["return"] } }
      : {}),
  },
});

const server = createServer(async (request, response) => {
  const url = new URL(request.url, "http://127.0.0.1");
  const single = /^\/loans\/(\d+)$/.exec(url.pathname);
  const member = /^\/members\/([\w-]+)$/.exec(url.pathname);

  if (request.method === "GET" && member) {
    const m = db.prepare("SELECT * FROM member WHERE code = ?").get(member[1]);
    return m ? respond(response, 200, m) : respond(response, 404, { error: "member_not_found" });
  }

  if (request.method === "GET" && url.pathname === "/loans") {
    const size = Math.min(Number(url.searchParams.get("size") ?? 3), 50);
    const cursor = url.searchParams.get("cursor");
    const rows = cursor === null
      ? db.prepare("SELECT * FROM loan ORDER BY id LIMIT ?").all(size)
      : db.prepare("SELECT * FROM loan WHERE id > ? ORDER BY id LIMIT ?").all(Number(cursor), size);
    const last = rows.at(-1);
    // The next page is reported only if it actually exists.
    const remaining = last
      ? db.prepare("SELECT COUNT(*) AS n FROM loan WHERE id > ?").get(last.id).n : 0;
    return respond(response, 200, {
      data: rows.map(recordView),
      links: {
        self: { path: `/loans?size=${size}`, method: "GET" },
        ...(remaining > 0
          ? { next: { path: `/loans?size=${size}&cursor=${last.id}`, method: "GET" } }
          : {}),
      },
    });
  }

  if (request.method === "GET" && single) {
    const o = db.prepare("SELECT * FROM loan WHERE id = ?").get(Number(single[1]));
    return o ? respond(response, 200, recordView(o)) : respond(response, 404, { error: "loan_not_found" });
  }

  if (request.method === "PATCH" && single) {
    const g = await readBody(request);
    const id = Number(single[1]);
    if ("return" in g) db.prepare("UPDATE loan SET return = ? WHERE id = ?").run(g.return, id);
    return respond(response, 200, recordView(db.prepare("SELECT * FROM loan WHERE id = ?").get(id)));
  }

  respond(response, 404, { error: "path_not_found" });
});

server.listen(8484, "127.0.0.1", () => console.log("link server 127.0.0.1:8484"));

Two details matter in particular. The next link is generated only if a record genuinely remains, so the client never needs to write a rule like “stop if an empty page arrives.” The return link is generated only while the record’s return field is empty; the business rule ends up embedded inside the representation.

A Client That Knows Only the Entry Address

The client on the other side writes a single address and builds no other.

// navigator.mjs — client that knows only the entry address, reads the rest from responses
const BASE = "http://127.0.0.1:8484";
const ENTRY = "/loans?size=3";
const addressesBuilt = 1;   // only the entry address is written on the client

const call = async (link, body) => {
  const options = { method: link.method };
  if (body) {
    options.headers = { "content-type": "application/json" };
    options.body = JSON.stringify(body);
  }
  return (await fetch(BASE + link.path, options)).json();
};

// 1) Pages are walked only by following the "next" link.
let page = await (await fetch(BASE + ENTRY)).json();
let pageCount = 1;
const records = [];
while (true) {
  records.push(...page.data);
  console.log(`page ${pageCount}: ${page.data.map((r) => r.id).join(", ")}` +
              `   next: ${page.links.next?.path ?? "none"}`);
  if (!page.links.next) break;
  page = await call(page.links.next);
  pageCount++;
}
console.log(`total ${records.length} records, ${pageCount} pages`);

// 2) The first record with an open action link is found and that link is followed.
const open = records.find((r) => r.links.return);
console.log(`\nbefore action id=${open.id} links: ${Object.keys(open.links).join(", ")}`);
const updated = await call(open.links.return, { return: "2026-03-15" });
console.log(`after action  id=${updated.id} links: ${Object.keys(updated.links).join(", ")}`);

// 3) The record's own address is also read from the response.
const reread = await call(updated.links.self);
console.log(`reread id=${reread.id} return=${reread.return} ` +
            `links: ${Object.keys(reread.links).join(", ")}`);
console.log(`\naddresses built on the client: ${addressesBuilt}`);
# Seven loan records; the client runs the entire flow knowing only the entry address.
rm -f library.db && sqlite3 library.db < schema.sql && sqlite3 library.db < catalog.sql
sqlite3 library.db <<'SQL'
DELETE FROM loan;
INSERT INTO loan (id, member, isbn, issuedAt, return) VALUES
  (1,'U-1001','K-01','2026-02-01','2026-02-14'),
  (2,'U-1002','K-02','2026-02-05','2026-02-19'),
  (3,'U-1001','K-03','2026-03-01',NULL),
  (4,'U-1002','K-04','2026-03-02',NULL),
  (5,'U-1001','K-05','2026-03-03',NULL),
  (6,'U-1002','K-06','2026-03-04',NULL),
  (7,'U-1001','K-07','2026-03-05',NULL);
SQL
node link-server.mjs & server=$!
sleep 0.4

node navigator.mjs
echo "--- raw view of a single record ---"
curl -sS http://127.0.0.1:8484/loans/4; echo

kill $server
link server 127.0.0.1:8484
page 1: 1, 2, 3   next: /loans?size=3&cursor=3
page 2: 4, 5, 6   next: /loans?size=3&cursor=6
page 3: 7   next: none
total 7 records, 3 pages

before action id=3 links: self, member, return
after action  id=3 links: self, member
reread id=3 return=2026-03-15 links: self, member

addresses built on the client: 1
--- raw view of a single record ---
{"id":4,"member":"U-1002","isbn":"K-04","issuedAt":"2026-03-02","return":null,"links":{"self":{"path":"/loans/4","method":"GET"},"member":{"path":"/members/U-1002","method":"GET"},"return":{"path":"/loans/4","method":"PATCH","fields":["return"]}}}

Three pages, seven records, one written address. The word cursor never appears in the client’s code; how the cursor is encoded, what parameter name carries it, even whether pagination is cursor-based or offset-based, is none of the client’s concern. When the server changes that decision, the client does not change.

The second section is more interesting. Before the return operation, return was among the record’s links; after the operation, it was gone. The client does not answer “can this record be returned” by looking at a field’s value and applying its own rule; it looks at whether the link exists. The rule lives on the server, and it lives in exactly one place.

Its counterpart on the interface side is direct: the return button’s visibility is tied to whether the return link exists. When the business rule changes — say, members with a fine now have to return books at a branch — the server stops generating the link and the button disappears on its own.

The Raw Representation and the Contract’s Boundary

The output’s last line gives a single record’s raw representation. Links are gathered into a separate field of the body, not mixed in with the record’s own fields. This separation must be kept, or the name return would be both a date field and a link name.

Connection-based design has a cost too, and it should not be overstated.

The body grows. Every record in a seven-record list carrying three links dedicates a significant portion of the body to links. The criterion from the Partial Response lesson applies here too: the link block should be closeable through field selection.

The client’s dependency does not disappear, it relocates. The client is now dependent not on the address format but on link names. The name next changing is just as breaking as the cursor parameter’s name changing. The gain is that link names change less often than address formats do.

Generating links costs the server work. The collection endpoint above runs an extra count query just to find out whether a next page exists. This count is expensive on large collections; the common fix is fetching one more record than the page size and deciding a next page exists if that extra one comes back.

Finally, not all links carry the same weight of necessity. Without navigation links, the client has to know the pagination format; without action links, it has to reimplement the business rules. The first pays for itself in almost every service; the second is valuable in services where business rules are complex and change often.

Summary

  • A link carries at least two pieces of information: address and method; action links also report the fields expected in the body.
  • A collection response carries navigation links, a singular resource response carries related-resource and action links; links are gathered into a separate field of the body.
  • In the measurement, the client walked three pages and collected seven records by writing a single address; the cursor parameter’s name never appeared in the client’s code.
  • Action links are generated from a resource’s current state: a returned record carries no return link, so the business rule lives in exactly one place.
  • The next-page link is generated only while a record remains; the client never needs to write a rule to stop on an empty page.
  • Its cost is the body’s growth, the server’s burden of generating links, and the dependency shifting from address format to link names.

Next Step

Resource and contract design is complete: resources were identified, their addresses settled, methods and status codes mapped, body format decided, collections paginated and filtered, write requests guarded against a repeated effect, responses came to carry their own navigation information. All of this holds when things go right. In the error case, though, every endpoint still makes up its own body: an error field in one place, field and reason in another, just a code somewhere else. The next topic starts from here, and its first lesson settles error responses into a single structure: a standard problem-details format, which fields it carries, and how the client uses it.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close