Salta al contenuto principale

02 - REST nel 2026: Best Practice, Versioning e Richardson Maturity Model

Come costruire una REST API corretta: metodi idempotenti, codici di stato, ETag, errori RFC 9457, versionamento e OpenAPI 3.1, con esempi eseguiti.

Cosa ne pensi di questo articolo?

In Breve

Come costruire una REST API corretta: metodi idempotenti, codici di stato, ETag, errori RFC 9457, versionamento e OpenAPI 3.1, con esempi eseguiti.

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.

LivelloChe cosa introduceAspetto di una richiesta
0HTTP come semplice trasportoPOST /api con {"action": "getUser", "id": 123} nel corpo
1Risorse con un proprio indirizzoindirizzi come /users/123, ma ancora quasi tutto in POST
2Metodi HTTP e codici di stato con il loro significatoGET /users/123 risponde 200 o 404; POST /users risponde 201
3Controlli ipermediali (HATEOAS): la risposta dice quali azioni sono possibilila 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 è.

MetodoSicuroIdempotenteNota
GET, HEAD, OPTIONSsìsìLetture; HEAD ottiene solo le intestazioni
POSTnonoCreazione e azioni; ripeterlo può creare due risorse
PUTnosìCrea o sostituisce l'intera rappresentazione
PATCHnonon in generaleLa RFC 5789 dice che non è né sicuro né idempotente, ma può essere usato in modo idempotente
DELETEnosì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.

CodiceQuandoDettaglio da ricordare
200 / 204Lettura riuscita / operazione riuscita senza corpoPUT e DELETE possono usare entrambi
201È stata creata una risorsaCon l'intestazione Location; vale per POST e per PUT che crea
202Accettato ma non ancora eseguitoPer lavori asincroni; indica dove controllare l'esito
304La copia del client è ancora validaRisposta a una richiesta condizionale, senza corpo
400 / 422Richiesta malformata / sintassi corretta ma contenuto non elaborabileIl 422 nella RFC 9110 si chiama «Unprocessable Content»
401 / 403Non autenticato / autenticato ma non autorizzatoIl 401 richiede l'intestazione WWW-Authenticate
404 / 409Risorsa assente / conflitto con lo stato correnteUn'email già registrata è un 409
412 / 428Precondizione fallita / precondizione richiestaIl 428 è definito nella RFC 6585
429Troppe richiesteRFC 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.

StrategiaCome appareVantaggioCosto
Nell'URL/v1/users, /v2/usersVisibile, facile da loggare e da mettere in cache (l'URL è diverso)Più indirizzi da mantenere; la versione non è parte della risorsa
Nell'intestazioneAccept: application/vnd.example.v2+jsonIndirizzi stabiliServe Vary: Accept perché le cache distinguano le versioni; meno visibile nel debug
Cambiamenti solo additivinuovi campi senza nuova versioneNessuna migrazione per i clientNon 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

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.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti