In questo post esamineremo cosa sono le richieste HTTP, e vedremo gli strumenti offerti dal framework FastAPI per la progettazione di API.
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 undict
, di cui viene fornito un esempio nella sezionecontent
.- 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
. Questo eseguirà la funzione Depends
(<funzione di dipendenza>)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
in cui QueryParameters
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
, creati a partire dalla lista di oggetti ExpenseRead
restituiti da Expense
. Istanze di
.query()CRUDHandler
non possono essere utilizzate direttamente, poiché non sono modelli Expense
pydantic
, e FastAPI incontra problemi durante la loro serializzazione. D’altra parte,
ha gli stessi campi, e dunque FastAPI può eseguire la conversione da ExpenseRead
a Expense
in modo diretto.ExpenseRead
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
è molto simile, poiché richiede anch’essa un’istanza di
.summarize()CRUDHandler
come argomento. La funzione sarà collegata all’URL QueryParameters
/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.