In this post, we investigate the structure of HTTP requests, and understand how to design an API to receive them using FastAPI.
In our previous post, we saw how CRUD operations on a database are implemented in SQLAlchemy. Today, we will understand what HTTP requests are, and how to design an API to handle them using FastAPI.
HTTP Requests
Clients can send HTTP requests to servers, to interact with their resources (e.g., databases and their records). Requests are defined by a few qualifiers, which we will explore below.
The first ingredient is the HTTP method, which is usually linked to the type of action to perform on the resource. In our API we will encounter:
GET
, usually employed to request data to the server;DELETE
, usually employed to remove a resource from the server;POST
, usually employed to send data to the server to add to a resource;PATCH
, usually employed to send data to the server to update an existing resource.
Together with the method, the other key part of a request is its Universal Resource Locator (URL), the location to which the request will be sent. Usually, the URL will depend on the operation to perform as well as on which resource to access.
As an example, a database named db
may be associated to the two URLs /db/remove/
and /db/add/
to remove and add records, respectively. These URLs will be added to the base URL of the server, determined by the networking setup (e.g., http://localhost:8000/db/add/
for a locally running server listening on port 8000). The API will be then designed to handle DELETE requests to the former, and POST requests to the latter.
Once the server has received and processed the request, it will send back the result with a HTTP response. This will contain two elements:
- A status code, a three-digit integer which specifies the overall result of the request. For instance, 200 implies successful execution, 404 implies that a resource was not found, and more codes exist to specify detailed outcomes.
- A response body, a JSON object which will contain relevant data sent from the server back to the client.
Request Parameters
Most requests require parameters, to specify which resources to access and/or how to access, query or change them.
The first possible mechanism is adding parameters as part of the URL (path parameters). These are usually employed to specify which resource the request should target, if several are available. For instance, if a server can access databases db1
and db2
, the relative addition URLs may look like
https://localhost:8000/add/db1/
https://localhost:8000/add/db2/
and will be served by a single endpoint /add
. Path parameters are usually added at the end of the URL, but this is not strictly necessary.
The second mechanism is passing parameters in the URL, but not as part of the endpoint itself (query parameters). These are separated from the rest of the path by a ?
, and from each other by &
characters; they usually specify information on how to access the resource (e.g., which records to query or delete). An example may look like
https://localhost:8000/remove/db1/?date=2023-12-12&type=food&type=extra
Note how type
has been passed twice: the API may use this idiom for list parameters, with one such entry per list item.
Finally, POST
, PATCH
and a few other methods also allow to pass parameters in the request body, a JSON object passed to the server along with the request. This may be used to encode structured data: for instance, an insertion method will probably have a body such as
{
field_1: value_1,
field_2: value_2,
...
}
containing the fields of the record to insert in the database.
General API Features
API Settings
In FastAPI, one uses decorators to declare API functions. The decorators are applied to path functions, normal Python functions which implement the necessary logic, accept parameters and return values; the latter will be used to compose the response for the client.
FastAPI takes care of doing a good deal of conversion and validation of inputs and outputs (via pydantic
), mediating between the types requested and returned by the path functions and the data transmitted via HTTP requests and responses.
In sem
, API definitions are contained in the modules/api.py
module. The relative module docstring displays the functions, sorted by operation and URL (see below for descriptions of each).
# 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 """
The module begins with the declaration of an object of type FastAPI
(here named app
). Several API-wide properties can be specified in its constructor: here I specify the name of the API, which will appear as the title of the documentation. I also set the name of the 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")
Dependency Injection
It is very common to design API functions to access to a resource like a database, either directly or via a wrapper class (here, CRUDHandler
). In FastAPI, this is done via dependency injection: a function giving access to the resource (and performing cleanup) is passed as argument to the API functions, which execute it on call and operate on the associated resource.
Here, I declared a dependency function yielding a CRUDHandler
, which allows to access the 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
Note how the resource is yielded, and cleanup is automatically performed thanks to the context manager.
Function Structure
Now that we saw how to set up general features of the API, we can look at an example function, to understand common features which will appear in the others as well.
The first function in most APIs allows to access a root location (for instance, the main page in a website). I associated this function to GET
requests to the path /
, returning the HTTP response {"message": "homepage reached"}
with a status code 200 (success).
# 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"}
Here, several details are specified in the decorator:
status_code
reports the default status code which the function should return (here it is also the only one, since no errors should be handled within the function body).description
is a description of the function, which will appear in the API documentation.reponses
is a dictionary, whose keys are the status codes which can be returned, and whose values are dictionaries specifying information on the associated response. Here, the status code 200 is associated to a returneddict
, of which an example is given in thecontent
section.- The path function
root()
(the name may be chosen freely) returns the specified type, which will be converted to JSON by FastAPI if necessary (here it is not).
Creation API
The first path function we will examine allows to insert records in the database. I associated this operation to POST
requests to the /add
path; the function returns a message to confirm the successful insertion.
# 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"}
With respect to the previous function, here we have the added complexity of an ExpenseAdd
body parameter, to carry all the necessary information.
By default, non-simple (i.e., compound) types are automatically interpreted by FastAPI as passed in the request body (as opposed to the other available mechanisms discussed below). Here, this is explicitly specified for clarity: the parameter is defined using typing.Annotated
, which can carry information beyond the type (here, the Body()
specifier).
When the request is performed, it will have to carry a request body of the form
{
"date": "2023-12-12",
"type": "food",
"category": "shared",
"amount": -1.00,
"description": "hamburger"
}
(with category
possibly being omitted, due to having a default value).
ch
is passed as a dependency, via the Depends(<dependency function>)
syntax. This will execute the get_ch()
function every time the endpoint is accessed, giving access to the underlying resource.
Query API
The next functions in the API are the endpoints dedicated to querying. Here, we should receive an instance of the QueryParameters
class, and return a list of Expense
(or ExpenseRead
) objects. The natural HTTP method to link this operation to is GET
: information will be passed via query parameters, since they specify the way in which resources should be accessed.
Compound objects can be passed as query parameters via the Query()
specifier, used in the same way as Body()
above. In this case, one passes one query parameter per field, with the same name as the field, and FastAPI takes care of building the object automatically. In this case, however, this is not possible, since some of the fields of QueryParameters
(types and categories) are compound themselves (being list[str]
).
A few solutions exist: the one I chose in sem
consists in using a function which builds a QueryParameters
instance from Query()
parameters. This function will then be passed as a dependency to the API, which will pass its query arguments to the dependency function, obtaining in turn the constructed object. The dependency function is
# 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
)
while the query API function targets the /query
URL, and looks like
# 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)
An example URL to which this request could be sent would look like
/query?start=2023-11-12&end=2024-12-14&types=food&types=extra
Here, query_parameters()
would build an instance of
where QueryParameters
start
and end
are constructed from the passed strings, and types = ["food", "extra"]
(categories
is left unspecified, and will default to None
, as specified in the definition of
).QueryParameters
The API function is stated to return a list of
objects, which are built from the list of ExpenseRead
objects returned by Expense
.
.query()CRUDHandler
objects cannot be used directly, since they are not Expense
pydantic
models, and FastAPI finds issues when serializing them. On the other hand,
has identical fields, and therefore FastAPI can straightforwardly convert from ExpenseRead
to Expense
.ExpenseRead
The obtained list[ExpenseRead]
is then converted to JSON and returned in the body of the response; an example of the latter may look like
{
[
{
"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",
}
]
}
Numeric data is left in a numeric format, while all other data types are transformed in strings by jsonable_encoder
, the internal FastAPI JSON encoder.
The API invoking
is very similar, since it also needs to receive a
.summarize()CRUDHandler
instance as argument. The function will target the QueryParameters
/summarize
URL, and can conveniently reuse the query_parameters()
dependency.
# 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)
Update API
The function which calls CRUDHandler.update()
should be associated to the PATCH
method, and the necessary ExpenseUpdate
object should be carried in the request body, similarly to what done for the /add
endpoint. The ID of the expense to update could be passed in the body as well; I decided instead to pass it as a query parameter, since it is a resource access specifier.
The update endpoint targets the /update
URL; unlike the ones seen before, it needs to manage possible errors returned (via exceptions) from the service layer. The decorator displays the different types of possible responses.
# 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"}
In the path function body, service layer exceptions are captured by a try-except block, and in turn raise an HTTPException
error, which behaves like a Python exception (for instance, interrupting function execution when raised). From the point of view of the client, however, the server still behaves normally, returning a valid HTTP response which follows one of its possible return models (the one, or one of those, associated with errors).
HTTPException
has an associated status code (which I set here as 404, implying that a resource was not found) and a detail
field, whose content will be returned to the client in a JSON object under the detail
key. The latter is used to give additional information on the error (here we pass the service layer exception message verbatim). The response the client will see will have these properties, as displayed in the responses
dictionary under code 404.
All API functions which receive parameters also have a hidden response model/status code pair, automatically set up by FastAPI. This is triggered in the case the passed parameters cannot be converted to the expected types: the returned status code is 422, and the associated response body is a JSON object containing information about the invalid parameter.
Deletion API
The final component of the sem
API discussed here involves the deletion functions. The former of these calls CRUDHandler.remove()
, and is invoked using the DELETE method on the /remove
URL, passing the IDs to delete as a list of integers (note how the explicit use of Query()
on ids
allows to pass a compound object via 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"}
The next deletion endpoint calls the CRUDHandler.erase()
method, and removes all entries from the database. This function does not accept any arguments, and sends a DELETE
request to the /erase
URL.
# 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"}
What’s Next?
In this post, we understood how to design an API which will receive HTTP requests and communicate with the service layer.
In the next installment in this series, we will learn how to write documentation and testing for a Python server program, to improve the maintainability and reliability of the codebase.
Author: Adriano Angelone
After obtaining his master in Physics at University of Pisa in 2013, he received his Ph. D. in Physics at Strasbourg University in 2017. He worked as a post-doctoral researcher at Strasbourg University, SISSA (Trieste) and Sorbonne University (Paris), before joining eXact-lab as Scientific Software Developer in 2023.
In eXact-lab, he works on the optimization of computational codes, and on the development of data engineering software.