In questo post esamineremo cosa sono le richieste HTTP, e vedremo gli strumenti offerti dal framework FastAPI per la progettazione di API.

Un'API offre URL (noti come endpoint) a cui i client possono inviare richieste HTTP. Le funzioni API restituiscono quindi delle risposte, eventualmente contattando il service layer per modificare il database o per ottenere dati da restituire al client.

Nel nostro post precedente, abbiamo visto come vengono implementate le operazioni CRUD su un database in SQLAlchemy. Oggi capiremo cosa sono le richieste HTTP e quali strumenti il framework FastAPI offre per la progettazione di API per riceverle.

Richieste HTTP

I client possono inviare richieste HTTP ai server, per interagire con le loro risorse (ad esempio, database e i loro record). Le richieste sono definite da alcuni qualificatori, che esploreremo di seguito.

Il primo ingrediente è il metodo HTTP, che solitamente è legato al tipo di azione da compiere sulla risorsa. Nella nostra API incontreremo:

  • GET, in genere usato per richiedere dati al server;
  • DELETE, in genere usato per rimuovere una risorsa dal server;
  • POST, in genere usato per inviare dati al server per aggiungere una risorsa;
  • PATCH, in genere usato per inviare dati al server per aggiornare una risorsa esistente.

Insieme al metodo, l’altro identificatore chiave di una richiesta è il suo Universal Resource Locator (URL), l’indirizzo a cui verrà inviata la richiesta. Di solito, l’URL dipenderà dall’operazione da eseguire e dalla risorsa a cui accedere.

Ad esempio, un database chiamato db può essere associato ai due URL /db/remove/ e /db/add/, per rimuovere e aggiungere record, rispettivamente. Questi URL verranno aggiunti all’URL di base del server, determinato dalla configurazione di rete (ad esempio, http://localhost:8000/db/add/ per un server in esecuzione localmente in ascolto sulla porta 8000). L’API verrà poi progettata per gestire richieste DELETE al primo URL e richieste POST al secondo.

Una volta che il server ha ricevuto ed elaborato la richiesta, invierà il risultato indietro sotto forma di risposta HTTP. Questa conterrà due elementi:

  • Uno status code, un numero di tre cifre che specifica il risultato complessivo della richiesta. Per esempio, 200 implica successo, 404 implica che una risorsa non è stata trovata, ed altri codici specificano vari risultati in maniera dettagliata.
  • Un corpo della risposta, un oggetto JSON che conterrà i dati rilevanti inviati dal server al client.

Parametri delle Richieste

La maggior parte delle richieste richiede parametri, per specificare a quali risorse accedere e/o come accedervi, interrogarle o modificarle.

Il primo possibile meccanismo per fare questo è l’aggiunta di parametri come parte dell’URL (path parameters). Questi vengono solitamente utilizzati per specificare a quale risorsa dovrebbe essere indirizzata la richiesta, se sono disponibili più risorse. Ad esempio, se un server può accedere ai database db1 e db2, i relativi URL di aggiunta potrebbero essere

https://localhost:8000/add/db1/
https://localhost:8000/add/db2/

e saranno serviti da un unico endpoint /add. I path parameters vengono solitamente aggiunti alla fine dell’URL, ma ciò non è strettamente necessario.

Il secondo meccanismo consiste nel passare i parametri nell’URL, ma non come parte dell’endpoint (query parameters). Questi sono separati dal resto dell’URL da un ?, e l’uno dall’altro dal carattere &; di solito danno informazioni su come accedere alla risorsa (ad esempio, quali record filtrare o eliminare). Un esempio potrebbe essere

https://localhost:8000/remove/db1/?date=2023-12-12&type=food&type=extra

Notare come type è stato passato due volte: l’API può utilizzare questo idioma per ricevere liste, con un valore per ogni elemento dell’elenco.

Infine, POST, PATCH e alcuni altri metodi consentono anche di passare parametri nel corpo della richiesta (body parameters), un oggetto JSON passato al server insieme alla richiesta. Questo può essere utilizzato per codificare dati strutturati: ad esempio, un metodo di inserimento avrà probabilmente un corpo del tipo

{
    field_1: value_1,
    field_2: value_2,
    ...
}

contenente i campi del record da inserire nel database.

Configurazione Generale dell’API

Parametri dell’API

In FastAPI, si utilizzano decoratori per dichiarare le funzioni dell’API. I decoratori vengono applicati alle path functions, normali funzioni Python che implementano la logica necessaria, accettano parametri e restituiscono valori; questi ultimi verranno usati per comporre la risposta per il client.

FastAPI esegue una discreta quantità di conversione e di convalida di input e output (tramite pydantic), mediando tra i tipi richiesti e restituiti dalle path functions e i dati trasmessi con le richieste e le risposte HTTP.

In sem, le definizioni delle funzioni dell’API sono contenute nel modulo modules/api.py. La relativa docstring mostra le path functions, ordinate per operazione e URL (vedi sotto per la descrizione di ciascuna).

# modules/api.py

 3	"""API definitions.
 4	
 5	Functions
 6	-----------------------
 7	get_ch()
 8	    Yield CRUDHandler object for DB connection.
 9	
10	@app.get("/")
11	    Connect to the main page.
12	@app.post("/add")
13	    Add expense to the DB.
14	@app.get("/query")
15	    Return expenses matching specified filters.
16	@app.get("/summarize")
17	    Summarize expenses matching specified filters.
18	@app.post("/load")
19	    Append content of CSV file to DB.
20	@app.get("/save")
21	    Save current content of DB to CSV file.
22	@app.patch("/update")
23	    Update existing expense selected by ID.
24	@app.delete("/remove")
25	    Remove selected expenses.
26	@app.delete("/erase")
27	    Remove all expenses.
28	"""

Il modulo inizia con la dichiarazione di un oggetto di tipo FastAPI (qui chiamato app). Nel suo costruttore è possibile specificare diverse proprietà che si applicheranno all’intera API: qui ho specificato solo il nome dell’API, che apparirà come titolo nella documentazione. Ho anche settato il nome del database.

# modules/api.py

53	from typing import Annotated
54	from typing import Optional
55	
56	from fastapi import FastAPI
57	from fastapi import Depends
58	from fastapi import Query
59	from fastapi import Body
60	from fastapi import HTTPException
61	
62	from modules.schemas import ExpenseAdd
63	from modules.schemas import ExpenseRead
64	from modules.schemas import ExpenseUpdate
65	from modules.schemas import QueryParameters
66	from modules.crud_handler import CRUDHandlerError
67	from modules.crud_handler import CRUDHandler
68	from modules.crud_handler import CRUDHandlerContext
69	
70	
71	DEFAULT_DB_NAME = "sem"
72	
73	app = FastAPI(title="sem")

Iniezione di Dipendenze

È molto comune progettare funzioni in un’API per accedere a una risorsa come un database, direttamente o tramite una classe wrapper (qui, CRUDHandler). In FastAPI, ciò avviene tramite iniezione di dipendenze: una funzione che dà accesso alla risorsa (ed esegue cleanup) viene passata come argomento alle funzioni dell’API, che la eseguono quando vengono chiamate e operano sulla risorsa associata.

Qui ho dichiarato una funzione che produce un CRUDHandler, il quale da’ accesso al database.

# modules/api.py

76	def get_ch() -> CRUDHandler:
77	    """Yield CRUDHandler object for DB connection.
78	
79	    Yields
80	    -----------------------
81	    CRUDHandler
82	        The CRUDHandler object.
83	    """
84	    with CRUDHandlerContext(DEFAULT_DB_NAME) as ch:
85	        yield ch

Notare come la risorsa viene resa tramite yield, e il cleanup viene eseguito automaticamente grazie al gestore di contesto.

Struttura delle Funzioni

Ora che abbiamo visto come impostare le funzionalità generali dell’API, possiamo esaminare una funzione di esempio, per comprendere le funzionalità comuni che appariranno anche nelle altre.

La prima funzione nella maggior parte delle API consente di accedere a una posizione root (ad esempio, la pagina principale di un sito web). Ho associato questa funzione alle richieste GET al percorso /, restituendo la risposta HTTP {"message": "homepage reached"} con un codice di stato 200 (successo).

# modules/api.py 

 88	@app.get(
 89	    "/",
 90	    status_code=200,
 91	    description="Connect to the main page.",
 92	    responses={
 93	        200: {
 94	            "model": dict,
 95	            "description": "Homepage reached.",
 96	            "content": {
 97	                "application/json": {
 98	                    "example": {"message": "homepage reached"}
 99	                }
100	            },
101	        }
102	    },
103	)
104	def root():
105	    return {"message": "homepage reached"}

Diversi dettagli sono specificati nel decoratore:

  • status_code riporta il codice di stato predefinito che la funzione dovrebbe restituire (qui è anche l’unico, perché non sono previsti errori da gestire nella funzione).
  • description è una descrizione della funzione, che verrà visualizzata nella documentazione dell’API.
  • responses è un dizionario, le cui chiavi sono i codici di stato che possono essere restituiti, e i cui valori sono dizionari che specificano informazioni sulla risposta associata. Qui, il codice di stato 200 è associato a un dict, di cui viene fornito un esempio nella sezione content.
  • La path function root() (il nome può essere scelto liberamente) restituisce il tipo specificato, che se necessario (qui non lo è) verrà convertito in JSON da FastAPI.

API di Creazione

La prima path function che esamineremo consente di aggiungere record al database. Ho associato questa operazione a richieste POST all’endpoint /add; la funzione restituisce un messaggio di conferma del corretto inserimento.

# modules/api.py

108	@app.post(
109	    "/add",
110	    status_code=200,
111	    description="Add expense to the DB.",
112	    responses={
113	        200: {
114	            "model": dict,
115	            "description": "Expense added.",
116	            "content": {
117	                "application/json": {
                            "example": {"message": "expense added"}
                        }
118	            },
119	        }
120	    },
121	)
122	def add(
123	    data: Annotated[ExpenseAdd, Body(description="Expense to add.")],
124	    ch: CRUDHandler = Depends(get_ch),
125	):
126	    ch.add(data)
127	    return {"message": "expense added"}

Rispetto alla funzione precedente, qui abbiamo la complessità aggiuntiva di un body parameter di tipo ExpenseAdd, per trasportare tutte le informazioni necessarie.

Di default, i tipi non semplici (ovvero composti) vengono interpretati automaticamente da FastAPI come body parameters (a differenza degli altri meccanismi disponibili discussi di seguito). Qui, questo è esplicitamente specificato per chiarezza: il parametro è definito usando typing.Annotated, che può dare informazioni al di là del semplice tipo (qui, l’identificatore Body()).

Quando la richiesta verrà eseguita, dovrà avere un corpo di richiesta del tipo

{
    "date": "2023-12-12",
    "type": "food",
    "category": "shared",
    "amount": -1.00,
    "description": "hamburger"
}

(category potrebbe essere omessa, poiché ha un valore predefinito).

ch viene passato come dipendenza, tramite la sintassi Depends(<funzione di dipendenza>). Questo eseguirà la funzione get_ch() ogni volta che si accede all’endpoint, dando accesso alla risorsa associata.

API di Ricerca

Le funzioni successive che esamineremo nell’API sono gli endpoint dedicati alla ricerca di dati. Qui, dovremmo ricevere un’istanza della classe QueryParameters, restituendo una lista di oggetti Expense (o ExpenseRead). Il metodo HTTP naturale a cui collegare questa operazione è GET: le informazioni verranno passate tramite query parameters, poiché specificano il modo in cui si deve accedere alle risorse.

Oggetti composti possono essere passati come query parameters tramite lo specificatore Query(), utilizzato allo stesso modo di Body() sopra. In questo caso si passa un query parameter per campo, con lo stesso nome del campo, e FastAPI si occupa di costruire automaticamente l’oggetto. In questo caso, tuttavia, ciò non è possibile, poiché alcuni campi di QueryParameters (types e categories) sono essi stessi composti (essendo list[str]).

Diverse soluzioni esistono: quella che ho scelto in sem consiste nell’usare una funzione che crea un oggetto QueryParameters da parametri di tipo Query(). Questa funzione verrà quindi passata come dipendenza all’API, che le passerà i propri query parameters e otterrà in cambio l’oggetto costruito. La funzione di dipendenza è

# modules/api.py

130	def query_parameters(
131	    start: Annotated[
132	        Optional[str],
133	        Query(
134	            description="Start date (included).
                                `None` does not filter.",
135	        ),
136	    ] = None,
137	    end: Annotated[
138	        Optional[str],
139	        Query(
140	            description="End date (included).
                                `None` does not filter.",
141	        ),
142	    ] = None,
143	    types: Annotated[
144	        Optional[list[str]],
145	        Query(
146	            description="Included expense types.
                                `None` does not filter.",
147	        ),
148	    ] = None,
149	    cat: Annotated[
150	        Optional[list[str]],
151	        Query(
152	            description="Included expense categories.
                                `None` does not filter.",
153	        ),
154	    ] = None,
155	) -> QueryParameters:
156	    return QueryParameters(
                start=start, end=end, types=types, categories=cat
            )

mentre la funzione di ricerca nell’API è associata all’URL /query e ha la forma

# modules/api.py

159	@app.get(
160	    "/query",
161	    status_code=200,
162	    description="Return expenses matching specified filters.",
163	    responses={
164	        200: {
165	            "model": list[ExpenseRead],
166	            "description": "List of matching expenses.",
167	        },
168	    },
169	)
170	def query(
171	    params: QueryParameters = Depends(query_parameters),
172	    ch: CRUDHandler = Depends(get_ch),
173	):
174	    return ch.query(params)

Un URL di esempio a cui potrebbe essere inviata questa richiesta potrebbe essere

/query?start=2023-11-12&end=2024-12-14&types=food&types=extra

Qui, query_parameters() crea un’istanza di QueryParameters in cui start e end sono costruiti dalle stringhe passate, e types = ["food", "extra"] (categories non è specificato, e quindi sarà settato a None per default, come specificato nella definizione di QueryParameters).

La funzione dell’API dovrà restituire una lista di oggetti ExpenseRead, creati a partire dalla lista di oggetti Expense restituiti da CRUDHandler.query(). Istanze di Expense non possono essere utilizzate direttamente, poiché non sono modelli pydantic, e FastAPI incontra problemi durante la loro serializzazione. D’altra parte, ExpenseRead ha gli stessi campi, e dunque FastAPI può eseguire la conversione da Expense a ExpenseRead in modo diretto.

La list[ExpenseRead] ottenuta in questo modo è poi convertita in JSON e restituita nel corpo della risposta; un esempio di quest’ultimo potrebbe essere

{
  [
    {
        "id"=1,
        "date"="2023-12-31",
        "type"="R",
        "category"="gen",
        "amount"=-12.0,
        "description"="test-1",
    },
    {
        "id"=2,
        "date"="2023-12-15",
        "type"="C",
        "category"="test",
        "amount"=-13.0,
        "description"="test-2",
    }
  ]
}

I dati numerici sono lasciati in formato numerico, mentre tutti gli altri tipi di dati vengono trasformati in stringhe da jsonable_encoder, l’encoder JSON interno di FastAPI.

L’API che invoca CRUDHandler.summarize() è molto simile, poiché richiede anch’essa un’istanza di QueryParameters come argomento. La funzione sarà collegata all’URL /summarize e potrà riutilizzare la dipendenza query_parameters().

# modules/api.py

177	@app.get(
178	    "/summarize",
179	    status_code=200,
180	    description="Summarize expenses matching specified filters.",
181	    responses={
182	        200: {
183	            "model": dict[str, dict[str, float]],
184	            "description": "Amount sums,
                                   grouped by category and type.",
185	        },
186	    },
187	)
188	def summarize(
189	    params: QueryParameters = Depends(query_parameters),
190	    ch: CRUDHandler = Depends(get_ch),
191	):
192	    return ch.summarize(params)

API di Update

La funzione che invoca CRUDHandler.update() dovrebbe essere associata al metodo PATCH, e l’oggetto ExpenseUpdate necessario dovrebbe essere contenuto nel corpo della richiesta, analogamente a quanto fatto per l’endpoint /add. Anche l’ID della spesa da aggiornare potrebbe essere passato nel corpo; ho deciso invece di passarlo come query parameter, poiché è uno specificatore di accesso alle risorse.

L’endpoint di update ha come destinazione l’URL /update; a differenza di quelli visti prima, dovrà gestire eventuali errori restituiti (tramite eccezioni) dal service layer. Il decoratore contiene i diversi tipi di risposte possibili.

# modules/api.py

261	@app.patch(
262	    "/update",
263	    status_code=200,
264	    description="Update existing expense selected by ID.",
265	    responses={
266	        200: {
267	            "model": dict,
268	            "description": "Expense updated.",
269	            "content": {
270	                "application/json": {
                            "example": {"message": "expense updated"}
                        }
271	            },
272	        },
273	        404: {
274	            "model": dict,
275	            "description": "Expense ID not found.",
276	            "content": {
277	                "application/json": {
278	                    "example": {"detail": "ID <id> not found"}
279	                }
280	            },
281	        },
282	    },
283	)
284	def update(
285	    ID: Annotated[int, Query(
                description="ID of the expense to update."
            )],
286	    data: Annotated[ExpenseUpdate, Body(
                description="New expense fields."
            )],
287	    ch: CRUDHandler = Depends(get_ch),
288	):
289	    try:
290	        ch.update(ID, data)
291	    except CRUDHandlerError as err:
292	        raise HTTPException(
                    status_code=404, detail=str(err)
                ) from err
293	    return {"message": "expense updated"}

Nel corpo della path function, le eccezioni provenienti dal service layer vengono catturate da un blocco try-except e, a loro volta, sollevano un errore HTTPException, che si comporta come un’eccezione Python (ad esempio, interrompendo l’esecuzione della funzione quando sollevata). Dal punto di vista del client, invece, il server si comporta normalmente, restituendo una risposta HTTP valida che segue uno dei suoi possibili modelli di ritorno (quello, o uno di quelli, associato ad errori).

HTTPException ha un codice di stato associato (qui impostato a 404, il che implica che una risorsa non è stata trovata) e un campo detail, il cui contenuto verrà restituito al cliente in un oggetto JSON sotto la chiave detail. Quest’ultimo viene utilizzato per fornire informazioni aggiuntive sull’errore (qui trasmettiamo direttamente il messaggio dell’eccezione del service layer). La risposta che vedrà il client avrà queste proprietà, come specificato nel dizionario responses al codice 404.

Tutte le funzioni API che ricevono parametri hanno anche un modello di risposta nascosto, impostato automaticamente da FastAPI. Questo viene restituito nel caso in cui i parametri passati non possano essere convertiti nei tipi previsti: il codice di stato restituito è 422, e il corpo della risposta associato è un oggetto JSON contenente informazioni sul parametro non valido.

API di Eliminazione

Il componente finale dell’API di sem discusso qui riguarda le funzioni di eliminazione. La prima di queste invoca CRUDHandler.remove(), e viene chiamato utilizzando il metodo DELETE all’URL /remove, passando gli ID da eliminare come una lista di interi (notare come l’uso esplicito di Query() su ids permette di passare un oggetto composto tramite query parameters).

# modules/api.py

296	@app.delete(
297	    "/remove",
298	    status_code=200,
299	    description="Remove selected expenses.",
300	    responses={
301	        200: {
302	            "model": dict,
303	            "description": "Expense(s) removed.",
304	            "content": {
305	                "application/json": {
306	                    "example": {"message": "expense(s) removed"}
307	                }
308	            },
309	        },
310	        404: {
311	            "model": dict,
312	            "description": "Expense ID not found.",
313	            "content": {
314	                "application/json": {
315	                    "example": {"detail": "ID <id> not found"}
316	                }
317	            },
318	        },
319	    },
320	)
321	def remove(
322	    ids: Annotated[
323	        list[int],
324	        Query(description="IDs of the expense(s) to remove."),
325	    ],
326	    ch: CRUDHandler = Depends(get_ch),
327	):
328	    try:
329	        ch.remove(ids)
330	    except CRUDHandlerError as err:
331	        raise HTTPException(
                    status_code=404, detail=str(err)
                ) from err
332	    return {"message": "expense(s) removed"}

L’endpoint di eliminazione successivo chiama il metodo CRUDHandler.erase() e rimuove tutti i record dal database. Questa funzione non accetta alcun argomento e invia una richiesta DELETE all’URL /erase.

# modules/api.py

335	@app.delete(
336	    "/erase",
337	    status_code=200,
338	    description="Remove all expenses and reset ID field.",
339	    responses={
340	        200: {
341	            "model": dict,
342	            "description": "Database erased.",
343	            "content": {
344	                "application/json": {
                            "example": {"message": "database erased"}
                        }
345	            },
346	        },
347	    },
348	)
349	def erase(ch: CRUDHandler = Depends(get_ch)):
350	    ch.erase()
351	    return {"message": "database erased"}

Nelle Prossime Puntate

In questo post abbiamo capito come sfruttare FastAPI per la progettazione di API che riceveranno richieste HTTP e comunicheranno con il service layer.

Nel prossimo post di questa serie impareremo come scrivere documentazione e test per un programma server in Python, per migliorare la robustezza e l’affidabilità della codebase.


Autore: Adriano Angelone

Dopo aver ottenuto la Laurea Magistrale in Fisica all’Università di Pisa nel 2013, ha ricevuto il Dottorato in Fisica all’Università di Strasburgo nel 2017. Ha lavorato come ricercatore post-dottorale all’Università di Strasburgo, alla SISSA (Trieste) e all’Università Sorbona (Parigi), prima di entrare in eXact-lab come Sviluppatore di Software Scientifico nel 2023.

In eXact-lab, lavora all’ottimizzazione di codici computazionali e allo sviluppo di software di gestione dati e data engineering.