Resources and URIs
A URI names a thing, the method says what to do with it. Nouns, collections and items, nesting only for real containment, and actions that are not CRUD.
After this lesson you can
- Design a URI scheme from collections and items rather than verbs
- Decide when nesting a resource under another is justified
- Model an action such as "cancel" without putting a verb in the path
A REST API is organised around resources: the things a client cares
about — an order, a customer, an invoice, the list of a customer's
orders. Each resource is identified by a URI, and the HTTP method says
what the client wants done to it. The URI is the noun; the method is the
verb. Put the verb in the URI as well and you have two places that say
what is happening, which is how APIs end up with both
POST /deleteOrder and DELETE /orders/7.
Collections and items
Almost every resource comes in two shapes:
GET /orders # the collection: list, filter, page
POST /orders # add to the collection; the server picks the id
GET /orders/7 # one item
PATCH /orders/7 # change part of it
DELETE /orders/7 # remove it
Conventions that most public APIs agree on: plural nouns for collections
(/orders, not /order), lowercase with hyphens between words
(/line-items), and no file extension — the format is negotiated with
the Accept and Content-Type headers, not baked into the path. These
are conventions, not rules of HTTP, but a reviewer will notice an API
that breaks them inconsistently.
Two facts that are rules: per RFC 3986 the scheme and host of a URI are
case-insensitive but the path is case-sensitive, so /Orders and
/orders are different URIs; and /orders and /orders/ are different
URIs too. Pick one form and redirect or reject the other — do not let
both silently work for some routes and not others.
Try it
The router below maps a method and a path onto a handler. The same URI reaches different handlers depending on the method, and a path that exists but does not support the method is a different failure from a path that does not exist at all.
const routes = [ ["GET", "/orders", "listOrders"], ["POST", "/orders", "createOrder"], ["GET", "/orders/{id}", "getOrder"], ["PATCH", "/orders/{id}", "updateOrder"], ["DELETE", "/orders/{id}", "deleteOrder"], ["POST", "/orders/{id}/cancellation", "cancelOrder"],]; function matchTemplate(template, path) { const t = template.split("/").filter(Boolean); const p = path.split("/").filter(Boolean); if (t.length !== p.length) return null; const params = {}; for (let i = 0; i < t.length; i++) { if (t[i].startsWith("{")) params[t[i].slice(1, -1)] = p[i]; else if (t[i] !== p[i]) return null; } return params;} function route(method, path) { const onPath = routes.filter(([, tpl]) => matchTemplate(tpl, path)); if (onPath.length === 0) return "404 Not Found"; const hit = onPath.find(([m]) => m === method); if (!hit) return "405 Method Not Allowed, Allow: " + onPath.map(([m]) => m).join(", "); return hit[2] + " " + JSON.stringify(matchTemplate(hit[1], path));} export function run() { return [ ["GET", "/orders/7"], ["DELETE", "/orders/7"], ["POST", "/orders/7/cancellation"], ["PUT", "/orders/7"], ["GET", "/getOrder"], ].map(([m, p]) => `${m} ${p} -> ${route(m, p)}`);}Nesting means containment
/customers/42/orders reads well when an order genuinely belongs to one
customer and the client is asking "this customer's orders". It is a
filtered view of /orders, and that is fine. The trouble starts deeper:
/customers/42/orders/7/items/3 forces the client to know the customer
id just to address an item, and breaks the day an item moves. A good
rule is one level of nesting at most, with every item also reachable at
its own top-level URI (/orders/7).
Actions that are not CRUD
"Cancel an order" or "send an invoice" do not map neatly onto create, read, update and delete. Two honest options, both without a verb in the path:
- Change state:
PATCH /orders/7with{"status": "cancelled"}, when cancelling is really just a field changing and the server validates the transition. - Create a sub-resource:
POST /orders/7/cancellation, when the action has its own data (a reason, who did it, when) and its own lifecycle you may want to read back later withGET.
Query strings filter, paths identify
/orders?status=open is the orders collection, filtered. The path
identifies which resource; the query string narrows or shapes what
comes back. An id belongs in the path, a filter in the query.
Common mistakes
- Verbs in paths (
/getOrders, ): the method already carries that meaning, and the URI stops identifying one resource.
Try it yourself
3 visible tests · 5 hidden testsImplement lintPath(path). path is a concrete request path starting
with / (no query string). Return an array of rule codes, sorted
alphabetically, each code at most once, or [] for a clean path:
"lowercase"— the path contains an uppercase letter."trailing-slash"— the path ends with/and is not just/."extension"— the last non-empty segment contains a.."underscore"— the path contains_."verb"— some segment's first word is one ofget,create,update,delete,add,remove(compared case-insensitively). Words in a segment are separated by-or by a lowercase letter followed by an uppercase one, so the first word ofgetOrdersand ofget-ordersisget, and the first word ofaddressesisaddresses.
lintPath("/customers/42/orders")lintPath("/getOrders.json")lintPath("/Line_Items/7/delete-item/")
Sign up to run the tests, check the hidden ones and save your progress. Sign up