Skip to content
academia.sh

Lesson 09 / 34

URI Design

The path layout of resource addresses: the division of labor between path and query part, naming collections and singular resources, the clash between static and parameter segments, the parent segment of nested paths, and the canonical-address decision.

Contents

The previous lesson identified the resources: book, member, loan record, branch, and a member status with no table of its own. Its examples used addresses like /books, /loans/1, and /members/U-1001/status without questioning them. Yet each one was a decision, and every decision had an alternative.

This lesson opens those decisions up: which part of an address carries identity and which sets the view, how a collection name is written, which path counts as valid when a loan record has two, and what a trailing slash changes. All of it produces measurable results once a matcher table runs.

The Division of Labor Between Path and Query

The How the Internet Works course introduced the parts of an address: scheme, host, path, query, and fragment. In API design there is a sharp division of labor among these parts.

The path carries a resource’s identity. /books/978-0131103627 points to a specific book; that book is not found at any other path. The query part sets the resource’s view. /loans?status=open is not a new resource, it is a filtered view of the loan collection. The same division holds for sorting, pagination, and field selection; all of these are the subject of later lessons and all of them live in the query part.

The practical consequence of this division is this: the path carries no verb. Addresses like /returnLoan, /searchBook, /removeMember name the operation, not the resource. What says what to do is the method; the path only says what to do it to. The next lesson takes up in detail how methods take on that job.

Collection names are written in the plural: /books, /members, /loans. The reasoning is not aesthetic. When /book/978-0131103627 is used alongside /books, every address-building spot on the client has to remember separately whether it is singular or plural. One rule — the collection is plural, its member sits under it — removes that burden. Which rule is chosen matters less than that it never changes.

The Clash Between a Static Segment and a Parameter Segment

Address patterns form a table, and the table is scanned in order. When the order is wrong, the error is silent. The matcher below reports which pattern matched inside the response, so the decision becomes visible.

// path-matcher.mjs — matcher that tries address patterns in order; reports the matched pattern
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

// Order matters: the list is scanned top to bottom, first match wins.
const PATTERNS = [
  ["/books/{isbn}", /^\/books\/([^/]+)$/, (e) =>
    db.prepare("SELECT * FROM book WHERE isbn = ?").get(e[1]) ?? null],
  ["/books/search",    /^\/books\/search$/, () =>
    db.prepare("SELECT isbn, title FROM book WHERE author LIKE 'K%'").all()],
  ["/members/{code}/loans/{id}", /^\/members\/([^/]+)\/loans\/(\d+)$/, (e) =>
    db.prepare("SELECT * FROM loan WHERE id = ?").get(Number(e[2])) ?? null],
];

const server = createServer((request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  for (const [name, pattern, handler] of PATTERNS) {
    const match = pattern.exec(path);
    if (match) return respond(response, 200, { matched: name, result: handler(match) });
  }
  respond(response, 404, { error: "path_not_found" });
});

server.listen(8474, "127.0.0.1", () => console.log("path matcher 127.0.0.1:8474"));

The schema is the schema.sql file set up in the previous lesson; the script rebuilds the database on every run.

rm -f library.db && sqlite3 library.db < schema.sql
node path-matcher.mjs & server=$!
sleep 0.4

request() { printf '%-34s %s\n' "$1" "$(curl -sS "http://127.0.0.1:8474$1")"; }
request "/books/978-0131103627"
request "/books/search"
request "/members/U-1002/loans/1"
request "/books/978-0131103627/"
request "/Books/978-0131103627"

kill $server
path matcher 127.0.0.1:8474
/books/978-0131103627              {"matched":"/books/{isbn}","result":{"isbn":"978-0131103627","title":"The C Programming Language","author":"Ritchie","year":1978,"branch":"S-01"}}
/books/search                      {"matched":"/books/{isbn}","result":null}
/members/U-1002/loans/1            {"matched":"/members/{code}/loans/{id}","result":{"id":1,"member":"U-1001","isbn":"978-0262033848","issuedAt":"2026-03-01","return":null}}
/books/978-0131103627/             {"error":"path_not_found"}
/Books/978-0131103627              {"error":"path_not_found"}

Five lines show four separate problems.

The second line is the clash. /books/search never reached the search pattern; the parameter segment caught it first and treated search as an ISBN, looking it up in the database. The result came back null — the client concludes not that the search endpoint is broken, but that the book does not exist. The specificity criterion defined in the Application Architecture course applies here too: static-segment patterns should be tried before parameter-segment ones. If you do not want to rely on order, there is an alternative — move the search criterion into the collection address’s query part: /books?author=Knuth. In this form no segment is left to clash.

The third line is more serious. /members/U-1002/loans/1 returned member U-1001’s loan record. The pattern captured the parent segment but the handler never used it, querying with only . The nested path falls into being decoration: the address carries a claim of ownership, and the server never checks that claim.

The fourth and fifth lines are a canonical-form problem. The trailing slash and the capital letter each caused the path to fail to match. Both came back as “not found,” even though the resource is right there.

Validating the Parent Segment and Canonical Form

The matcher below applies all three fixes together: the static segment moves to the front, the parent segment joins the query, and a trailing slash redirects to the canonical address.

// path-matcher-2.mjs — matcher that tries specificity first and validates the parent segment
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

// Static-segment patterns first; the parameter segment stays last.
const PATTERNS = [
  ["/books/search",    /^\/books\/search$/, () =>
    [200, db.prepare("SELECT isbn, title FROM book WHERE author LIKE 'K%'").all()]],
  ["/books/{isbn}", /^\/books\/([^/]+)$/, (e) => {
    const b = db.prepare("SELECT * FROM book WHERE isbn = ?").get(e[1]);
    return b ? [200, b] : [404, { error: "book_not_found" }];
  }],
  ["/members/{code}/loans/{id}", /^\/members\/([^/]+)\/loans\/(\d+)$/, (e) => {
    // Saying the parent segment is decoration is wrong: no path if the record is not that member's.
    const l = db.prepare("SELECT * FROM loan WHERE id = ? AND member = ?").get(Number(e[2]), e[1]);
    return l ? [200, l] : [404, { error: "loan_not_found" }];
  }],
];

const server = createServer((request, response) => {
  let path = new URL(request.url, "http://127.0.0.1").pathname;
  // Canonical form: a trailing slash collapses to a single address.
  if (path.length > 1 && path.endsWith("/")) {
    response.writeHead(308, { location: path.slice(0, -1) });
    return response.end();
  }
  for (const [name, pattern, handler] of PATTERNS) {
    const match = pattern.exec(path);
    if (match) {
      const [status, result] = handler(match);
      return respond(response, status, { matched: name, result });
    }
  }
  respond(response, 404, { error: "path_not_found" });
});

server.listen(8475, "127.0.0.1", () => console.log("fixed matcher 127.0.0.1:8475"));
rm -f library.db && sqlite3 library.db < schema.sql
node path-matcher-2.mjs & server=$!
sleep 0.4

request() { printf '%-30s %3s  %s\n' "$1" \
  "$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:8475$1")" \
  "$(curl -sS "http://127.0.0.1:8475$1")"; }
request "/books/search"
request "/members/U-1002/loans/1"
request "/members/U-1001/loans/1"
request "/books/978-0131103627/"

# Same address, following the redirect:
printf '%-30s %3s  %s\n' "/books/978-0131103627/ (-L)" \
  "$(curl -sSL -o /dev/null -w '%{http_code}' http://127.0.0.1:8475/books/978-0131103627/)" \
  "$(curl -sSL http://127.0.0.1:8475/books/978-0131103627/)"

kill $server
fixed matcher 127.0.0.1:8475
/books/search                  200  {"matched":"/books/search","result":[{"isbn":"978-0201896831","title":"The Art of Computer Programming"}]}
/members/U-1002/loans/1        404  {"matched":"/members/{code}/loans/{id}","result":{"error":"loan_not_found"}}
/members/U-1001/loans/1        200  {"matched":"/members/{code}/loans/{id}","result":{"id":1,"member":"U-1001","isbn":"978-0262033848","issuedAt":"2026-03-01","return":null}}
/books/978-0131103627/         308
/books/978-0131103627/ (-L)    200  {"matched":"/books/{isbn}","result":{"isbn":"978-0131103627","title":"The C Programming Language","author":"Ritchie","year":1978,"branch":"S-01"}}

Read the second and third lines together: the same loan record gives two different results with two different parent segments. The parent segment is now a condition, not a claim. This is a correctness decision, not a security one; authorization is a separate topic, covered in the Authentication and Authorization course. The gain here is that whatever the address claims, the server verifies it.

The fourth line shows the redirect: the trailing-slash address turns into the canonical one with a 308, body empty. The fifth line makes the same request following the redirect and reaches the resource with a 200. This kind of redirect preserves the method and the body — a distinction that becomes decisive whenever a write request needs redirecting.

The capital-letter problem was deliberately left unfixed. A path is case-sensitive and should stay that way; accepting /Books as well would mean producing an unbounded number of addresses for the same resource. The right decision is to document one spelling and reject the rest.

Separators Inside an Identity

The character that separates path segments is the slash; if an identity value contains that character, the address breaks.

# What happens when a path separator appears inside an identity?
node -e '
const code = "S-01/Central";
console.log("raw      :", "/branches/" + code);
console.log("encoded  :", "/branches/" + encodeURIComponent(code));
const url = new URL("http://127.0.0.1/branches/" + encodeURIComponent(code));
console.log("decoded  :", decodeURIComponent(url.pathname.split("/")[2]));
'
raw      : /branches/S-01/Central
encoded  : /branches/S-01%2FCentral
decoded  : S-01/Central

In the raw form, a single identity gets split into two segments and the matcher mistakes it for a sub-resource. In the encoded form, the segment keeps its integrity. This is the client’s responsibility and it should be written into the contract. The more robust path is restricting the identity’s format from the start: identities that contain no separator, no space, and no capital letter never raise this problem at all.

The Canonical-Address Decision

A resource can be reached by more than one path: /loans/1 and /members/U-1001/loans/1 return the same record. Both can be valid, but one must be chosen as the canonical address. The selection criterion is this: if the resource has an independent identity, the canonical address is the shorter one. A loan record has its own identity, so /loans/1 is canonical; the nested form is a shortcut used while browsing a member’s records.

Settling on a canonical address does work in three places. When a response links to a resource, which form to write is settled. When a new record is created, the address to put in the Location header is settled. When the address is used as a cache key, the same resource is not stored under two different keys.

Depth is bounded by this same criterion. A four-level path like /branches/S-01/members/U-1001/loans/1 needs four separate checks, and all four have to run on every request. As a rule, one level is enough: the resource that owns the collection, then the collection, then its member. Needing more depth than that is a sign that the relationship is better written as a query criterion instead.

Summary

  • The path carries a resource’s identity, the query part sets its view; the path carries no verb, the method says what to do.
  • Collections are named in the plural and this decision does not change throughout the course; consistency matters more than which rule was chosen.
  • Static-segment patterns must be tried before parameter-segment ones; otherwise the address /books/search is mistaken for an ISBN and the error looks like “not found.”
  • A nested path’s parent segment is a condition, not decoration: if the record does not belong to that member, the response must be a 404.
  • Every resource gets one settled canonical address; variants like a trailing slash are redirected to it with a 308, and capitalization variants are never accepted at all.
  • If identity values contain the path separator, the address must be encoded; the more robust fix is restricting the identity’s format from the start.

Next Step

Now that addresses are settled, the question of where the verb comes from remains. This lesson said the path layout carries no verb and moved on with “the method says what to do.” But choosing a method is not choosing a name: every method has properties that the client and the components in between rely on. The assumption that a read does not change data, and that sending the same request twice produces a single effect, both come from here. The next lesson defines these properties and tests each one by counting rows in the database.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close