Molte API che si dichiarano «RESTful» usano HTTP solo come tubo: un solo indirizzo, un solo metodo, e un campo nel corpo che dice che cosa fare. Funzionano, ma rinunciano a ciò che HTTP già offre: cache, ripetizione sicura delle richieste, codici di stato che i proxy e gli strumenti di monitoraggio capiscono. La risposta pratica di questo articolo è che un'API REST professionale si ferma al livello 2 del modello di maturità di Richardson (risorse, metodi e codici di stato usati con il loro significato), aggiunge ETag per cache e aggiornamenti concorrenti, restituisce gli errori come Problem Details (RFC 9457) e documenta il contratto con OpenAPI 3.1.
Parto dal modello e dalla semantica dei metodi, poi passo a codici di stato, ETag, errori, paginazione e versionamento. Ogni listato
è stato eseguito in processo con FastAPI e il suo TestClient, senza server in ascolto, e le uscite riportate sono quelle reali. Questo
articolo segue il panorama delle API: se non sai ancora se REST sia la scelta
giusta per il tuo caso, parti da lì. Se FastAPI è nuovo per te, il suo setup è in
FastAPI da zero.
I quattro livelli del modello di Richardson
Il modello descrive quanto un'API sfrutta davvero il web. Lo ha sviluppato Leonard Richardson, che lo ha presentato in un intervento a QCon, e lo ha reso popolare Martin Fowler nel suo articolo del 2010, che lo riassume in tre passi (risorse, verbi HTTP, controlli ipermediali) sopra un livello zero di partenza. Il valore del modello è diagnostico: ti fa vedere in quale punto la tua API smette di usare HTTP per ciò che sa fare.
| Livello | Che cosa introduce | Aspetto di una richiesta |
|---|---|---|
| 0 | HTTP come semplice trasporto | POST /api con {"action": "getUser", "id": 123} nel corpo |
| 1 | Risorse con un proprio indirizzo | indirizzi come /users/123, ma ancora quasi tutto in POST |
| 2 | Metodi HTTP e codici di stato con il loro significato | GET /users/123 risponde 200 o 404; POST /users risponde 201 |
| 3 | Controlli ipermediali (HATEOAS): la risposta dice quali azioni sono possibili | la risposta contiene link come deactivate, presenti solo se l'azione è consentita |
Nel livello 2 stanno le decisioni che contano di più. Come nota Fowler, usare GET per le letture è decisivo perché HTTP lo definisce
sicuro e permette a qualunque intermediario di mettere in cache la risposta. Il livello 3 è meno diffuso: la risposta indica al client che cosa può fare dopo, invece di costringerlo a
conoscere gli indirizzi a priori. Il listato della sezione successiva lo mostra con un caso minimo (il link deactivate compare solo per un utente
attivo). Il costo è tangibile: risposte più grandi e client che devono saper seguire i link. Per un'API usata da un solo frontend che controlli, spesso il rendimento è basso;
ha più senso quando gli indirizzi devono poter cambiare senza rompere client che non controlli.
Metodi sicuri e idempotenti: che cosa promettono davvero
Due proprietà dei metodi decidono se una richiesta si può ripetere senza danni, e vanno distinte. Un metodo è sicuro se il suo significato è di sola lettura: il client non chiede e non si aspetta un cambiamento di stato (RFC 9110, sezione 9.2.1). È idempotente se l'effetto voluto di più richieste identiche è lo stesso di una sola (sezione 9.2.2). La RFC elenca fra i metodi idempotenti PUT, DELETE e tutti quelli sicuri; POST non lo è. La conseguenza pratica, scritta nella stessa sezione, è che un client può ripetere automaticamente una richiesta idempotente se la connessione cade prima della risposta, e non dovrebbe farlo con un metodo che non lo è.
| Metodo | Sicuro | Idempotente | Nota |
|---|---|---|---|
| GET, HEAD, OPTIONS | sì | sì | Letture; HEAD ottiene solo le intestazioni |
| POST | no | no | Creazione e azioni; ripeterlo può creare due risorse |
| PUT | no | sì | Crea o sostituisce l'intera rappresentazione |
| PATCH | no | non in generale | La RFC 5789 dice che non è né sicuro né idempotente, ma può essere usato in modo idempotente |
| DELETE | no | sì | Un eventuale corpo non ha una semantica definita (RFC 9110, sezione 9.3.5) |
La tabella corregge due imprecisioni che circolano. La prima: PATCH non diventa idempotente solo perché si usa JSON Patch, come il listato qui sotto dimostra con un caso concreto. La seconda riguarda DELETE: l'idempotenza riguarda l'effetto sul server, non il codice di risposta, per cui una seconda cancellazione risponde 404 e va bene lo stesso, perché la risorsa è assente come voleva il client. Se ti serve ripetere un POST senza duplicati, la soluzione è una chiave di idempotenza scelta dal client e controllata dal server: la tecnica, vista dal lato dei consumer di eventi, è in Idempotenza nei consumer.
Le richieste in azione: creare, sostituire, cancellare
Il listato seguente è un'API minima di utenti con quattro rotte. Le righe che contano sono quattro. In create, l'intestazione
Location dice dove trovare la risorsa appena creata, accompagnando lo stato 201. In read, il link deactivate viene aggiunto
solo se user["active"] è vero: è l'idea del livello 3 ridotta all'osso. In replace, il PUT sostituisce tutta la rappresentazione e, se la
risorsa non esiste, la crea rispondendo 201 invece di 200. In remove, DELETE risponde 204 senza corpo. Le chiamate in coda
stampano gli esiti.
# status.py - metodi HTTP e codici di stato, provati in processo con 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 # opzionale: se manca, vale 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}" # dove trovare la risorsa creata
return {"id": new_id, **db[new_id]}
@app.get("/users/{uid}")
def read(uid: int):
if uid not in db:
raise HTTPException(404, "utente inesistente")
user = db[uid]
links = {"self": {"href": f"/users/{uid}"}}
if user["active"]: # il link compare solo se l'azione è possibile
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 su una risorsa assente la crea
active = db.get(uid, {}).get("active", True) # campo gestito dal server: non lo sostituisce il client
db[uid] = {**user.model_dump(), "active": active} # sostituzione COMPLETA
return {"id": uid, **db[uid]}
@app.delete("/users/{uid}", status_code=204)
def remove(uid: int):
if uid not in db:
raise HTTPException(404, "utente inesistente")
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 (manca email)", client.post("/users", json={"name": "Bruno"}))
print("GET /users/123 ->", client.get("/users/123").json()["_links"])
show("PUT /users/123 senza phone", client.put("/users/123", json={"name": "Federico C.", "email": "federico@example.com"}))
print(" phone dopo il PUT:", client.get("/users/123").json()["phone"])
show("PUT /users/900 (non esiste)", client.put("/users/900", json={"name": "Nuovo", "email": "n@example.com"}))
show("DELETE /users/900 (1a volta)", client.delete("/users/900"))
show("DELETE /users/900 (2a volta)", client.delete("/users/900"))
# Output (python status.py):
# POST /users -> 201 Location=/users/124
# POST /users (manca email) -> 422
# GET /users/123 -> {'self': {'href': '/users/123'}, 'deactivate': {'href': '/users/123/deactivate', 'method': 'POST'}}
# PUT /users/123 senza phone -> 200
# phone dopo il PUT: None
# PUT /users/900 (non esiste) -> 201
# DELETE /users/900 (1a volta) -> 204
# DELETE /users/900 (2a volta) -> 404
Leggi l'uscita riga per riga. Il POST senza email risponde 422 (lo fa Pydantic, prima ancora del tuo codice). Il PUT su /users/123 senza
phone lascia phone a None: è la sostituzione completa, e il campo omesso non si conserva. Il PUT su un indirizzo che non esisteva
risponde 201, in accordo con la RFC 9110 (sezione 9.3.4), che per PUT prevede 201 se crea e 200 o 204 se modifica. Infine, il secondo DELETE
sullo stesso indirizzo risponde 404: l'effetto è identico, il codice no. Il campo active resta com'era perché lo gestisce il server e non fa parte della
rappresentazione che il client invia.
PATCH: parziale non vuol dire idempotente
Per le modifiche parziali esiste JSON Patch (RFC 6902), un documento che elenca operazioni come replace, add e
remove su percorsi del JSON. Il listato usa la libreria jsonpatch per applicare due patch a un utente e, per ciascuna, confronta il risultato di una
applicazione con quello di due applicazioni consecutive. La prima patch sostituisce un campo, la seconda aggiunge un elemento in coda a un array
(il percorso /tags/-, dove il trattino significa «in fondo»).
# patch.py - PUT, JSON Patch (RFC 6902) e idempotenza: ripetere la stessa richiesta cambia il risultato?
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) # lavora su una copia
sostituisci = [{"op": "replace", "path": "/name", "value": "Federico C."}]
aggiungi = [{"op": "add", "path": "/tags/-", "value": "api"}] # "-" = in coda all'array
for etichetta, ops in [("replace /name", sostituisci), ("add /tags/- ", aggiungi)]:
una = applica(utente, ops)
due = applica(una, ops)
print(f"{etichetta}: una volta = due volte? {una == due} (due volte: {due})")
# Output (python patch.py):
# replace /name: una volta = due volte? True (due volte: {'name': 'Federico C.', 'phone': '+39 333 0000000', 'tags': ['blog']})
# add /tags/- : una volta = due volte? False (due volte: {'name': 'Federico', 'phone': '+39 333 0000000', 'tags': ['blog', 'api', 'api']})
La sostituzione di un valore è idempotente: ripeterla non cambia nulla. L'aggiunta in coda a un array no: la seconda applicazione produce un duplicato
(['blog', 'api', 'api']). È il motivo per cui la RFC 5789 non attribuisce l'idempotenza a PATCH in generale, e per cui una ripetizione automatica del client è
sicura solo se l'insieme delle operazioni lo è. La stessa RFC suggerisce, per evitare collisioni fra due PATCH, una richiesta condizionale con un ETag forte nell'intestazione
If-Match, che è l'argomento di una sezione più avanti. La RFC 6902 precisa inoltre che con il metodo HTTP PATCH l'applicazione è atomica (come stabilisce la RFC 5789): se un'operazione non riesce, il documento resta invariato.
I codici di stato che conviene usare bene
Restituire 200 per tutto e mettere l'errore nel corpo toglie a client, proxy e strumenti di monitoraggio l'informazione più economica che hanno. La tabella raccoglie i codici che servono in un'API di risorse, con la regola di scelta. Tutti sono definiti nella RFC 9110 salvo quelli indicati.
| Codice | Quando | Dettaglio da ricordare |
|---|---|---|
| 200 / 204 | Lettura riuscita / operazione riuscita senza corpo | PUT e DELETE possono usare entrambi |
| 201 | È stata creata una risorsa | Con l'intestazione Location; vale per POST e per PUT che crea |
| 202 | Accettato ma non ancora eseguito | Per lavori asincroni; indica dove controllare l'esito |
| 304 | La copia del client è ancora valida | Risposta a una richiesta condizionale, senza corpo |
| 400 / 422 | Richiesta malformata / sintassi corretta ma contenuto non elaborabile | Il 422 nella RFC 9110 si chiama «Unprocessable Content» |
| 401 / 403 | Non autenticato / autenticato ma non autorizzato | Il 401 richiede l'intestazione WWW-Authenticate |
| 404 / 409 | Risorsa assente / conflitto con lo stato corrente | Un'email già registrata è un 409 |
| 412 / 428 | Precondizione fallita / precondizione richiesta | Il 428 è definito nella RFC 6585 |
| 429 | Troppe richieste | RFC 6585: può includere Retry-After |
Due coppie meritano attenzione. 401 e 403 si confondono di continuo: il primo dice «non so chi sei», il secondo «so chi sei e non puoi». E 400 contro 422: nella pratica è la differenza fra un JSON che non si legge e uno che si legge ma non rispetta le regole di dominio, e conta più la coerenza con cui la applichi che la scelta in sé. I codici 5xx, infine, indicano un problema del server e non dovrebbero mai uscire per un errore del client.
ETag e richieste condizionali
L'ETag è l'identificativo opaco di una versione della risorsa, scritto fra virgolette e, volendo, preceduto da W/ se debole (RFC 9110, sezione 8.8.3). Serve a due cose
che il listato mette alla prova. La prima è la cache: il client rimanda il valore in If-None-Match e, se la risorsa non è cambiata, riceve 304 senza corpo.
La seconda è la concorrenza ottimistica: con If-Match sul PUT, il server accetta la modifica solo se la versione è quella che il client ha letto, e
altrimenti risponde 412. La RFC 9110 indica proprio la protezione dal «lost update» come uso tipico di If-Match.
# etag.py - cache condizionale (If-None-Match) e concorrenza ottimistica (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}"' # le virgolette fanno parte del valore (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) # niente corpo: il client riusa la copia
return JSONResponse(user, headers=headers)
@app.put("/users/123")
async def replace(request: Request):
if "if-match" not in request.headers:
return JSONResponse({"title": "Serve If-Match"}, status_code=428) # RFC 6585
if request.headers["if-match"] != etag_of(user):
return JSONResponse({"title": "La risorsa è cambiata"}, 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, "| corpo", len(r1.content), "byte")
r2 = c.get("/users/123", headers={"If-None-Match": tag})
print("2. GET + If-None-Match ->", r2.status_code, "| corpo", len(r2.content), "byte")
print("3. PUT senza 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 con If-Match corretto ->", r4.status_code, "nuovo ETag", r4.headers["etag"])
print("5. PUT con ETag vecchio ->", c.put("/users/123", json={"name": "Altro"}, headers={"If-Match": tag}).status_code)
print("6. GET con ETag vecchio ->", c.get("/users/123", headers={"If-None-Match": tag}).status_code)
# Output (python etag.py):
# 1. GET -> 200 ETag "1ae37d8b82f9ec8d" | corpo 40 byte
# 2. GET + If-None-Match -> 304 | corpo 0 byte
# 3. PUT senza If-Match -> 428
# 4. PUT con If-Match corretto -> 200 nuovo ETag "21249ef44761a296"
# 5. PUT con ETag vecchio -> 412
# 6. GET con ETag vecchio -> 200
Il passo 2 mostra il vantaggio della cache: 0 byte di corpo invece di 40. I passi 4 e 5 mostrano la concorrenza ottimistica: la prima modifica passa e cambia l'ETag, la seconda,
fatta con l'ETag vecchio, viene respinta con 412 e il client deve rileggere prima di riprovare. Il passo 3 usa il 428 della RFC 6585 per esigere che la richiesta sia
condizionale: è una scelta del progettista, utile quando una modifica senza If-Match sarebbe pericolosa. Il confronto del listato è un'uguaglianza fra stringhe, per
semplicità: la RFC prescrive il confronto debole per If-None-Match e il forte per If-Match, e ammette elenchi di ETag, quindi in produzione
conviene un'implementazione già pronta del framework.
Un caso reale dal backend di questo sito, letto nel codice: DatasetSchemaController costruisce l'ETag a partire dal checksum SHA-256 del dataset (racchiuso fra virgolette) e
lo abbina a Cache-Control: max-age=3600, public. In quel metodo non c'è il confronto con If-None-Match: se il 304 venga prodotto da un altro strato non l'ho verificato,
quindi non lo affermo.
Errori coerenti: Problem Details e Retry-After
Una struttura unica per gli errori fa risparmiare tempo a chi consuma l'API. La RFC 9457 (che ha sostituito la RFC 7807) definisce il tipo di contenuto
application/problem+json e cinque membri standard: type (un URI che identifica il tipo di problema; se manca vale about:blank),
title, status, detail e instance (l'occorrenza specifica). Si possono aggiungere membri di estensione, come email nel
listato. Il membro status è informativo e deve coincidere con il codice HTTP vero. Il listato mostra un 409 per un'email duplicata, un 429 con Retry-After
(RFC 6585 e RFC 9110, sezione 10.2.3) e la paginazione con l'intestazione Link (RFC 8288).
# problem.py - errori come Problem Details (RFC 9457), 429 con Retry-After, paginazione con 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 elementi da paginare
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 già registrata",
"Esiste già un account con questa email.", email=data["email"]) # "email" è un membro di estensione
return JSONResponse({"ok": True}, status_code=201)
@app.get("/limited")
def limited():
r = problem(429, "https://api.example.com/problems/rate-limited", "Troppe richieste",
"Hai superato il limite: riprova tra 60 secondi.")
r.headers["Retry-After"] = "60" # in secondi (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("ultima pagina, 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 già registrata",
# "status": 409,
# "detail": "Esiste già un account con questa email.",
# "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"
# ultima pagina, Link: <http://testserver/items?page=2&size=10>; rel="prev"
L'uscita mostra il tipo di contenuto application/problem+json sul 409 e sul 429, e il valore di Retry-After in secondi nell'intestazione, dove un client
generico lo cerca, e non soltanto in un campo del corpo. La paginazione con Link segue lo stesso principio: gli indirizzi delle pagine adiacenti viaggiano in
un'intestazione standard con rel="next" e rel="prev", e sull'ultima pagina resta solo prev. Il vantaggio rispetto ai numeri di pagina costruiti dal client è che
l'indirizzo della pagina successiva lo decide il server, e può cambiare schema (per esempio passare a cursori) senza rompere i client.
Per confronto, il backend di questo sito non usa Problem Details: GlobalExceptionHandler (36 metodi annotati @ExceptionHandler, contati con grep -c) restituisce
una busta propria, ApiEnvelope, con success, data, error (codice, messaggio, dettagli) e meta, e una ricerca di
problem+json nel codice Java non trova nulla. È una scelta valida e coerente; il punto di questa sezione è che qualunque formato scegli, deve essere uno solo.
In alcuni controller (per esempio BoardDeckController) il 429 porta anche l'intestazione Retry-After, calcolata dal bucket del limitatore.
Per i meccanismi di limitazione del traffico vedi API Security: OAuth 2.1, JWT e Rate Limiting.
Versionamento: tre strategie e i loro costi
Una volta che dei client dipendono dal tuo contratto, cambiarlo li rompe. Le strategie hanno costi diversi, e nessuna elimina la necessità di comunicare la fine di una versione.
| Strategia | Come appare | Vantaggio | Costo |
|---|---|---|---|
| Nell'URL | /v1/users, /v2/users | Visibile, facile da loggare e da mettere in cache (l'URL è diverso) | Più indirizzi da mantenere; la versione non è parte della risorsa |
| Nell'intestazione | Accept: application/vnd.example.v2+json | Indirizzi stabili | Serve Vary: Accept perché le cache distinguano le versioni; meno visibile nel debug |
| Cambiamenti solo additivi | nuovi campi senza nuova versione | Nessuna migrazione per i client | Non elimina la necessità di una versione per rimozioni e cambi di tipo; presuppone client che ignorano i campi sconosciuti |
Nessuna strategia vince sempre: dipende da quanti client hai e da quanto li controlli. I cambiamenti additivi sono il punto di partenza più economico, ma funzionano solo se i client tollerano
campi che non conoscono, cosa da verificare e non da presumere. Il listato seguente mostra due cose operative. La prima: una versione in via di dismissione si annuncia con le
intestazioni Deprecation (RFC 9745, la data come timestamp Unix preceduto da @) e Sunset (RFC 8594, una data HTTP), più un collegamento con
rel="deprecation" alla guida di migrazione. La seconda: se la versione sta nell'intestazione Accept, la risposta deve portare Vary: Accept, perché
altrimenti una cache condivisa può servire a un client la versione richiesta da un altro (RFC 9111, sezione 4.1).
# versioning.py - due versioni in parallelo: nell'URL, e nell'intestazione Accept
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/v1/users/123") # vecchia versione: ancora servita, ma annunciata come deprecata
def v1():
return JSONResponse(
{"id": 123, "name": "Federico"},
headers={
"Deprecation": "@1782863999", # data di deprecazione, come timestamp Unix (RFC 9745)
"Sunset": "Wed, 30 Jun 2027 23:59:59 GMT", # quando smetterà di rispondere (RFC 8594)
"Link": '<https://api.example.com/docs/migrazione-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") # stessa URL, versione scelta dal client con 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 dice alle cache che la risposta dipende da Accept: senza, una cache potrebbe servire la v1 a chi chiede la 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"], "| campi:", sorted(a.json()))
print("senza Accept ->", b.headers["content-type"], "| campi:", 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/migrazione-v2>; rel="deprecation"; type="text/html"
# Accept v2 -> application/vnd.example.v2+json | Vary: Accept | campi: ['createdAt', 'id', 'name']
# senza Accept -> application/vnd.example.v1+json | campi: ['id', 'name']
Le tre intestazioni del /v1 sono quelle che un client automatico può leggere per avvisarti prima che la versione sparisca. La data nel listato (30 giugno 2026 per la deprecazione, 30 giugno
2027 per lo spegnimento) è un esempio mio: nella tua API le date sono una promessa che devi poter mantenere. Ha senso che il Sunset cada dopo la deprecazione, e annunciare la fine di una versione prima di spegnerla
costa molto meno che raccogliere le proteste dei client dopo.
Documentare il contratto con OpenAPI 3.1
Un documento OpenAPI descrive l'API in un formato che strumenti diversi sanno leggere: documentazione interattiva, generazione di client, test di conformità. Il documento qui sotto descrive
due operazioni (elenco paginato e creazione di utenti), usa components per non ripetere gli schemi, dichiara l'intestazione Link della risposta 200 e le
risposte di errore come application/problem+json. Il blocco finale riporta l'uscita di un validatore (la libreria openapi-spec-validator, installata in una cartella
temporanea) lanciato su questo stesso file.
# openapi.yaml - documento OpenAPI 3.1 minimo ma completo: ogni $ref punta a qualcosa che esiste
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: Elenco utenti, paginato
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: Pagina di utenti
headers:
Link:
description: Pagine adiacenti, con rel="next" e 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: Crea un utente
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateUserRequest' }
responses:
'201':
description: Utente creato
headers:
Location: { schema: { type: string }, description: URL del nuovo utente }
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
'400': { $ref: '#/components/responses/BadRequest' }
'409':
description: Email già registrata
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: Credenziali mancanti o non valide
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
BadRequest:
description: Richiesta malformata o dati non validi
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: valido, OpenAPI 3.1.0, 2 operazioni
Il file è valido, e questo conta più di quanto sembri: ogni $ref punta a qualcosa che esiste. Togliendo
la sezione components.responses da questo file, il validatore fallisce con PointerToNowhere: '/components/responses/Unauthorized' does not exist, ed è l'errore che un documento scritto
a mano sbaglia più spesso. Gli schemi usano due parole chiave di JSON Schema: readOnly sull'identificativo, assegnato dal server (la specifica fa proprio l'esempio del numero seriale generato da un database), e writeOnly sulla password,
che si può inviare ma non compare mai quando la risorsa viene letta.
Che cosa sai fare adesso
Sai collocare un'API nel modello di Richardson e spiegare perché il livello 2 basta quasi sempre. Sai scegliere fra PUT e PATCH sapendo che cosa promette ciascuno in termini di idempotenza, e conosci la differenza fra sicuro e
idempotente. Sai usare ETag per risparmiare banda con il 304 e per evitare aggiornamenti persi con il 412, restituire errori in application/problem+json con Retry-After sui 429,
paginare con Link, annunciare la fine di una versione con Deprecation e Sunset, e validare il tuo documento OpenAPI con uno strumento. Il passo
successivo, per la parte di lettura flessibile dei dati, è GraphQL e il problema N+1.
Domande frequenti
PUT o PATCH per aggiornare una risorsa?
PUT quando il client invia l'intera rappresentazione: i campi omessi vengono azzerati e il metodo è idempotente. PATCH quando vuoi modificare solo alcuni campi, tenendo presente che non è idempotente in generale: una patch che aggiunge un elemento a un array, applicata due volte, lo duplica.
Quando usare 400 e quando 422?
400 quando la richiesta è malformata, per esempio un JSON che non si legge. 422 (nella RFC 9110 «Unprocessable Content») quando la sintassi è corretta ma il contenuto non è elaborabile, come un campo che viola una regola. Conta la coerenza: scegli una convenzione e applicala ovunque.
Serve davvero HATEOAS in un'API REST?
Nella maggior parte dei casi no. Il livello 3 del modello di Richardson rende le risposte più grandi e richiede client che seguano i link. Ha senso quando gli indirizzi devono poter cambiare senza rompere client che non controlli; per un'API usata dal tuo frontend il livello 2 con un documento OpenAPI basta.
Fonti
- RFC 9110, HTTP Semantics - metodi sicuri e idempotenti (9.2), PUT e DELETE (9.3.4, 9.3.5), ETag e If-Match (8.8.3, 13.1.1), 412 e 422, Retry-After (10.2.3), consultata il 2026-11-12
- RFC 9111, HTTP Caching - il campo Vary nel calcolo della chiave di cache (sezione 4.1), consultata il 2026-11-12
- RFC 5789, PATCH Method for HTTP - PATCH né sicuro né idempotente, ma usabile in modo idempotente con If-Match, consultata il 2026-11-12
- RFC 6902, JSON Patch - operazioni add, remove, replace e inserimento in coda con
/-, consultata il 2026-11-12 - RFC 6585, Additional HTTP Status Codes - 428 Precondition Required e 429 Too Many Requests con Retry-After, consultata il 2026-11-12
- RFC 9457, Problem Details for HTTP APIs - tipo di contenuto, membri standard e membri di estensione, consultata il 2026-11-12
- RFC 8288, Web Linking - sintassi dell'intestazione Link e della relazione next, consultata il 2026-11-12
- RFC 9745, The Deprecation HTTP Response Header Field - la data come timestamp Unix preceduto da @ e il collegamento rel="deprecation", consultata il 2026-11-12
- RFC 8594, The Sunset HTTP Header Field - data HTTP di spegnimento, consultata il 2026-11-12
- Martin Fowler, Richardson Maturity Model - i livelli del modello e il ruolo di GET sicuro per la cache, 18 marzo 2010, consultata il 2026-11-12
- OpenAPI Specification v3.1.0 - struttura del documento e oggetto Components, consultata il 2026-11-12
- JSON Schema Validation, draft 2020-12 - significato di
readOnlyewriteOnly(sezione 9.4), consultata il 2026-11-12
I sei listati sono stati eseguiti con FastAPI 0.136.3, Pydantic 2.13.4, Starlette 1.2.1 e Python 3.12; jsonpatch 1.47 e openapi-spec-validator sono stati installati in una cartella temporanea. L'uscita riportata è identica in due esecuzioni consecutive. Le pagine del backend di questo sito citate sono state lette nel codice, non in documentazione.

Commenti
Caricamento commenti...
Accedi per commentare