A JSON API for the Harbour Night Market: stalls, reviews, saved stalls and orders. It runs on the course server, and everyone has their own market on it, named after their GitHub username:
https://harbour-api.lopin.me/<your-github-username>/api
That's the base URL; every path on this page (/api/stalls, /api/reviews/:id…) is relative to your market, so GET /api/stalls means GET https://harbour-api.lopin.me/<you>/api/stalls. In code, put the base URL in one constant and keep paths relative to it: API = "https://harbour-api.lopin.me/<you>/api", then getJSON("/stalls"), not "/api/stalls" (that would request …/api/api/stalls, a 404). The control page harbour-api.lopin.me checks your URL, sets chaos and resets your market. The same implementation also runs in the exercise editors and the request lab.
GET /api/stalls (the collection), GET /api/stalls/mezcal (one item), POST /api/stalls/mezcal/reviews (add to a sub-collection), DELETE /api/reviews/r3. Never /api/getStalls or a GET that changes data.Content-Type: application/json, or the server answers 415. Invalid JSON is 400."5") is a validation error, not converted.{ "error": "code", "message": "for humans" }, plus "fields": { "name": "message" } on a 422.Allow header.Authorization: Bearer <token>. Two kinds: the admin token harbour-admin-2026 may do everything; a vendor token vendor-<stall id> (e.g. vendor-taco-bike) may only PATCH its own stall. 401: no token, or an invalid one ("who are you?"). 403: a valid token that isn't allowed to do this ("I know who you are, and no"). The tokens are public because this is a course server."2026-10-16T19:42:07.311Z".PUT /api/_chaos with { "latency": 1200, "fail": 0.3 } delays every response by 1.2 s and answers 503 (with Retry-After: 2) to 30 % of requests; GET /api/_chaos shows the settings. ?chaos=1 on any request makes that one request slow and unreliable. The control page harbour-api.lopin.me has switches for both.{ "error": "internal_error" }, on purpose and always (chaos or not). A failed request changed nothing: sending it again is safe. Your page has to cope.POST /api/_reset puts your market back to the seed data. Your data otherwise persists: it's still there tomorrow.Access-Control-Allow-Origin: *, so a page on any origin (your localhost:8080) can read it; JSON bodies and Authorization trigger a preflight, which is answered."namespace_full": reset), bodies up to 100 kB (413).{
"id": "mezcal", // a slug, never changes
"name": "Mezcal Moon",
"tag": "drinks", // "food" | "music" | "crafts" | "drinks"
"price": 9, // whole euros, 0 = free
"soldOut": true,
"featured": true, // only on some stalls
"blurb": "Small-batch mezcal and …",
"rating": 3.7, // average of its reviews, one decimal, null if none
"reviewCount": 3
}
Every stall. Filtering, searching, sorting and pagination happen on the server through query parameters, which can be combined.
| Parameter | Example | Effect |
|---|---|---|
| tag | ?tag=food | only that tag; an unknown tag is 400 |
| q | ?q=taco | case-insensitive search in names and blurbs. Short queries are answered more slowly (up to ~1 s for one letter) |
| sort | ?sort=price ?sort=-rating | name, price or rating; a leading - sorts descending; anything else is 400 |
| limit, page | ?limit=4&page=2 | pagination (limit 1–50, default 4 when either is present) |
200 · an array of Stall objects. Headers: X-Total-Count (matches before pagination), and with pagination Link: </api/stalls?…page=3>; rel="next", <…>; rel="prev".
200 · one Stall. 404 if there's no stall with that id.
Body: { "name", "tag", "price", "blurb" }, all required; soldOut optional. The id is made from the name ("Crêpe Escape" → "crepe-escape").
201 · the Stall, with Location: /api/stalls/:id. 401 no or invalid token · 403 a vendor token. 409 a stall with that id exists. 422 fields: name 1–40 characters, tag one of the four, price integer 0–100, blurb up to 200 characters, soldOut boolean.
Body: only the fields to change, same rules as POST. Admin token, or the stall's own vendor token. 200 · the updated Stall. 401 no or invalid token · 403 another vendor's token · 404 · 422.
fetch("/api/stalls/mezcal", {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: "Bearer harbour-admin-2026" },
body: JSON.stringify({ soldOut: false }),
});
204 · no body; the stall's reviews and its saved entry go too. 401 · 403 a vendor token · 404.
200 · an array of reviews, newest first, with X-Total-Count. 404 unknown stall.
{ "id": "r12", "stallId": "mezcal", "author": "Rosa", "rating": 5, "text": "Ask for the espadín.", "createdAt": "2026-10-16T16:22:00.000Z" }
Body: { "author", "rating", "text" }. Other fields are ignored.
| Field | Rule | Message on a 422 |
|---|---|---|
| author | string, 1–40 characters after trimming | Required, up to 40 characters. |
| rating | integer 1–5, a number | A whole number from 1 to 5. / A number, not a string ("5"). |
| text | string, 10–280 characters after trimming | 10–280 characters. |
201 · the review as stored (with id and createdAt), and Location: /api/reviews/:id. 400 invalid JSON · 404 unknown stall · 415 not JSON · 422 { "fields": { … } }.
GET: 200 · the review. DELETE: 204. Both 404 for an unknown id. (No ownership check: a course simplification. A real API would only let the author delete.)
GET /api/saved → 200 · an array of stall ids: ["mezcal", "taco-bike"].PUT /api/saved/:id → 204; 404 for an unknown stall. No body needed. Idempotent: saving twice is saving once.DELETE /api/saved/:id → 204, also when it wasn't saved. Idempotent.One list per server: everyone using the same server shares it.
POST /api/orders with { "stallId", "name" } → 201 · the order, Location: /api/orders/:id. 409 "sold_out" when the stall is sold out. 422 fields: stallId must exist, name 1–24 characters.GET /api/orders/:id → 200 · the order with its current status: "placed", then "preparing" after 5 seconds, then "ready" after 15. 404 unknown id.GET /api/orders → 200 · every order, newest first.{ "id": "o1", "stallId": "taco-bike", "stallName": "Taco Bike", "name": "Ana", "placedAt": "2026-10-16T19:50:11.020Z", "status": "preparing" }
| Header | Direction | What it says |
|---|---|---|
| Content-Type | both | the body's format; requests with a body must send application/json |
| Accept | request | the formats the client can read (this API always answers JSON) |
| Authorization | request | Bearer <token> for the routes that change stalls |
| WWW-Authenticate | response | sent with a 401: which scheme to use |
| Location | response | the URL of a created resource (201) |
| Allow | response | the methods a path supports (405) |
| X-Total-Count · Link | response | pagination: the total, the next and previous pages |
| Retry-After | response | seconds to wait after a 503 |
| Cache-Control | response | no-store: API responses are never cached |
| Origin · Access-Control-* | both | CORS: Access-Control-Allow-Origin: * on every response; Access-Control-Expose-Headers lets your page read Location, X-Total-Count, Link, Retry-After, Allow |
200 · the API's name, version and resources. A good first request to check that the server is up.