Skip to content
academia.sh

Lesson 03 / 34

REST Principles

The set of constraints behind the resource-based style: demonstrating statelessness by testing the same request in two separate processes, measuring the uniform interface with a client that does not know the resource name, the effect of cacheability with an entity tag on bytes carried, and how a layered system follows from these constraints.

Contents

The previous lesson measured the resource-based style and found it the most expensive option: five requests, forty-seven carried fields, 15% used. Yet most lending services are written in this style. The contradiction comes from properties the measurement does not capture.

Those properties are the consequences of a set of constraints. REST (representational state transfer) is not a protocol or a library; it is the name of the constraints an interface can conform to. This lesson tests three of these constraints — statelessness, uniform interface, and cacheability — by running them.

Constraints and Resource Orientation

REST’s fundamental unit is the resource: every concept that can be referred to by a name. In the library service, a book, a member, a loan, and a shelf are each a resource. What the client sees is not the resource itself but a representation: a copy of that resource in a specific format. The same resource can have a JSON representation as well as an HTML representation; both describe the same thing.

The constraints build on top of this unit:

  • Client–server separation: the two sides evolve separately; the only bond between them is the interface.
  • Statelessness: every request is self-contained; the server does not keep a context belonging to the client between requests.
  • Cacheability: the response itself reports whether it can be stored.
  • Uniform interface: resources are named the same way and processed with the same methods.
  • Layered system: the client does not have to know whether the party on the other side is the final server or an intermediate layer.

The following server carries two designs side by side, one that applies these constraints and one that violates them; because it takes its port from outside, two copies of it can be run together.

// server.mjs — the port is supplied from outside so the same code can run as two copies:
// node server.mjs 8461
import { createServer } from "node:http";
import { createHash, randomUUID } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8461);
const RESOURCES = {
  books:   [{ id: "978-0262033848", title: "Introduction to Algorithms" }, { id: "978-0201896831", title: "The Art of Computer Programming" },
            { id: "978-0131103627", title: "The C Programming Language" }, { id: "978-0596007126", title: "Head First" }],
  members: [{ id: "u1", name: "Alice Kane" }, { id: "u2", name: "Ben Ortiz" }],
  loans:   [{ id: "o11", member: "u1", book: "978-0262033848" }],
  shelves: [{ id: "R-12", floor: 2 }, { id: "R-03", floor: 1 }],   // resource added later
};
const cursors = new Map();          // state kept on the server side: exists only in this copy

const json = (res, code, body, tag) => {
  const text = JSON.stringify(body);
  res.setHeader("Content-Type", "application/json; charset=utf-8");
  res.setHeader("X-Process", process.pid);
  if (tag) res.setHeader("ETag", tag);
  res.setHeader("Content-Length", Buffer.byteLength(text));
  res.writeHead(code).end(text);
};
const tagOf = (body) =>
  `"${createHash("sha256").update(JSON.stringify(body)).digest("hex").slice(0, 16)}"`;

createServer((req, res) => {
  res.sendDate = false;
  const url = new URL(req.url, "http://local");
  const [, first, second] = url.pathname.split("/");

  if (first === "") return json(res, 200, { resources: Object.keys(RESOURCES) });

  // Stateful pagination: the cursor is kept on the server, the request carries only the cursor id.
  if (first === "cursor" && req.method === "POST") {
    const id = randomUUID().slice(0, 8);
    cursors.set(id, 0);
    return json(res, 201, { cursor: id });
  }
  if (first === "cursor" && req.method === "GET") {
    if (!cursors.has(second)) return json(res, 404, { error: "unknown_cursor" });
    const offset = cursors.get(second);
    cursors.set(second, offset + 2);
    return json(res, 200, { offset, page: RESOURCES.books.slice(offset, offset + 2) });
  }

  // Uniform interface: every resource is named the same way, read with the same methods.
  const collection = RESOURCES[first];
  if (!collection) return json(res, 404, { error: "resource_not_found" });
  if (second) {
    const entry = collection.find((k) => k.id === second);
    if (!entry) return json(res, 404, { error: "not found" });
    const tag = tagOf(entry);
    if (req.headers["if-none-match"] === tag) {         // conditional request: no body is sent
      res.setHeader("ETag", tag);
      res.setHeader("X-Process", process.pid);
      return res.writeHead(304).end();
    }
    return json(res, 200, entry, tag);
  }
  // Stateless pagination: the page information is inside the request.
  const offset = Number(url.searchParams.get("offset") ?? 0);
  const quantity = Number(url.searchParams.get("quantity") ?? collection.length);
  return json(res, 200, { offset, quantity, entries: collection.slice(offset, offset + quantity) });
}).listen(PORT, "127.0.0.1", () => console.log(`copy :${PORT} pid=${process.pid}`));

The measurement client consists of three parts and distributes requests alternately between the two copies.

// measure.mjs — tests three principles: statelessness, uniform interface, cacheability
import { Agent, request } from "node:http";
import { connect } from "node:net";

let received = 0;
const agent = new Agent({ keepAlive: true });
agent.createConnection = (s) => { const b = connect(s); b.on("data", (p) => (received += p.length)); return b; };
const sendRequest = (port, path, option = {}) => new Promise((resolve, reject) => {
  const r = request({ agent, host: "127.0.0.1", port, path,
    method: option.method ?? "GET", headers: option.headers ?? {} }, (y) => {
    let m = ""; y.on("data", (p) => (m += p));
    y.on("end", () => resolve({ status: y.statusCode, tag: y.headers.etag, proc: y.headers["x-process"],
                                body: m ? JSON.parse(m) : null }));
  });
  r.on("error", reject); r.end();
});

// 1) STATELESSNESS — does the same request give the same response in two separate processes?
console.log("-- 1) statelessness: requests are distributed alternately between the two copies --");
const { body: created } = await sendRequest(8461, "/cursor", { method: "POST" });
for (const port of [8461, 8462, 8461]) {
  const y = await sendRequest(port, `/cursor/${created.cursor}`);
  console.log(`  stateful    :${port} pid=${y.proc} -> ${y.status} ${JSON.stringify(y.body).slice(0, 62)}`);
}
for (const port of [8461, 8462, 8461]) {
  const y = await sendRequest(port, "/books?offset=2&quantity=2");
  console.log(`  stateless   :${port} pid=${y.proc} -> ${y.status} ${JSON.stringify(y.body).slice(0, 62)}`);
}

// 2) UNIFORM INTERFACE — does the same client code read every resource without knowing its name?
console.log("\n-- 2) uniform interface: resource names are learned from the server --");
const { body: root } = await sendRequest(8461, "/");
for (const name of root.resources) {
  const list = await sendRequest(8461, `/${name}`);
  const first = list.body.entries[0];
  const single = await sendRequest(8461, `/${name}/${first.id}`);
  console.log(`  ${name.padEnd(9)} list=${list.status} quantity=${list.body.entries.length}` +
              `  single=${single.status} tag=${single.tag}`);
}
const missing = await sendRequest(8461, "/branches");
console.log(`  branches  list=${missing.status} ${JSON.stringify(missing.body)}`);

// 3) CACHEABILITY — how much does a conditional request cut the bytes carried?
console.log("\n-- 3) cacheability: the same record requested ten times --");
const PATH = "/books/978-0262033848";
received = 0;
for (let i = 0; i < 10; i++) await sendRequest(8461, PATH);
const unconditional = received;
received = 0;
const first = await sendRequest(8461, PATH);
for (let i = 0; i < 9; i++) await sendRequest(8461, PATH, { headers: { "If-None-Match": first.tag } });
const conditional = received;
const final = await sendRequest(8461, PATH, { headers: { "If-None-Match": first.tag } });
console.log(`  unconditional  10 requests -> ${unconditional} B`);
console.log(`  conditional    10 requests -> ${conditional} B  (final response ${final.status}, gain %${
  Math.round((100 * (unconditional - conditional)) / unconditional)})`);
agent.destroy();
node server.mjs 8461 & a=$!
node server.mjs 8462 & b=$!
for k in 8461 8462; do
  curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null "http://127.0.0.1:$k/"
done
node measure.mjs
kill $a $b
copy :8461 pid=24387
copy :8462 pid=24388
-- 1) statelessness: requests are distributed alternately between the two copies --
  stateful    :8461 pid=24387 -> 200 {"offset":0,"page":[{"id":"978-0262033848","title":"Introducti
  stateful    :8462 pid=24388 -> 404 {"error":"unknown_cursor"}
  stateful    :8461 pid=24387 -> 200 {"offset":2,"page":[{"id":"978-0131103627","title":"The C Prog
  stateless   :8461 pid=24387 -> 200 {"offset":2,"quantity":2,"entries":[{"id":"978-0131103627","ti
  stateless   :8462 pid=24388 -> 200 {"offset":2,"quantity":2,"entries":[{"id":"978-0131103627","ti
  stateless   :8461 pid=24387 -> 200 {"offset":2,"quantity":2,"entries":[{"id":"978-0131103627","ti

-- 2) uniform interface: resource names are learned from the server --
  books     list=200 quantity=4  single=200 tag="c80ac47429ab852d"
  members   list=200 quantity=2  single=200 tag="79aa82aae4e5fc1c"
  loans     list=200 quantity=1  single=200 tag="6f2d536c9fff5239"
  shelves   list=200 quantity=2  single=200 tag="bb5d3afab6485c63"
  branches  list=404 {"error":"resource_not_found"}

-- 3) cacheability: the same record requested ten times --
  unconditional  10 requests -> 2370 B
  conditional    10 requests -> 1317 B  (final response 304, gain %44)

Process ids change on every run. Because the id is carried in the X-Process header, its digit count shifts the total bytes by a few, but the ratio does not change. Byte counts also depend on the header set the runtime writes.

Statelessness: The Request Being Self-Contained

The first part tests two pagination designs under the same condition: requests are distributed alternately between the two server copies. This is a small example of the multi-copy setup behind the reverse proxy built in the previous course.

In the stateful design, the cursor sits in the server’s memory. The first request lands on 8461 and runs. When the second request lands on 8462, it gets a 404 because the same cursor is not there. When the third request lands on 8461 again, the second page comes back — meaning the error the second copy gave has also broken the cursor’s state: the client has skipped a page without ever seeing it.

In the stateless design, the page information is inside the request. The same request went three times, to two separate processes, and gave the same body all three times. The server’s identity has no effect on the response whatsoever.

The cost of statelessness is resending the context on every request: identity information, pagination position, and filter criteria are carried each time. What is gained in return is that server copies become interchangeable. When a copy goes down, the request can be routed to another copy, the number of copies can be scaled up under load, and none of them has to carry the client’s session.

Uniform Interface: The Same Client Code, Four Resources

The client in the second part does not know any resource name. It reads the resource list from the root address, then makes the same two requests for each resource: fetch the collection, fetch the single record by the first entry’s id. All four were read with the same code; not a single line changed in the client for the shelves resource that was added later.

This is the concrete counterpart of the uniform interface: resources are named by addresses, the same methods mean the same thing on every resource, and responses are self-descriptive — how to read the body is understood from the content type header, and the record’s version from the entity tag. A resource that does not exist is met the same way too, with a 404.

The cost of this is a loss of flexibility. When an operation does not sit on a single resource — something like “lend these three books at once and recompute the overdue fine” — the uniform interface does not accommodate that operation naturally. Such operations are either turned into resources themselves, or the interface moves closer to a remote procedure call at that point.

Cacheability: Requesting the Same Record Ten Times

The third part requests the same book ten times. In this run, the unconditional requests carry 2370 bytes. In the second round, the client saves the entity tag from the first response and attaches it to the next nine requests with the If-None-Match header; the server sees the record has not changed and returns a bodyless 304. The bytes carried drop to 1317 — a 44% reduction.

The entity tag was introduced in the Static File Serving lesson; the difference here is that the same mechanism is applied to a computed record. The source of the gain is the body not being sent; the headers are still carried, so the reduction cannot reach 100%. The ratio rises as the record grows: the record in this measurement carries only two fields.

Layered System

All three measurements together make a fourth constraint possible too. If the response depends only on the request and reports its own cacheability, a layer can be placed between the client and the server: a reverse proxy, a cache, or a router. The client does not know whether the party on the other side is the final server or an intermediate layer, and it does not need to.

The reverse proxy built in the Web Server Concept lesson of the previous course is exactly this. That lesson showed that the proxy also brings a loss of information; the additional observation here is that the proxy’s ability to step in depends on the interface being stateless and cacheable. In a design that keeps sessions on the server, the proxy is forced to send the request to the specific copy that holds the session, not to just any copy.

Summary

  • REST is not a protocol but a set of constraints a resource-oriented interface conforms to; the fundamental unit is the resource, and what gets carried is the resource’s representation.
  • Statelessness was seen directly in the measurement: the cursor kept on the server could not be found on the second copy and a page was skipped; page information carried inside the request gave the same response on both copies.
  • The uniform interface means a single client code that does not know the resource name can read all four resources; the resource added later required no change in the client.
  • A conditional request cut the bytes carried by about 44% when the same record was fetched ten times; the gain comes from the body not being sent, and it cannot be complete because the headers keep being carried.
  • A layered system depends on these two constraints: a proxy or a cache can be placed in between only when the response depends solely on the request and is storable.

Next Step

REST’s constraints lean the interface on the rules of the network: addresses, methods, status codes, headers. In return, the contract stays textual and loose — a field’s type or a method’s signature can only be known by reading the documentation. There is an approach that does the opposite: writing the contract first in a separate schema file, generating both the client and server side from that schema, and using the wire in binary form instead of text. The next lesson builds this approach: it writes a schema file, generates a validator from it, writes its own binary framing, and compares the byte count of the same record in binary and text form.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close