In this post, we investigate the structure of HTTP requests, and understand how to design an API to receive them using FastAPI.

An API offers locations (known as endpoints) where clients can send HTTP requests. API functions return then responses, possibly contacting the service layer to modify the database or to retrieve data to return to the client.

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 returned dict, of which an example is given in the content 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 QueryParameters where 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 ExpenseRead objects, which are built from the list of Expense objects returned by CRUDHandler.query(). Expense objects cannot be used directly, since they are not pydantic models, and FastAPI finds issues when serializing them. On the other hand, ExpenseRead has identical fields, and therefore FastAPI can straightforwardly convert from Expense to 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 CRUDHandler.summarize() is very similar, since it also needs to receive a QueryParameters instance as argument. The function will target the /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.