Many APIs that call themselves "RESTful" use HTTP only as a pipe: a single address, a single method, and a field in the body that says what to do. They work, but they give up what HTTP already offers: caching, safe repetition of requests, status codes that proxies and monitoring tools understand. The practical answer of this article is that a professional REST API stops at level 2 of the Richardson maturity model (resources, methods and status codes used with their meaning), adds ETag for caching and concurrent updates, returns errors as Problem Details (RFC 9457) and documents the contract with OpenAPI 3.1.
I start from the model and the semantics of the methods, then move on to status codes, ETag, errors, pagination and versioning. Every listing
was run in process with FastAPI and its TestClient, with no server listening, and the outputs shown are the real ones. This
article follows the API landscape: if you do not yet know whether REST is the right
choice for your case, start there. If FastAPI is new to you, its setup is covered in
FastAPI from scratch.
The four levels of the Richardson model
The model describes how much an API really exploits the web. It was developed by Leonard Richardson, who presented it in a talk at QCon, and it was popularised by Martin Fowler in his 2010 article, which summarises it in three steps (resources, HTTP verbs, hypermedia controls) on top of a starting level zero. The value of the model is diagnostic: it shows you the point where your API stops using HTTP for what it can do.
| Level | What it introduces | What a request looks like |
|---|---|---|
| 0 | HTTP as plain transport | POST /api with {"action": "getUser", "id": 123} in the body |
| 1 | Resources with their own address | addresses such as /users/123, but still almost everything in POST |
| 2 | HTTP methods and status codes with their meaning | GET /users/123 answers 200 or 404; POST /users answers 201 |
| 3 | Hypermedia controls (HATEOAS): the response says which actions are possible | the response contains links such as deactivate, present only if the action is allowed |
Level 2 is where the decisions that matter most live. As Fowler notes, using GET for reads is decisive because HTTP defines it as
safe and lets any intermediary cache the response. Level 3 is less widespread: the response tells the client what it can do next, instead of forcing it to
know the addresses in advance. The listing in the next section shows it with a minimal case (the deactivate link appears only for an
active user). The cost is tangible: larger responses and clients that must know how to follow links. For an API used by a single frontend that you control, the return is often low;
it makes more sense when addresses must be able to change without breaking clients you do not control.
Safe and idempotent methods: what they really promise
Two properties of methods decide whether a request can be repeated without harm, and they must be told apart. A method is safe if its meaning is read-only: the client neither requests nor expects a change of state (RFC 9110, section 9.2.1). It is idempotent if the intended effect of several identical requests is the same as that of a single one (section 9.2.2). The RFC lists among the idempotent methods PUT, DELETE and all the safe ones; POST is not. The practical consequence, written in the same section, is that a client may automatically repeat an idempotent request if the connection drops before the response, and should not do so with a method that is not.
| Method | Safe | Idempotent | Note |
|---|---|---|---|
| GET, HEAD, OPTIONS | yes | yes | Reads; HEAD gets only the headers |
| POST | no | no | Creation and actions; repeating it may create two resources |
| PUT | no | yes | Creates or replaces the entire representation |
| PATCH | no | not in general | RFC 5789 says it is neither safe nor idempotent, but it can be used in an idempotent way |
| DELETE | no | yes | A body, if present, has no defined semantics (RFC 9110, section 9.3.5) |
The table corrects two inaccuracies that circulate. The first: PATCH does not become idempotent just because JSON Patch is used, as the listing below demonstrates with a concrete case. The second concerns DELETE: idempotence concerns the effect on the server, not the response code, so a second deletion answers 404 and that is fine, because the resource is absent as the client wanted. If you need to repeat a POST without duplicates, the solution is an idempotency key chosen by the client and checked by the server: the technique, seen from the side of event consumers, is in Idempotency in consumers.
Requests in action: create, replace, delete
The following listing is a minimal users API with four routes. Four lines matter. In create, the
Location header says where to find the newly created resource, accompanying the 201 status. In read, the deactivate link is added
only if user["active"] is true: it is the idea of level 3 reduced to the bone. In replace, PUT replaces the whole representation and, if the
resource does not exist, creates it answering 201 instead of 200. In remove, DELETE answers 204 with no body. The calls at the bottom
print the outcomes.
# status.py - HTTP methods and status codes, tried in process with TestClient
from fastapi import FastAPI, HTTPException, Response
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
db: dict[int, dict] = {
123: {"name": "Federico", "email": "federico@example.com", "phone": "+39 333 0000000", "active": True}
}
class UserIn(BaseModel):
name: str
email: str
phone: str | None = None # optional: if missing, it is None
@app.post("/users", status_code=201)
def create(user: UserIn, response: Response):
new_id = max(db) + 1
db[new_id] = {**user.model_dump(), "active": True}
response.headers["Location"] = f"/users/{new_id}" # where to find the created resource
return {"id": new_id, **db[new_id]}
@app.get("/users/{uid}")
def read(uid: int):
if uid not in db:
raise HTTPException(404, "user not found")
user = db[uid]
links = {"self": {"href": f"/users/{uid}"}}
if user["active"]: # the link appears only if the action is possible
links["deactivate"] = {"href": f"/users/{uid}/deactivate", "method": "POST"}
return {"id": uid, **user, "_links": links}
@app.put("/users/{uid}")
def replace(uid: int, user: UserIn, response: Response):
if uid not in db:
response.status_code = 201 # PUT on a missing resource creates it
active = db.get(uid, {}).get("active", True) # field managed by the server: the client does not replace it
db[uid] = {**user.model_dump(), "active": active} # COMPLETE replacement
return {"id": uid, **db[uid]}
@app.delete("/users/{uid}", status_code=204)
def remove(uid: int):
if uid not in db:
raise HTTPException(404, "user not found")
del db[uid]
client = TestClient(app)
def show(label, r):
extra = f" Location={r.headers['location']}" if "location" in r.headers else ""
print(f"{label:<34} -> {r.status_code}{extra}")
show("POST /users", client.post("/users", json={"name": "Anna", "email": "anna@example.com"}))
show("POST /users (email missing)", client.post("/users", json={"name": "Bruno"}))
print("GET /users/123 ->", client.get("/users/123").json()["_links"])
show("PUT /users/123 without phone", client.put("/users/123", json={"name": "Federico C.", "email": "federico@example.com"}))
print(" phone after the PUT:", client.get("/users/123").json()["phone"])
show("PUT /users/900 (does not exist)", client.put("/users/900", json={"name": "Nuovo", "email": "n@example.com"}))
show("DELETE /users/900 (1st time)", client.delete("/users/900"))
show("DELETE /users/900 (2nd time)", client.delete("/users/900"))
# Output (python status.py):
# POST /users -> 201 Location=/users/124
# POST /users (email missing) -> 422
# GET /users/123 -> {'self': {'href': '/users/123'}, 'deactivate': {'href': '/users/123/deactivate', 'method': 'POST'}}
# PUT /users/123 without phone -> 200
# phone after the PUT: None
# PUT /users/900 (does not exist) -> 201
# DELETE /users/900 (1st time) -> 204
# DELETE /users/900 (2nd time) -> 404
Read the output line by line. The POST without email answers 422 (Pydantic does it, before your code even runs). The PUT on /users/123 without
phone leaves phone as None: that is full replacement, and the omitted field is not preserved. The PUT on an address that did not exist
answers 201, in accordance with RFC 9110 (section 9.3.4), which for PUT provides 201 if it creates and 200 or 204 if it modifies. Finally, the second DELETE
on the same address answers 404: the effect is identical, the code is not. The active field stays as it was because it is managed by the server and is not part of the
representation the client sends.
PATCH: partial does not mean idempotent
For partial changes there is JSON Patch (RFC 6902), a document that lists operations such as replace, add and
remove on JSON paths. The listing uses the jsonpatch library to apply two patches to a user and, for each, compares the result of one
application with that of two consecutive applications. The first patch replaces a field, the second adds an element at the end of an array
(the path /tags/-, where the dash means "at the end").
# patch.py - PUT, JSON Patch (RFC 6902) and idempotence: does repeating the same request change the result?
import copy
import jsonpatch
utente = {"name": "Federico", "phone": "+39 333 0000000", "tags": ["blog"]}
def applica(doc: dict, ops: list) -> dict:
return jsonpatch.apply_patch(copy.deepcopy(doc), ops) # works on a copy
sostituisci = [{"op": "replace", "path": "/name", "value": "Federico C."}]
aggiungi = [{"op": "add", "path": "/tags/-", "value": "api"}] # "-" = at the end of the array
for etichetta, ops in [("replace /name", sostituisci), ("add /tags/- ", aggiungi)]:
una = applica(utente, ops)
due = applica(una, ops)
print(f"{etichetta}: once = twice? {una == due} (twice: {due})")
# Output (python patch.py):
# replace /name: once = twice? True (twice: {'name': 'Federico C.', 'phone': '+39 333 0000000', 'tags': ['blog']})
# add /tags/- : once = twice? False (twice: {'name': 'Federico', 'phone': '+39 333 0000000', 'tags': ['blog', 'api', 'api']})
Replacing a value is idempotent: repeating it changes nothing. Appending to an array is not: the second application produces a duplicate
(['blog', 'api', 'api']). This is why RFC 5789 does not attribute idempotence to PATCH in general, and why an automatic client retry is
safe only if the set of operations is. The same RFC suggests, to avoid collisions between two PATCH requests, a conditional request with a strong ETag in the
If-Match header, which is the subject of a later section. RFC 6902 also specifies that with the HTTP PATCH method the application is atomic (as RFC 5789 establishes): if an operation fails, the document stays unchanged.
The status codes worth using well
Returning 200 for everything and putting the error in the body takes away from clients, proxies and monitoring tools the cheapest piece of information they have. The table collects the codes you need in a resource API, with the rule for choosing. All are defined in RFC 9110 except those indicated.
| Code | When | Detail to remember |
|---|---|---|
| 200 / 204 | Successful read / successful operation with no body | PUT and DELETE can use either |
| 201 | A resource has been created | With the Location header; applies to POST and to a PUT that creates |
| 202 | Accepted but not yet carried out | For asynchronous jobs; indicates where to check the outcome |
| 304 | The client's copy is still valid | Response to a conditional request, with no body |
| 400 / 422 | Malformed request / correct syntax but content that cannot be processed | In RFC 9110, 422 is called "Unprocessable Content" |
| 401 / 403 | Not authenticated / authenticated but not authorised | 401 requires the WWW-Authenticate header |
| 404 / 409 | Missing resource / conflict with the current state | An email that is already registered is a 409 |
| 412 / 428 | Precondition failed / precondition required | 428 is defined in RFC 6585 |
| 429 | Too many requests | RFC 6585: may include Retry-After |
Two pairs deserve attention. 401 and 403 are confused all the time: the first says "I do not know who you are", the second "I know who you are and you cannot". And 400 versus 422: in practice it is the difference between a JSON that cannot be read and one that can be read but does not respect the domain rules, and the consistency with which you apply it matters more than the choice itself. Finally, 5xx codes indicate a server problem and should never come out for a client error.
ETag and conditional requests
The ETag is the opaque identifier of a version of the resource, written between quotes and, if you want, preceded by W/ if weak (RFC 9110, section 8.8.3). It serves two purposes
that the listing puts to the test. The first is caching: the client sends the value back in If-None-Match and, if the resource has not changed, receives 304 with no body.
The second is optimistic concurrency: with If-Match on the PUT, the server accepts the change only if the version is the one the client read, and
otherwise answers 412. RFC 9110 names protection from the "lost update" as the typical use of If-Match.
# etag.py - conditional cache (If-None-Match) and optimistic concurrency (If-Match)
import hashlib
import json
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
app = FastAPI()
user = {"id": 123, "name": "Federico", "version": 3}
def etag_of(data: dict) -> str:
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:16]
return f'"{digest}"' # the quotes are part of the value (RFC 9110, 8.8.3)
@app.get("/users/123")
def read(request: Request):
tag = etag_of(user)
headers = {"ETag": tag, "Cache-Control": "max-age=300"}
if request.headers.get("if-none-match") == tag:
return Response(status_code=304, headers=headers) # no body: the client reuses its copy
return JSONResponse(user, headers=headers)
@app.put("/users/123")
async def replace(request: Request):
if "if-match" not in request.headers:
return JSONResponse({"title": "If-Match required"}, status_code=428) # RFC 6585
if request.headers["if-match"] != etag_of(user):
return JSONResponse({"title": "The resource has changed"}, status_code=412)
user.update(await request.json(), version=user["version"] + 1)
return JSONResponse(user, headers={"ETag": etag_of(user)})
c = TestClient(app)
r1 = c.get("/users/123")
tag = r1.headers["etag"]
print("1. GET ->", r1.status_code, "ETag", tag, "| body", len(r1.content), "bytes")
r2 = c.get("/users/123", headers={"If-None-Match": tag})
print("2. GET + If-None-Match ->", r2.status_code, "| body", len(r2.content), "bytes")
print("3. PUT without If-Match ->", c.put("/users/123", json={"name": "A"}).status_code)
r4 = c.put("/users/123", json={"name": "Federico C."}, headers={"If-Match": tag})
print("4. PUT with correct If-Match ->", r4.status_code, "new ETag", r4.headers["etag"])
print("5. PUT with old ETag ->", c.put("/users/123", json={"name": "Other"}, headers={"If-Match": tag}).status_code)
print("6. GET with old ETag ->", c.get("/users/123", headers={"If-None-Match": tag}).status_code)
# Output (python etag.py):
# 1. GET -> 200 ETag "1ae37d8b82f9ec8d" | body 40 bytes
# 2. GET + If-None-Match -> 304 | body 0 bytes
# 3. PUT without If-Match -> 428
# 4. PUT with correct If-Match -> 200 new ETag "21249ef44761a296"
# 5. PUT with old ETag -> 412
# 6. GET with old ETag -> 200
Step 2 shows the advantage of caching: 0 bytes of body instead of 40. Steps 4 and 5 show optimistic concurrency: the first change goes through and changes the ETag, the second,
made with the old ETag, is rejected with 412 and the client must reread before trying again. Step 3 uses the 428 of RFC 6585 to demand that the request be
conditional: it is a designer's choice, useful when a change without If-Match would be dangerous. The comparison in the listing is a string equality, for
simplicity: the RFC prescribes weak comparison for If-None-Match and strong comparison for If-Match, and allows lists of ETags, so in production
a ready-made implementation from the framework is preferable.
A real case from this site's backend, read in the code: DatasetSchemaController builds the ETag from the dataset's SHA-256 checksum (wrapped in quotes) and
pairs it with Cache-Control: max-age=3600, public. In that method there is no comparison with If-None-Match: whether the 304 is produced by another layer I have not verified,
so I do not claim it.
Consistent errors: Problem Details and Retry-After
A single structure for errors saves time for whoever consumes the API. RFC 9457 (which replaced RFC 7807) defines the content type
application/problem+json and five standard members: type (a URI identifying the problem type; if missing it is about:blank),
title, status, detail and instance (the specific occurrence). You can add extension members, such as email in the
listing. The status member is informative and must match the real HTTP code. The listing shows a 409 for a duplicate email, a 429 with Retry-After
(RFC 6585 and RFC 9110, section 10.2.3) and pagination with the Link header (RFC 8288).
# problem.py - errors as Problem Details (RFC 9457), 429 with Retry-After, pagination with Link (RFC 8288)
import json
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
app = FastAPI()
emails = {"federico@example.com"}
PROBLEM = "application/problem+json"
ITEMS = list(range(1, 26)) # 25 items to paginate
def problem(status: int, type_: str, title: str, detail: str, **extra):
body = {"type": type_, "title": title, "status": status, "detail": detail, **extra}
return JSONResponse(body, status_code=status, media_type=PROBLEM)
@app.post("/users")
async def create(request: Request):
data = await request.json()
if data.get("email") in emails:
return problem(409, "https://api.example.com/problems/duplicate-email", "Email already registered",
"An account with this email already exists.", email=data["email"]) # "email" is an extension member
return JSONResponse({"ok": True}, status_code=201)
@app.get("/limited")
def limited():
r = problem(429, "https://api.example.com/problems/rate-limited", "Too many requests",
"You have exceeded the limit: try again in 60 seconds.")
r.headers["Retry-After"] = "60" # in seconds (RFC 9110, 10.2.3)
return r
@app.get("/items")
def items(request: Request, page: int = 1, size: int = 10):
start = (page - 1) * size
last = -(-len(ITEMS) // size)
base = str(request.url.remove_query_params(["page", "size"]))
sep = "&" if "?" in base else "?"
links = []
if page < last:
links.append(f'<{base}{sep}page={page + 1}&size={size}>; rel="next"')
if page > 1:
links.append(f'<{base}{sep}page={page - 1}&size={size}>; rel="prev"')
return JSONResponse(ITEMS[start:start + size], headers={"Link": ", ".join(links)} if links else {})
c = TestClient(app)
r = c.post("/users", json={"email": "federico@example.com"})
print(r.status_code, r.headers["content-type"])
print(json.dumps(r.json(), ensure_ascii=False, indent=2))
r = c.get("/limited")
print(r.status_code, "Retry-After:", r.headers["retry-after"], "|", r.headers["content-type"])
r = c.get("/items?page=2&size=10")
print(r.status_code, "Link:", r.headers["link"])
print("last page, Link:", c.get("/items?page=3&size=10").headers["link"])
# Output (python problem.py):
# 409 application/problem+json
# {
# "type": "https://api.example.com/problems/duplicate-email",
# "title": "Email already registered",
# "status": 409,
# "detail": "An account with this email already exists.",
# "email": "federico@example.com"
# }
# 429 Retry-After: 60 | application/problem+json
# 200 Link: <http://testserver/items?page=3&size=10>; rel="next", <http://testserver/items?page=1&size=10>; rel="prev"
# last page, Link: <http://testserver/items?page=2&size=10>; rel="prev"
The output shows the application/problem+json content type on the 409 and the 429, and the value of Retry-After in seconds in the header, where a generic
client looks for it, and not only in a body field. Pagination with Link follows the same principle: the addresses of the adjacent pages travel in
a standard header with rel="next" and rel="prev", and on the last page only prev remains. The advantage over page numbers built by the client is that
the address of the next page is decided by the server, and it can change scheme (for example moving to cursors) without breaking clients.
For comparison, this site's backend does not use Problem Details: GlobalExceptionHandler (36 methods annotated @ExceptionHandler, counted with grep -c) returns
its own envelope, ApiEnvelope, with success, data, error (code, message, details) and meta, and a search for
problem+json in the Java code finds nothing. It is a valid and consistent choice; the point of this section is that whatever format you choose, there must be only one.
In some controllers (for example BoardDeckController) the 429 also carries the Retry-After header, computed from the rate limiter's bucket.
For traffic-limiting mechanisms see API Security: OAuth 2.1, JWT and Rate Limiting.
Versioning: three strategies and their costs
Once clients depend on your contract, changing it breaks them. The strategies have different costs, and none removes the need to communicate the end of a version.
| Strategy | What it looks like | Advantage | Cost |
|---|---|---|---|
| In the URL | /v1/users, /v2/users | Visible, easy to log and to cache (the URL is different) | More addresses to maintain; the version is not part of the resource |
| In the header | Accept: application/vnd.example.v2+json | Stable addresses | Needs Vary: Accept so that caches tell versions apart; less visible when debugging |
| Additive changes only | new fields without a new version | No migration for clients | Does not remove the need for a version for removals and type changes; assumes clients that ignore unknown fields |
No strategy always wins: it depends on how many clients you have and how much you control them. Additive changes are the cheapest starting point, but they work only if clients tolerate
fields they do not know, something to verify and not to assume. The following listing shows two operational things. The first: a version being phased out is announced with the
Deprecation header (RFC 9745, the date as a Unix timestamp preceded by @) and Sunset (RFC 8594, an HTTP date), plus a link with
rel="deprecation" to the migration guide. The second: if the version lives in the Accept header, the response must carry Vary: Accept, because
otherwise a shared cache may serve a client the version requested by another (RFC 9111, section 4.1).
# versioning.py - two versions in parallel: in the URL, and in the Accept header
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/v1/users/123") # old version: still served, but announced as deprecated
def v1():
return JSONResponse(
{"id": 123, "name": "Federico"},
headers={
"Deprecation": "@1782863999", # deprecation date, as a Unix timestamp (RFC 9745)
"Sunset": "Wed, 30 Jun 2027 23:59:59 GMT", # when it will stop responding (RFC 8594)
"Link": '<https://api.example.com/docs/migration-v2>; rel="deprecation"; type="text/html"',
},
)
@app.get("/v2/users/123")
def v2():
return {"id": 123, "name": "Federico", "createdAt": "2025-01-15T10:30:00Z"}
@app.get("/users/123") # same URL, version chosen by the client with Accept
def negotiated(request: Request):
accept = request.headers.get("accept", "")
v = 2 if "application/vnd.example.v2+json" in accept else 1
body = {"id": 123, "name": "Federico"} | ({"createdAt": "2025-01-15T10:30:00Z"} if v == 2 else {})
# Vary tells caches that the response depends on Accept: without it, a cache could serve v1 to whoever asks for v2
return JSONResponse(body, headers={"Vary": "Accept"}, media_type=f"application/vnd.example.v{v}+json")
c = TestClient(app)
h = c.get("/v1/users/123").headers
for name in ("deprecation", "sunset", "link"):
print(f"/v1 {name:<12}: {h[name]}")
a = c.get("/users/123", headers={"Accept": "application/vnd.example.v2+json"})
b = c.get("/users/123")
print("Accept v2 ->", a.headers["content-type"], "| Vary:", a.headers["vary"], "| fields:", sorted(a.json()))
print("without Accept ->", b.headers["content-type"], "| fields:", sorted(b.json()))
# Output (python versioning.py):
# /v1 deprecation : @1782863999
# /v1 sunset : Wed, 30 Jun 2027 23:59:59 GMT
# /v1 link : <https://api.example.com/docs/migration-v2>; rel="deprecation"; type="text/html"
# Accept v2 -> application/vnd.example.v2+json | Vary: Accept | fields: ['createdAt', 'id', 'name']
# without Accept -> application/vnd.example.v1+json | fields: ['id', 'name']
The three headers of /v1 are the ones an automated client can read to warn you before the version disappears. The dates in the listing (30 June 2026 for deprecation, 30 June
2027 for shutdown) are an example of mine: in your API the dates are a promise you must be able to keep. It makes sense for Sunset to fall after the deprecation, and announcing the end of a version before shutting it down
costs far less than collecting the clients' complaints afterwards.
Documenting the contract with OpenAPI 3.1
An OpenAPI document describes the API in a format that different tools can read: interactive documentation, client generation, conformance tests. The document below describes
two operations (a paginated list and the creation of users), uses components so as not to repeat schemas, declares the Link header of the 200 response and the
error responses as application/problem+json. The final block shows the output of a validator (the openapi-spec-validator library, installed in a temporary
folder) run on this same file.
# openapi.yaml - minimal but complete OpenAPI 3.1 document: every $ref points to something that exists
openapi: 3.1.0
info:
title: User Management API
version: 1.2.0
license:
name: MIT
identifier: MIT
servers:
- url: https://api.example.com/v1
paths:
/users:
get:
operationId: listUsers
summary: List of users, paginated
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
responses:
'200':
description: A page of users
headers:
Link:
description: Adjacent pages, with rel="next" and rel="prev" (RFC 8288)
schema: { type: string }
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/User' } }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createUser
summary: Create a user
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateUserRequest' }
responses:
'201':
description: User created
headers:
Location: { schema: { type: string }, description: URL of the new user }
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
'400': { $ref: '#/components/responses/BadRequest' }
'409':
description: Email already registered
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
components:
schemas:
User:
type: object
required: [id, name, email]
properties:
id: { type: integer, format: int64, readOnly: true }
name: { type: string, minLength: 1, maxLength: 100 }
email: { type: string, format: email }
CreateUserRequest:
type: object
required: [name, email, password]
properties:
name: { type: string, minLength: 1, maxLength: 100 }
email: { type: string, format: email }
password: { type: string, minLength: 8, writeOnly: true }
Problem:
type: object
properties:
type: { type: string, format: uri-reference }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string, format: uri-reference }
additionalProperties: true
responses:
Unauthorized:
description: Missing or invalid credentials
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
BadRequest:
description: Malformed request or invalid data
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
security:
- bearerAuth: []
# Output (python validate_openapi.py openapi.yaml):
# openapi.yaml: valid, OpenAPI 3.1.0, 2 operations
The file is valid, and this matters more than it seems: every $ref points to something that exists. If you remove
the components.responses section from this file, the validator fails with PointerToNowhere: '/components/responses/Unauthorized' does not exist, which is the error a hand-written
document makes most often. The schemas use two JSON Schema keywords: readOnly on the identifier, assigned by the server (the specification gives exactly the example of the serial number generated by a database), and writeOnly on the password,
which can be sent but never appears when the resource is read.
What you can do now
You can place an API in the Richardson model and explain why level 2 is almost always enough. You can choose between PUT and PATCH knowing what each promises in terms of idempotence, and you know the difference between safe and
idempotent. You can use ETag to save bandwidth with the 304 and to avoid lost updates with the 412, return errors in application/problem+json with Retry-After on 429s,
paginate with Link, announce the end of a version with Deprecation and Sunset, and validate your OpenAPI document with a tool. The next
step, for the flexible reading of data, is GraphQL and the N+1 problem.
Frequently asked questions
PUT or PATCH to update a resource?
PUT when the client sends the entire representation: omitted fields are reset and the method is idempotent. PATCH when you want to change only some fields, bearing in mind that it is not idempotent in general: a patch that appends an element to an array, applied twice, duplicates it.
When should I use 400 and when 422?
400 when the request is malformed, for example a JSON that cannot be read. 422 (in RFC 9110 "Unprocessable Content") when the syntax is correct but the content cannot be processed, such as a field that violates a rule. Consistency matters: choose a convention and apply it everywhere.
Do you really need HATEOAS in a REST API?
In most cases no. Level 3 of the Richardson model makes responses larger and requires clients that follow links. It makes sense when addresses must be able to change without breaking clients you do not control; for an API used by your own frontend, level 2 with an OpenAPI document is enough.
Sources
- RFC 9110, HTTP Semantics - safe and idempotent methods (9.2), PUT and DELETE (9.3.4, 9.3.5), ETag and If-Match (8.8.3, 13.1.1), 412 and 422, Retry-After (10.2.3), consulted on 2026-11-12
- RFC 9111, HTTP Caching - the Vary field in the computation of the cache key (section 4.1), consulted on 2026-11-12
- RFC 5789, PATCH Method for HTTP - PATCH neither safe nor idempotent, but usable in an idempotent way with If-Match, consulted on 2026-11-12
- RFC 6902, JSON Patch - add, remove and replace operations and appending at the end with
/-, consulted on 2026-11-12 - RFC 6585, Additional HTTP Status Codes - 428 Precondition Required and 429 Too Many Requests with Retry-After, consulted on 2026-11-12
- RFC 9457, Problem Details for HTTP APIs - content type, standard members and extension members, consulted on 2026-11-12
- RFC 8288, Web Linking - syntax of the Link header and of the next relation, consulted on 2026-11-12
- RFC 9745, The Deprecation HTTP Response Header Field - the date as a Unix timestamp preceded by @ and the rel="deprecation" link, consulted on 2026-11-12
- RFC 8594, The Sunset HTTP Header Field - HTTP shutdown date, consulted on 2026-11-12
- Martin Fowler, Richardson Maturity Model - the levels of the model and the role of a safe GET for caching, 18 March 2010, consulted on 2026-11-12
- OpenAPI Specification v3.1.0 - document structure and the Components object, consulted on 2026-11-12
- JSON Schema Validation, draft 2020-12 - meaning of
readOnlyandwriteOnly(section 9.4), consulted on 2026-11-12
The six listings were run with FastAPI 0.136.3, Pydantic 2.13.4, Starlette 1.2.1 and Python 3.12; jsonpatch 1.47 and openapi-spec-validator were installed in a temporary folder. The output shown is identical across two consecutive runs. The pages of this site's backend that are cited were read in the code, not in documentation.

Commenti
Caricamento commenti...
Sign in to comment