REST APIs
Volume 3 — Modern Development
REST (Representational State Transfer) is an architectural style, not a protocol or a standard with a formal spec — which is exactly why "RESTful" APIs vary so much in practice. This chapter covers the principles worth actually following, and the HTTP mechanics underneath every API in the Traxs suite.
1. Core REST Principles
| Principle | What it means in practice |
|---|---|
| Resources, not actions | URLs identify things (/leave-requests/42), not verbs (/getLeaveRequest) — the HTTP method supplies the verb |
| Statelessness | Every request carries everything needed to process it (auth token, parameters) — the server holds no session state between requests |
| Uniform interface | The same HTTP methods and status codes mean the same thing everywhere in the API, not reinvented per endpoint |
| Representations | A resource can be represented in different formats (JSON is the near-universal default today) — the client and server negotiate this via headers |
2. HTTP Methods
| Method | Purpose | Idempotent? | Has a body? |
|---|---|---|---|
GET | Retrieve a resource | Yes | No |
POST | Create a new resource, or trigger an action | No | Yes |
PUT | Replace a resource entirely | Yes | Yes |
PATCH | Partially update a resource | No (though can be designed to be) | Yes |
DELETE | Remove a resource | Yes | Usually no |
Idempotent means calling it once or calling it five times in a row produces the same end state — DELETE /leave-requests/42 five times in a row still just results in that resource being gone, no different than calling it once. POST is deliberately not idempotent by convention — calling POST /leave-requests twice is expected to create two separate leave requests, not update the first one.
This distinction matters directly for retry logic: it's safe to automatically retry a failed GET, PUT, or DELETE without risking a duplicate side effect. It is not safe to blindly retry a failed POST without additional protection (like an idempotency key), because a network timeout doesn't tell you whether the original request actually succeeded server-side before the response was lost.
3. Status Codes
| Range | Category | Common codes |
|---|---|---|
| 2xx | Success | 200 OK, 201 Created, 204 No Content |
| 3xx | Redirection | 301 Moved Permanently, 304 Not Modified |
| 4xx | Client error | 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 415 Unsupported Media Type, 422 Unprocessable Entity |
| 5xx | Server error | 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable |
The distinctions that actually matter, and get confused most often:
| Codes | The real difference |
|---|---|
400 vs 422 | 400 = the request itself is malformed (broken JSON, wrong types). 422 = the request is well-formed but fails business/validation rules (e.g. leave dates overlap an existing approved request) |
401 vs 403 | 401 = you're not authenticated at all, or your credentials are invalid. 403 = you are authenticated, but you're not allowed to do this specific thing |
404 vs returning 200 with an empty result | 404 = this specific resource doesn't exist. An empty list from a collection endpoint (GET /leave-requests?status=pending with none pending) should still be 200 with [] — the collection exists, it's just empty |
409 | The request conflicts with the resource's current state — e.g. trying to approve a leave request that was already cancelled |
4. Resource Naming Conventions
GET /leave-requests list leave requests
POST /leave-requests create a new leave request
GET /leave-requests/42 get one specific leave request
PATCH /leave-requests/42 partially update it
DELETE /leave-requests/42 cancel/remove it
GET /leave-requests/42/history a sub-resource, nested under the parent
Conventions worth holding to consistently across an API:
- Plural nouns for collections (
/leave-requests, not/leave-request). - Nouns, not verbs, in the URL —
POST /leave-requests/42/approveis a defensible, common exception (an action that doesn't map cleanly to a plain resource update), but it should be the exception, not the default pattern. - Consistent casing — kebab-case (
/leave-requests) is the most common convention for URL paths.
5. Pagination, Filtering, and Sorting
GET /leave-requests?page=2&pageSize=25
GET /leave-requests?status=pending&sort=-requestedDate
A well-designed paginated collection response returns metadata alongside the results, not just a bare array:
{
"items": [ /* ... */ ],
"page": 2,
"pageSize": 25,
"totalCount": 143,
"totalPages": 6
}
Without this, a client has no way to know whether there's a next page without guessing — returning a bare array forces every consumer to either over-fetch everything or implement fragile heuristics.
6. API Versioning
| Approach | Example | Trade-off |
|---|---|---|
| URL path | /v1/leave-requests | Most visible and explicit; clutters the URL |
| Header | Api-Version: 1 | Clean URLs; less discoverable, easy to forget when testing manually |
| Query parameter | /leave-requests?api-version=1 | Explicit and easy to test, less conventional |
URL-path versioning is the most common choice specifically because it's the most discoverable — anyone reading a URL in a log or a bug report can immediately see which version was in play, with no need to inspect headers.
7. Consistent Error Responses
An API that returns a different error shape from every endpoint forces every client to write bespoke error-handling per endpoint. A single, consistent error format pays for itself immediately:
{
"type": "https://roundtrips.app/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "Leave request dates overlap an existing approved request.",
"errors": {
"startDate": ["Overlaps with an existing approved leave request (Jul 10–14)."]
}
}
This shape follows RFC 7807 (Problem Details), a standardized format for exactly this purpose — type, title, status, and detail are all conventional field names, which is also why frameworks like FastEndpoints and ASP.NET Core generate this shape by default for validation failures rather than inventing a custom one.
8. How This Maps to the Traxs API Design Convention
The Traxs pattern — commands and queries as records implementing IRequest<>, handled by IRequestHandler<>, exposed through FastEndpoints — is a CQRS-flavored implementation of these same REST principles, not a departure from them:
| REST concept | Traxs implementation |
|---|---|
| Resource | The aggregate the command/query operates on (e.g. LeaveRequest) |
GET request | A Query record, handled by a query handler, reading via Dapper |
POST/PUT/PATCH/DELETE request | A Command record, handled by a command handler, writing via EF Core |
| Consistent error shape | FastEndpoints' built-in validation failure response, following the Problem Details convention above |
| Idempotency | Command handlers should be designed so a genuinely duplicate command (same operation, same target) fails predictably (e.g. a 409 on an already-approved request) rather than silently double-applying |
9. Practical Developer Workflows
Check exactly what an API is returning, headers included:
curl -v https://api.roundtrips.app/leave-requests/42
Confirm a POST actually sends the right content type (see the Bash Reference chapter's note on bodyless POSTs defaulting to no Content-Type and triggering a 415):
curl -X POST https://api.roundtrips.app/leave-requests \
-H "Content-Type: application/json" \
-d '{"startDate":"2026-08-01","endDate":"2026-08-05"}'
10. Troubleshooting Playbook
| Symptom | Likely cause | What to check |
|---|---|---|
415 Unsupported Media Type | Missing Content-Type: application/json header, often from a bodyless axios.post(url) call | Explicitly set the header, or pass an empty object {} as the body |
404 on an endpoint that should exist | Wrong HTTP method for that route, or a routing/versioning mismatch | Confirm the method and full path, including any version prefix, against the actual route definition |
401 when you expected 403 (or vice versa) | Authentication vs. authorization confused in the endpoint's checks | Confirm whether the failure is "not logged in at all" vs. "logged in but not permitted" — see the Authentication & Authorization chapter |
| Client can't tell if there's another page of results | Response is a bare array with no pagination metadata | Wrap the response with page/pageSize/totalCount as shown in Section 5 |
Retried a failed POST, ended up with a duplicate resource | POST isn't idempotent by design, and no idempotency key was used | Add an idempotency-key mechanism for POST endpoints where accidental duplication is a real risk |
| Every endpoint has a different error response shape | No shared error-handling convention across endpoints | Standardize on one shape (Problem Details, Section 7) applied consistently, ideally via shared middleware/pipeline behavior |
11. Quick Reference
| Category | Item | Detail |
|---|---|---|
| Method | GET | Retrieve, idempotent, no body |
| Method | POST | Create/trigger, not idempotent |
| Method | PUT | Replace entirely, idempotent |
| Method | PATCH | Partial update |
| Method | DELETE | Remove, idempotent |
| Status | 422 | Well-formed request, failed business validation |
| Status | 401 | Not authenticated |
| Status | 403 | Authenticated, not permitted |
| Status | 409 | Conflicts with current resource state |
| Naming | /leave-requests | Plural nouns for collections |
| Pagination | ?page=2&pageSize=25 | Query params, with metadata in the response |
| Versioning | /v1/leave-requests | Most discoverable approach |
| Errors | RFC 7807 Problem Details | Consistent type/title/status/detail shape |
Part of the Traxs Engineering Handbook — Volume 3: Modern Development. Companion chapters in this volume: Docker Fundamentals, Docker Compose, Authentication & Authorization, JSON & YAML, Postman Guide.