In the previous lesson we fixed the working method and decided, with judgement, which nouns of the Aroma Store domain deserve to be resources. Now comes the next step: giving them an address. The URI is the most visible and most long-lived part of an API —clients write it into their code, save it in bookmarks, paste it into tickets— and that is also why it is the most expensive thing to change. This lesson establishes the naming rules, settles when to nest and when not to, chooses the type of identifier and tackles the problem that no CRUD solves on its own: how to model actions such as paying for an order or moderating a review. We will finish with the complete URI map of Aroma Store, which the rest of the module will take as given.
Contents
- What a resource is and what a URI is
- Nouns, not verbs
- Collections and elements
- Naming rules
- Hierarchy and nesting
- Singleton resources
- Path parameters versus query parameters
- Identifier design
- Actions that are not CRUD
- Aroma Store's URI map
- What a resource is and what a URI is
Let us recall the definition from 01-04: a resource is anything with identity that it makes sense to operate on; the URI is its stable identifier; the representation is one of its possible concrete forms (the JSON that travels, the PDF, the HTML).
Three practical consequences that govern this entire lesson:
- A resource can have several representations (
/orders/ord_5001/invoicein JSON or in PDF), but it keeps a single URI. The representation is chosen by content negotiation (02-05), not by changing the URL with.pdf. - A URI identifies, it does not describe the operation. What you do with the resource is stated by the method (02-03).
- URIs should outlive internal changes. If
/coffees/cof_001stops working because the database has been refactored, we have broken the contract.
- Nouns, not verbs
The most quoted rule of REST design, and the one most often broken:
| ❌ Verb in the path | ✅ Noun + method |
|---|---|
GET /getCoffees |
GET /coffees |
POST /createOrder |
POST /orders |
POST /updateStock?id=cof_001 |
PATCH /coffees/cof_001 |
GET /deleteReview?id=rev_101 |
DELETE /reviews/rev_101 |
POST /listCustomerOrders |
GET /customers/cus_842/orders |
Why it matters, beyond aesthetics:
- The method is already the verb.
GET /getCoffeesrepeats the verb and, worse, allows the incoherentPOST /getCoffees. - HTTP's semantics are lost.
GET /deleteReviewis a functional disaster: a search engine or a browser prefetch could delete reviews while following links, because GET is safe by definition (02-03). - Predictability is lost. With verbs, every endpoint has to be memorised:
getCoffees,fetchOrders,listReviews,readCustomer.
Watch out for one important nuance: identifiers in the code do use verbs (getCoffees(), createOrder()), as the style guide states. What carries no verb is the URI.
- Collections and elements
Every resource-oriented API is structured around two shapes:
- Collection: a set of resources of the same type.
/coffees. - Element (or individual resource): a specific member.
/coffees/cof_001.
The pattern reads from left to right like a navigation path:
/v1/customers/cus_842/orders/ord_5001 │ │ │ │ └── element: one specific order │ │ │ └───────── collection: that customer's orders │ │ └───────────────── element: one specific customer │ └─────────────────────────── collection: all the customers └─────────────────────────────── API version
Each level alternates collection → element → collection → element. If your URI breaks that alternation (/customers/orders/cus_842), there is almost always a design error.
And each shape admits different operations, which anticipates 02-03:
Collection /coffees |
Element /coffees/cof_001 |
|
|---|---|---|
GET |
Lists, filtered and paginated | Returns that coffee |
POST |
Creates a new one | Not used (except for an action sub-resource) |
PUT |
Not used (replacing a whole collection is dangerous) | Replaces that coffee |
PATCH |
Not used | Modifies fields of that coffee |
DELETE |
Not used (deleting the whole catalogue by accident) | Deletes that coffee |
- Naming rules
These are the rules Aroma Store adds to its style guide.
4.1. Consistent plurals
/coffees, /customers, /orders, /reviews, /carts. Always plural, even when it sounds odd, because the alternative —singular for the element and plural for the collection— forces you to remember two forms per resource.
The only exception is singletons (section 6), which by definition are not collections.
4.2. Always lowercase
The path component of a URL is case-sensitive (unlike the host name). /Coffees and /coffees are two different resources as far as the standard is concerned, and accepting both doubles the surface of the contract and ruins caching.
4.3. Hyphens for compound words (kebab-case)
✅ /tasting-notes /payment-methods /orders/ord_5001/gift-note ❌ /tastingNotes /tasting_notes /TastingNotes
Reasons: it is the dominant convention on the web, it is more readable and search engines treat the hyphen as a word separator (relevant if part of the API is indexed or if you share documentation links). Notice the deliberate asymmetry: kebab-case in the URI, camelCase in the JSON. It is an apparent inconsistency, but these are two worlds with their own settled conventions; what matters is that each world is consistent with itself.
4.4. No file extensions
✅ /coffees/cof_001 with the header Accept: application/json ❌ /coffees/cof_001.json ❌ /coffees/cof_001.xml
The format is a question of representation, and it is negotiated with headers (02-05). Adding .json mixes identity and format: if you add XML or PDF tomorrow, every resource has three URIs for the same thing.
4.5. No trailing slash
/coffees and /coffees/ are technically different paths. Pick one —Aroma Store uses no trailing slash— and redirect the other with a 301 (02-04) instead of serving both.
4.6. No technical suffixes or internal jargon
The name of the class, the table or the implementation pattern is none of the consumer's business. Watch out for /api too: if the host is already api.aromastore.example, repeating it in the path is redundant.
4.7. No problematic characters
No accents, ñ, spaces or capitals in the path segments you control. That is why the collection is /origins and never /orígenes: even though modern browsers encode non-ASCII characters, in practice they end up showing as /or%C3%ADgenes in logs, curl examples and old clients.
- Hierarchy and nesting
Nesting expresses belonging: /coffees/cof_001/reviews are the reviews of that coffee.
5.1. The practical rule
Nest a sub-resource only if it makes no sense outside its parent, or if the belonging relationship is the natural way of reaching it.
Examples in Aroma Store:
| URI | Nest? | Reason |
|---|---|---|
/coffees/cof_001/reviews |
Yes | The reviews of a coffee are a central use case (the product page) |
/carts/crt_77/items/cof_002 |
Yes | A cart item does not exist without its cart |
/orders/ord_5001/payment |
Yes | The payment belongs to one specific order |
/customers/cus_842/orders |
Yes | "My orders" is a real use case for the SPA and for Aroma Mobile |
/customers/cus_842/orders/ord_5001/items/1/coffee |
No | Four levels: illegible and fragile. Link to the coffee, do not nest it |
/origins/ethiopia/coffees |
No | The origin is an attribute: it is handled with a ?origin=Ethiopia filter |
5.2. Two levels maximum
A rule that saves a lot of grief: do not go beyond /collection/{id}/subcollection/{id}. Past that point the URI becomes illegible, it couples the client to a hierarchy that may change and it forces you to validate long chains of belonging.
When you need to go deeper, cut the hierarchy and use a top-level collection with a filter:
# Instead of nesting three levels
curl "https://api.aromastore.example/v1/reviews?coffeeId=cof_001&status=pending_moderation"5.3. Dual access: nested and flat
The same resource can be reachable through two paths if each one serves a different use case. In Aroma Store:
# Product page: the reviews of a coffee (SPA and Aroma Mobile)
GET /v1/coffees/cof_001/reviews
# Internal panel: all the reviews pending moderation, for any coffee
GET /v1/reviews?status=pending_moderationRules to stop this becoming a problem:
- The element lives at one single canonical URI:
/reviews/rev_101. The nested form is only for listing and creating. - The representation's
selflink always points at the canonical one, so that two paths do not generate two identities. - Nested creation is more convenient:
POST /coffees/cof_001/reviewsdoes not need to repeatcoffeeIdin the body, because it is already in the URI.
graph TD
C["GET /v1/coffees/cof_001/reviews<br/><i>nested view</i>"] --> R["rev_101<br/>rev_102"]
P["GET /v1/reviews?status=pending_moderation<br/><i>flat view with a filter</i>"] --> R
R --> CAN["Canonical URI of the element:<br/><b>/v1/reviews/rev_101</b><br/>(the one that goes in _links.self)"]
- Singleton resources
A singleton is a resource of which only one instance exists in its context. It has no collection and no identifier of its own, and that is why it goes in the singular.
/v1/customers/cus_842/preferences the customer's preferences (language, currency, newsletter) /v1/orders/ord_5001/payment the payment for that order /v1/orders/ord_5001/shipment the shipment handled by SwiftShip /v1/orders/ord_5001/invoice the invoice for that order /v1/coffees/cof_001/image the coffee's main image
A singleton typically admits GET, PUT and sometimes DELETE, but not POST (there is no collection to add to), with the exception of singletons that model an action, which do use POST (section 9).
GET /v1/customers/cus_842/preferences HTTP/1.1
Host: api.aromastore.example
Accept: application/jsonHTTP/1.1 200 OK
Content-Type: application/json
{
"language": "en",
"currency": "EUR",
"newsletter": true,
"preferredRoast": "medium",
"_links": {
"self": { "href": "/v1/customers/cus_842/preferences" },
"customer": { "href": "/v1/customers/cus_842" }
}
}Beware of the badly used singleton: /customers/cus_842/address is fine if the customer can only have one; the moment they can have several it becomes /customers/cus_842/addresses and that is a breaking change (02-07). If in doubt, start with a collection.
- Path parameters versus query parameters
The most common confusion in URI design. The rule is simple and almost never fails:
The path identifies the resource. The query string modifies how the collection is returned.
| Goes in the path | Goes in the query string |
|---|---|
Resource identifiers: /coffees/cof_001 |
Filters: ?origin=Colombia&roast=medium |
Belonging hierarchy: /coffees/cof_001/reviews |
Sorting: ?sort=-priceEuros |
Singleton sub-resources: /orders/ord_5001/payment |
Pagination: ?limit=20&offset=40 |
Actions modelled as sub-resources: /reviews/rev_101/approval |
Search: ?q=yirgacheffe |
Sparse fieldsets: ?fields=id,name,priceEuros |
|
Expansion: ?expand=items.coffee |
A side-by-side example:
# ✅ Correct: the id identifies, so it goes in the path
curl https://api.aromastore.example/v1/coffees/cof_001
# ❌ Wrong: the id is not a filter
curl "https://api.aromastore.example/v1/coffees?id=cof_001"
# ✅ Correct: origin is a selection criterion over the collection
curl "https://api.aromastore.example/v1/coffees?origin=Ethiopia"
# ❌ Wrong: turns an attribute value into hierarchy
curl https://api.aromastore.example/v1/coffees/origin/ethiopiaTwo nuances worth knowing:
- A filter that returns a single element is still a collection.
GET /coffees?name=Ethiopia Yirgacheffereturns{"data": [...], "total": 1}, not the bare object, and it returns200with an empty list if there are no matches (not404). The reason: the "filtered collection" resource exists even when it is empty. - Query parameters affect caching. Every different combination is a different URL and therefore a different cache entry. That is one more argument for not multiplying parameters needlessly (04-06).
- Identifier design
The identifier you put in the URI is contract forever. The options:
| Type | Example | Advantages | Drawbacks |
|---|---|---|---|
| Auto-increment integer | /coffees/1 |
Short, readable, cheap to index | Leaks business volume, enumerable, clashes when merging databases |
| UUID v4 | /coffees/6f1c... |
Unguessable, generable by the client, unique across systems | Long, illegible, worse index locality |
| UUID v7 / ULID | /coffees/01HQ... |
Sortable by time, good index behaviour | Reveals the moment of creation |
| Slug | /coffees/ethiopia-yirgacheffe |
Readable, good for SEO | Changes if the name changes; duplicates have to be managed |
| Prefixed id | /coffees/cof_001 |
Self-describing, impossible to confuse types, searchable in logs | An in-house convention, not a standard |
Aroma Store's decision: prefixed ids
cof_001, cus_842, ord_5001, rev_101, crt_77, inv_88, evt_9f2c. It is the style Stripe made popular and the reasons are very practical:
- Self-describing. When you read a log or a ticket,
ord_5001is understandable without context. With a bare5001you do not know what it belongs to. - Impossible to cross types. If someone sends
POST /v1/orderswith{"customerId": "cof_001"}, the server spots the wrong prefix and responds400withinvalid_data, instead of creating an incoherent order. - Opaque by contract. The documentation says so explicitly: the identifier is an opaque string, do not parse it, do not assume a length, do not assume that the numeric part is sequential. That way we can migrate tomorrow to
cof_01HQ8ZK...without breaking anyone.
In production, the part after the prefix should be random and non-sequential. The cof_001 and ord_5001 of this course are didactic; in a real shop, publishing sequential identifiers has two problems:
- Business information leakage. A competitor who places an order on Monday and another on Friday knows how many orders you received that week. It is the classic German tank problem.
- Enumeration. With sequential ids, walking
ord_5001,ord_5002,ord_5003… is trivial. A third party being able to read other people's orders is an authorisation failure, not an id failure —it is called IDOR and it is covered in 04-02— but guessable ids turn an isolated flaw into a mass leak. The rule is: always authorise, and on top of that do not make it easy.
About slugs: they are excellent for the public web (aromastore.example/coffees/ethiopia-yirgacheffe) and bad as API identity, because they change. If you want them, the usual pattern is for the slug to be one more field of the representation and a filter (?slug=ethiopia-yirgacheffe), while the canonical URI remains the opaque id.
- Actions that are not CRUD
Here is the most interesting design problem of the lesson. Many business operations are not "create, read, update, delete":
- Paying for an order.
- Cancelling an order.
- Approving or rejecting a review.
- Emptying a cart.
- Resending the confirmation email.
There are three strategies, and it is worth understanding all three before choosing.
Strategy A: change the state with PATCH
PATCH /v1/reviews/rev_101 HTTP/1.1
Content-Type: application/merge-patch+json
{ "status": "published" }| For | Against |
|---|---|
| It does not invent new resources | Side effects stay hidden: approving triggers emails, recalculates the coffee's average rating… |
| Pure CRUD, easy to implement | You cannot pass parameters that belong to the action (the reason for a rejection) |
| Clear semantics for the client | It does not distinguish "change a piece of data" from "execute a transition" |
| Hard to authorise separately: whoever can edit can approve |
It is acceptable when the transition is purely a data change, with no logic and no side effects.
Strategy B: a verb in the URI (RPC over HTTP)
| For | Against |
|---|---|
| Explicit, immediate intent | It reintroduces verbs into the URI, exactly what we avoided in section 2 |
| Easy to explain | It degrades quickly: approveWithComment, approveAndNotify |
| It is what many real APIs do | It drops to Richardson level 1 on those endpoints |
Strategy C: the action becomes a sub-resource (Aroma Store's choice)
You look for the noun behind the verb: approve → an approval; pay → a payment; cancel → a cancellation. That noun is a resource that is created with POST.
POST /v1/reviews/rev_101/approval HTTP/1.1
Host: api.aromastore.example
Authorization: Bearer <moderator's token>
Content-Type: application/json
{ "note": "Review verified, purchase confirmed" }HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/reviews/rev_101/approval
{
"status": "published",
"moderatorId": "cus_003",
"approvedAt": "2026-03-14T10:32:00Z",
"_links": {
"self": { "href": "/v1/reviews/rev_101/approval" },
"review": { "href": "/v1/reviews/rev_101" }
}
}The advantages are exactly the ones we were after:
- No verbs in the path:
approvalis a noun and the verb is supplied byPOST. - The action can carry its own body: the reason for the rejection, the payment reference, the amount.
- The action is a queryable resource:
GET /v1/orders/ord_5001/paymentreturns the payment that was made, with its date and its reference. - It is authorised separately: permission to create
/approvalis different from permission to edit the review (04-03). - It leaves room for idempotency: being a well-delimited
POST, you can require anIdempotency-Keyon it (02-03).
And Aroma Store's action map ends up like this:
| Business action | URI | Method | Verb avoided |
|---|---|---|---|
| Pay for an order | /orders/{id}/payment |
POST | pay |
| Cancel an order | /orders/{id}/cancellation |
POST | cancel |
| Return an order | /orders/{id}/return |
POST | return |
| Approve a review | /reviews/{id}/approval |
POST | approve |
| Reject a review | /reviews/{id}/rejection |
POST | reject |
| Reply to a review | /reviews/{id}/replies |
POST | reply |
| Empty a cart | /carts/{id}/items |
DELETE | empty |
Notice the last row: before inventing a sub-resource, check whether a standard method already expresses it. Emptying the cart is exactly "delete all its items", so DELETE /carts/crt_77/items is more natural than a POST /carts/crt_77/emptying. It is the exception to the rule in section 3 about not using DELETE on collections, and it is legitimate because here the collection is scoped to one specific cart and deleting it wholesale is a genuine business operation.
- Aroma Store's URI map
This is the outcome of the lesson and the map that the rest of the module will take as given. The methods appear to give context; their exact semantics are 02-03 and the response codes are 02-04.
| URI | Methods | Description |
|---|---|---|
/v1/coffees |
GET, POST, HEAD, OPTIONS | Coffee catalogue; filterable, sortable and paginated |
/v1/coffees/{coffeeId} |
GET, PUT, PATCH, DELETE, HEAD | One specific coffee |
/v1/coffees/{coffeeId}/image |
GET, PUT, DELETE | Main image (singleton, not JSON) |
/v1/coffees/{coffeeId}/reviews |
GET, POST | Reviews of a coffee (nested view) |
/v1/customers |
GET, POST | Registered customers (internal panel only) |
/v1/customers/{customerId} |
GET, PATCH, DELETE | One specific customer |
/v1/customers/{customerId}/preferences |
GET, PUT | Customer preferences (singleton) |
/v1/customers/{customerId}/orders |
GET | "My orders" |
/v1/carts |
POST | Creates a cart |
/v1/carts/{cartId} |
GET, DELETE | One specific cart |
/v1/carts/{cartId}/items |
GET, POST, DELETE | Cart items; DELETE empties it |
/v1/carts/{cartId}/items/{coffeeId} |
GET, PUT, DELETE | One item; PUT sets the quantity |
/v1/orders |
GET, POST | Orders; POST confirms a cart |
/v1/orders/{orderId} |
GET, PATCH | One specific order |
/v1/orders/{orderId}/items |
GET | Order items (immutable) |
/v1/orders/{orderId}/payment |
GET, POST | Order payment (action + query) |
/v1/orders/{orderId}/cancellation |
POST | Cancels the order |
/v1/orders/{orderId}/return |
POST | Requests a return |
/v1/orders/{orderId}/shipment |
GET, PUT | Shipment; SwiftShip updates it with PUT |
/v1/orders/{orderId}/invoice |
GET | Invoice (JSON or PDF, depending on Accept) |
/v1/reviews |
GET | All the reviews (flat view, filterable) |
/v1/reviews/{reviewId} |
GET, PATCH, DELETE | One review (canonical URI) |
/v1/reviews/{reviewId}/approval |
POST | Approves the review |
/v1/reviews/{reviewId}/rejection |
POST | Rejects the review |
/v1/reviews/{reviewId}/replies |
GET, POST | The shop's replies to the review |
Decisions noted down so we do not forget them:
- There is no
POST /v1/reviews: a review is always born attached to a coffee, so it is only created at/coffees/{coffeeId}/reviews. The flat view is read-only and serves the internal panel. - There is no
DELETE /v1/orders/{id}: an order is not deleted, it is cancelled. The accounting record is sacred. /v1/originshas been considered and discarded for v1: the origin is an attribute and is filtered with?origin=. If it ever has data of its own (altitude, cooperative, photo), it will become a collection and can be added without breaking anything (02-07).
Common Mistakes and Tips
- Putting the verb in the path "just for this one operation". You start with one exception and you end up with twenty. If you need an action, look for its noun.
- Nesting out of habit.
/customers/cus_842/orders/ord_5001forces the server to validate that the order belongs to that customer and forces the client to know two ids to fetch one thing. Nest to list, use the canonical URI for the element. - Using the query string to identify.
/coffees?id=cof_001breaks caching by URL, complicatesselflinks and does not allow sub-resources. - Adding
.jsonat the end. It mixes identity and format; that is whatAcceptis for. - Pluralising badly.
/coffeesand/coffee/cof_001living side by side is the most frequent documentation bug in the world. - Exposing the database id out of convenience. Changing engine or merging environments will force you to break the contract. An opaque id leaves you free.
- Tip: write the URIs before the code and read them out loud. If reading
/orders/ord_5001/cancellationwithPOSTis understandable without explanation, it is well designed. - Tip: keep a table like the one in section 10 in the repository. It is the index of the contract and the place where any new endpoint gets discussed before it exists.
Exercises
Exercise 1: fix a set of URIs
A team has proposed these paths for Aroma Store. Correct them and justify each change.
GET /v1/api/getCoffees.json POST /v1/Coffee/create GET /v1/coffees?id=cof_001 POST /v1/coffees/cof_001/reviews/rev_101/approve GET /v1/customers/cus_842/orders/ord_5001/items/1/coffee/cof_001/reviews DELETE /v1/carts/crt_77/emptyCart GET /v1/coffees/origin/ethiopia/roast/light
Exercise 2: model a new action
Aroma Store wants a customer to be able to gift an order: when confirming it they give the recipient's email address and a message, and the system sends a notification and hides the price on the delivery note.
Model this feature with the three strategies from section 9 (PATCH, verb in the URI, sub-resource), show the HTTP request for each one and pick the one that fits the style guide, justifying your choice.
Exercise 3: decide on nesting
For each need, decide the URI and say whether you nested it or not and why:
- The internal panel wants to see all the sold items of coffee
cof_001in the last month. - Aroma Mobile wants the reviews written by customer
cus_842. - SwiftShip wants to update the shipment status of order
ord_5001. - The SPA wants the price change history of
cof_001.
Solutions
Solution 1
| Proposal | Correction | Reason |
|---|---|---|
GET /v1/api/getCoffees.json |
GET /v1/coffees |
/api is redundant (it is already in the host), the get verb is redundant (the method supplies it) and .json is redundant (Accept negotiates it) |
POST /v1/Coffee/create |
POST /v1/coffees |
Lowercase, plural and no verb: POST on the collection already means create |
GET /v1/coffees?id=cof_001 |
GET /v1/coffees/cof_001 |
The identifier goes in the path; the query string filters collections |
POST /v1/coffees/cof_001/reviews/rev_101/approve |
POST /v1/reviews/rev_101/approval |
Verb → noun, and it is trimmed back to the review's canonical URI: the coffee is redundant |
GET /v1/customers/.../coffee/cof_001/reviews |
GET /v1/coffees/cof_001/reviews |
Five levels of unnecessary nesting: the review depends on the coffee, not on the customer's order |
DELETE /v1/carts/crt_77/emptyCart |
DELETE /v1/carts/crt_77/items |
The standard method already expresses the action on the sub-collection |
GET /v1/coffees/origin/ethiopia/roast/light |
GET /v1/coffees?origin=Ethiopia&roast=light |
Origin and roast are attributes, not hierarchy: they are filters |
Solution 2
Strategy A — PATCH on the order:
PATCH /v1/orders/ord_5001
Content-Type: application/merge-patch+json
{ "gift": { "recipient": "[email protected]", "message": "Congratulations!" } }It works, but sending the notification is left as a hidden side effect of a field change, and there is nowhere to check afterwards whether the notification was sent.
Strategy B — verb in the URI:
POST /v1/orders/ord_5001/gift-it
Content-Type: application/json
{ "recipient": "[email protected]", "message": "Congratulations!" }Crystal-clear intent, but a verb in the path: it breaks the style guide and opens the door to giftWithoutNotice.
Strategy C — sub-resource (the chosen one):
POST /v1/orders/ord_5001/gift
Content-Type: application/json
{ "recipient": "[email protected]", "message": "Congratulations!", "hidePrice": true }HTTP/1.1 201 Created
Location: /v1/orders/ord_5001/gift
{
"recipient": "[email protected]",
"message": "Congratulations!",
"hidePrice": true,
"notificationSent": true,
"notifiedAt": "2026-03-14T10:35:00Z",
"_links": { "self": { "href": "/v1/orders/ord_5001/gift" },
"order": { "href": "/v1/orders/ord_5001" } }
}This is the chosen one: a noun (gift), its own body for the action's parameters, queryable afterwards with GET, and cancellable with DELETE /v1/orders/ord_5001/gift as long as the order has not shipped. It fits exactly alongside /payment, /cancellation and /approval.
Solution 3
GET /v1/order-items?coffeeId=cof_001&dateFrom=2026-02-14— not nested. What is being searched for cuts across every order, so the/orders/{id}/itemshierarchy is no use: a flat top-level collection queryable with filters is needed. Beware the temptation to write/v1/orders/items: it would collide with/v1/orders/{orderId}, because the router cannot tell an id calleditemsfrom a fixed segment. (A reasonable alternative: a reporting resource/v1/sales?coffeeId=..., if the panel needs aggregates rather than individual items.)GET /v1/reviews?customerId=cus_842— not nested under the coffee, because the criterion is the author. Nesting it as/customers/cus_842/reviewswould also be defensible if "my reviews" were a screen of its own in Aroma Mobile; both obey the rule, and in that case it would be wise to pick just one so as not to duplicate contract.PUT /v1/orders/ord_5001/shipment— nested and a singleton: a shipment does not exist outside its order and there is only one.PUTbecause SwiftShip sends the shipment's complete state on every update (02-03).GET /v1/coffees/cof_001/price-history— nested: the history makes no sense outside its coffee and it is read-only. Note thekebab-casein the compound word. If the history were queried globally across the whole catalogue, it would become/v1/price-history?coffeeId=cof_001.
Conclusion
URIs are the public and most long-lived face of the API, and you now have concrete rules for designing them: plural, lowercase nouns, kebab-case for compound words, no extensions and no verbs, nesting only when it expresses real belonging and never beyond two levels, singulars for singletons that are genuinely unique, identification in the path and collection modification in the query string, and opaque prefixed identifiers that do not leak business information. Above all you have solved the problem that throws everybody: actions that are not CRUD are modelled as sub-resources created with POST, which gives each action its own body, later queryability and independent authorisation. Aroma Store's URI map is settled.
We now know which resources exist and where they live. What is missing is stating precisely what can be done with each of them. In the next lesson, 02-03 HTTP Methods, we will walk through GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS applied to this map; we will understand why safety and idempotency are far more than theory when SwiftShip retries a request or a customer clicks the pay button twice; we will compare PUT against PATCH with JSON Merge Patch and JSON Patch in depth; and we will design the idempotency keys for paying an order.
REST API Course: Principles of Designing and Developing RESTful APIs
Module 1: Introduction to RESTful APIs
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
