API REST in Python: routing e path operation per la risorsa

API REST in Python: routing e path operation per la risorsa

Nella prima puntata abbiamo creato il progetto, esposto un endpoint di health check e organizzato il codice in router. Ora costruiamo l'impalcatura del nostro dominio: la risorsa libro. Vedremo come raccogliere in un router dedicato tutte le rotte CRUD, come dichiarare i parametri di percorso e di query sfruttando le annotazioni di tipo, e come rispettare la semantica REST sui metodi HTTP e sui codici di stato. Per non anticipare l'argomento della persistenza, in questa puntata i dati vivranno temporaneamente in memoria, all'interno di una piccola classe di supporto; nella prossima li sposteremo su database, senza dover riscrivere le rotte.

Le rotte di una risorsa REST

Una risorsa REST espone un insieme convenzionale di endpoint. Per la nostra collezione di libri la mappatura è la seguente:

  • GET /api/books restituisce l'elenco dei libri.
  • POST /api/books crea un nuovo libro.
  • GET /api/books/{book_id} restituisce un singolo libro.
  • PUT /api/books/{book_id} o PATCH /api/books/{book_id} aggiorna un libro esistente.
  • DELETE /api/books/{book_id} elimina un libro.

A differenza di framework che generano queste cinque rotte con una sola istruzione, in FastAPI le dichiariamo esplicitamente una per una. Non è un difetto: la scrittura esplicita rende immediatamente visibile quali endpoint esistono, con quale metodo e quale codice di stato, e ogni rotta resta un punto di aggancio naturale per parametri, dipendenze e documentazione. Il router ci permette comunque di raccoglierle in modo ordinato e di condividere prefisso e metadati.

Il router della risorsa

Creiamo il file app/routers/books.py e vi definiamo un router con un prefisso e un'etichetta. Il prefisso evita di ripetere /books in ogni rotta; l'etichetta raggruppa gli endpoint nella documentazione automatica.

from fastapi import APIRouter

# prefix aggiunge /books a ogni rotta; tags raggruppa gli endpoint nei /docs
router = APIRouter(prefix="/books", tags=["books"])

Montiamo questo router nell'applicazione, accanto a quello di health, sempre sotto il prefisso /api. Aggiorniamo app/main.py.

from fastapi import FastAPI

from app.routers import books, health

app = FastAPI(title="Catalogo Libri API")

app.include_router(health.router, prefix="/api")
# Le rotte del router books saranno servite sotto /api/books
app.include_router(books.router, prefix="/api")

Un archivio temporaneo in memoria

Prima di implementare le rotte ci serve un posto dove tenere i libri. Poiché la persistenza è argomento della prossima puntata, creiamo una piccola classe che simula un archivio, mantenendo i dati in un dizionario. Creiamo il file app/store.py.

from itertools import count


class BookStore:
    # Archivio in memoria: serve solo come segnaposto finché non
    # introdurremo il database nella prossima puntata.
    def __init__(self) -> None:
        self._books: dict[int, dict] = {}
        self._ids = count(1)  # generatore di identificatori progressivi
        # Popoliamo l'archivio con due libri di esempio
        self.create({"title": "Il nome della rosa", "author": "Umberto Eco", "year": 1980})
        self.create({"title": "Se questo è un uomo", "author": "Primo Levi", "year": 1947})

    def all(self) -> list[dict]:
        return list(self._books.values())

    def find(self, book_id: int) -> dict | None:
        return self._books.get(book_id)

    def create(self, data: dict) -> dict:
        book_id = next(self._ids)
        book = {"id": book_id, **data}
        self._books[book_id] = book
        return book

    def update(self, book_id: int, data: dict) -> dict | None:
        book = self._books.get(book_id)
        if book is None:
            return None
        # Aggiorna solo le chiavi effettivamente fornite
        book.update({k: v for k, v in data.items() if v is not None})
        return book

    def delete(self, book_id: int) -> bool:
        return self._books.pop(book_id, None) is not None


# Istanza condivisa a livello di modulo, usata dalle rotte
store = BookStore()

Questa classe non è pensata per durare: è un'impalcatura provvisoria che ci consente di far funzionare gli endpoint e di ragionare sul flusso richiesta-risposta senza distrazioni. Nella prossima puntata la rimpiazzeremo con un vero modello su database.

Le rotte di lettura

Iniziamo dalle due rotte di lettura: l'elenco completo e il dettaglio di un singolo libro. In app/routers/books.py aggiungiamo le path operation function corrispondenti.

from fastapi import APIRouter, HTTPException, status

from app.store import store

router = APIRouter(prefix="/books", tags=["books"])


@router.get("")
def index() -> list[dict]:
    # Elenco completo dei libri
    return store.all()


@router.get("/{book_id}")
def show(book_id: int) -> dict:
    book = store.find(book_id)
    if book is None:
        # 404 Not Found quando la risorsa non esiste
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Libro non trovato")
    return book

Due dettagli meritano attenzione. Il primo è il parametro book_id: int nella rotta di dettaglio: il segnaposto {book_id} nell'URL viene collegato al parametro omonimo della funzione, e l'annotazione int istruisce FastAPI a convertire e validare il valore. Se il client richiede /api/books/abc, il framework risponde automaticamente con un errore 422, senza che il nostro codice venga mai eseguito. Il secondo è HTTPException: è il modo idiomatico per interrompere l'elaborazione e restituire un codice di stato di errore con un messaggio. Nella settima puntata renderemo questi errori conformi a uno standard, ma la meccanica è già questa.

La creazione e il codice 201

La rotta di creazione riceve i dati nel corpo della richiesta. Per ora accettiamo un dizionario grezzo tramite l'oggetto Request; nella puntata sulla validazione lo sostituiremo con un modello Pydantic, che renderà i dati tipizzati e validati. L'aspetto importante qui è il codice di stato: una risorsa appena creata va restituita con un 201 Created, che dichiariamo nel decoratore.

from fastapi import Request


@router.post("", status_code=status.HTTP_201_CREATED)
async def store_book(request: Request) -> dict:
    # Leggiamo il corpo JSON grezzo; la validazione arriverà con Pydantic
    data = await request.json()
    return store.create(data)

Nota la parola chiave async: leggere il corpo della richiesta è un'operazione di rete, e FastAPI ci permette di definire la funzione come asincrona per non bloccare il server durante l'attesa. Possiamo mescolare liberamente funzioni sincrone e asincrone: il framework gestisce entrambe. Nella prossima puntata, quando ogni rotta avrà un modello di input, non avremo più bisogno di leggere il corpo a mano, perché sarà FastAPI a farlo per noi.

Proviamo subito l'elenco e la creazione dal terminale.

curl -i http://127.0.0.1:8000/api/books

curl -i -X POST http://127.0.0.1:8000/api/books \
  -H "Content-Type: application/json" \
  -d '{"title":"La coscienza di Zeno","author":"Italo Svevo","year":1923}'

Aggiornamento ed eliminazione

Completiamo il quadro con le rotte di aggiornamento ed eliminazione. L'aggiornamento restituisce la risorsa modificata; l'eliminazione, non avendo nulla da restituire, usa il codice 204 No Content, che per convenzione non porta alcun corpo nella risposta.

from fastapi import Response


@router.put("/{book_id}")
async def update_book(book_id: int, request: Request) -> dict:
    data = await request.json()
    book = store.update(book_id, data)
    if book is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Libro non trovato")
    return book


@router.delete("/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
def destroy(book_id: int) -> Response:
    if not store.delete(book_id):
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Libro non trovato")
    # 204: nessun corpo nella risposta
    return Response(status_code=status.HTTP_204_NO_CONTENT)

Con queste ultime due rotte la nostra risorsa espone l'intero insieme CRUD. Verifichiamo l'interfaccia esposta aprendo la documentazione su /docs: vi troveremo le cinque operazioni raggruppate sotto l'etichetta books, ciascuna con il proprio metodo, percorso e codice di stato, pronte da provare.

Parametri di query

I parametri di percorso identificano una risorsa; i parametri di query, che seguono il punto interrogativo nell'URL, servono invece a filtrare, ordinare o impaginare una collezione. In FastAPI si dichiarano semplicemente come argomenti della funzione che non compaiono nel percorso. Arricchiamo l'elenco con un filtro opzionale per autore.

@router.get("")
def index(author: str | None = None) -> list[dict]:
    books = store.all()
    if author is not None:
        # Filtra per autore quando il parametro ?author=... è presente
        books = [b for b in books if b.get("author") == author]
    return books

Il valore predefinito None rende il parametro facoltativo: se il client non lo specifica, restituiamo l'elenco completo. Una chiamata a /api/books?author=Primo%20Levi filtrerà invece i risultati. FastAPI documenta automaticamente anche questo parametro nella pagina interattiva, deducendone tipo e opzionalità dalla firma. Nella puntata sulla trasformazione dei dati useremo lo stesso meccanismo per la paginazione, con parametri come page e per_page.

Limitare le azioni esposte

Non sempre una risorsa deve esporre tutti e cinque i verbi. Poiché in FastAPI ogni rotta è dichiarata singolarmente, restringere l'insieme di operazioni è immediato: basta non definire le path operation che non vogliamo offrire. Se, per ipotesi, i libri fossero di sola lettura tramite l'API, definiremmo soltanto le rotte index e show, omettendo del tutto creazione, aggiornamento ed eliminazione. È buona norma esporre soltanto le azioni realmente necessarie: ogni endpoint pubblico è una superficie da proteggere e da documentare, quindi meno ce ne sono e meglio è.

Un'anteprima delle dipendenze

Nelle rotte che operano su un singolo libro ripetiamo lo stesso schema: cerchiamo la risorsa e, se non esiste, solleviamo un 404. FastAPI offre un meccanismo elegante per estrarre questa logica ricorrente, le dependency: funzioni che il framework esegue prima della path operation e il cui risultato viene iniettato come parametro. Nella prossima puntata, con un vero database, definiremo una dipendenza che recupera il libro dall'identificatore e solleva automaticamente il 404, eliminando la ripetizione. La firma a cui puntiamo è questa.

# Obiettivo della prossima puntata: la dipendenza carica il libro al posto nostro
@router.get("/{book_id}")
def show(book: dict = Depends(get_book_or_404)) -> dict:
    return book

Conclusione

Abbiamo costruito l'ossatura della nostra API. Raccogliendo le rotte in un router con prefisso ed etichetta, abbiamo definito l'intero insieme CRUD per la risorsa libro, rispettando la convenzione REST sui metodi HTTP e sui codici di stato: 201 alla creazione, 404 quando una risorsa non esiste, 204 all'eliminazione. Abbiamo introdotto un archivio in memoria come segnaposto, dichiarato parametri di percorso e di query sfruttando le annotazioni di tipo e intravisto il meccanismo delle dipendenze che ci accompagnerà per il resto della serie.

Nella prossima puntata sostituiremo l'archivio provvisorio con il database. Definiremo un modello con SQLModel, configureremo la connessione e la sessione, riscriveremo le rotte sfruttando l'ORM e introdurremo una dipendenza per il recupero dei libri, completando finalmente tutte le operazioni CRUD su dati persistenti.