Setting Up FastAPI for Starters
In any software development work, it is always important to first know the business requirement of the project and the appropriate framework, tools, and deployment platform to use before pursuing the task. Frameworks that are easy to understand and use, seamless during coding, and within standards are always picked because of the integrity they provide to solve problems without risking too much development time. And a promising Python framework called FastAPI, created by Sebastian Ramirez, provides experienced developers, experts, and enthusiasts the best option for building REST APIs and microservices.
But before proceeding to the core details of building microservices using FastAPI, it is best to first learn the building blocks of this framework, such as how it captures clients’ requests, how it builds the rules for each HTTP method, and how it manages HTTP responses. Learning the basic components is always essential to know the strengths and weaknesses of the framework and to what extent we can apply FastAPI to solve different enterprise-grade and microservices-related problems.
Thus, in this chapter, we’re going to have a walkthrough of the basic features of FastAPI by covering the following main topics:
- The setup of the development environment
- Initialization and configuration of FastAPI
- Design and implementation of the REST APIs
- Managing user requests and server response
- Handling form parameters
- Handling cookies
Technical requirements
The software specimen for this chapter is a prototypical administrator-managed online academic discussion forum, which is an academic discussion hub where alumni, teachers, and students can exchange ideas. The prototype is working but it is open for changes, so you can tweak the code while reading this chapter. It is not designed to use any database management system, but all the data is temporarily stored in various Python collections. All the applications in this book are compiled and run using Python 3.8. Codes are all uploaded at https://github.com/PacktPublishing/Building-Python-Microservices-with-FastAPI/tree/main/ch01.
Setting up the development environment
The FastAPI framework is a fast, seamless, and robust Python framework but can only work on Python versions 3.6 and above. The Integrated Development Environment (IDE) used in this reference is Visual Studio Code (VS Code), which is an open source tool that we can download from this site: https://code.visualstudio.com/. Just be sure to install the VSC extensions such as Python, Python for VS Code, Python Extension Pack, Python Indent, and Material Icon Theme to provide your editor syntax checking, syntax highlighting, and other editor support.
After the successful installation of Python and VS Code, we can now install FastAPI using a terminal console. To ensure correct installation, first update Python’s package installer (pip
) by running this command:
python -m pip install --upgrade pip
Afterward, we install the framework by running this series of commands:
pip install fastapi pip install uvicorn[standard] pip install python-multipart
Important note
If you need to install the complete FastAPI platform, including all optional dependencies, the appropriate command is pip install fastapi[all]
. Likewise, if you want to install and utilize the full-blown uvicorn
server, you should run the pip install uvicorn
command. Also, install the bcrypt
module for encryption-related tasks.
At this point, you should have installed all the needed FastAPI module dependencies from the pydantic and starlette module components in your Python environment. Furthermore, the python-multipart module is required to create a REST API that handles form parameters. The installed uvicorn
, however, is an ASGI-based server that will run your FastAPI applications. The Asynchronous Server Gateway Interface (ASGI) server that FastAPI uses makes it the fastest Python framework at the time of writing. The uvicorn
server has the capability to run both synchronous and asynchronous services.
After the installation and configuration of the essential tools, modules, and IDE, let us now start our first API implementation using the framework.
Initializing and configuring FastAPI
Learning how to create applications using FastAPI is easy and straightforward. A simple application can be created just by creating a main.py
file inside your /ch01
project folder. In our online academic discussion forum, for instance, the application started with this code:
from fastapi import FastAPI app = FastAPI()
This initializes the FastAPI framework. The application needs to instantiate the core FastAPI
class from the fastapi
module and use app
as the reference variable to the object. Then, this object is used later as a Python @app
decorator, which provides our application with some features such as routes, middleware, exception handlers, and path operations.
Important note
You can replace app
with your preferred but valid Python variable name, such as main_app
, forum
, or myapp
.
Now, your application is ready to manage REST APIs that are technically Python functions. But to declare them as REST service methods, we need to decorate them with the appropriate HTTP request method provided by the path operation @app
decorator. This decorator contains the get()
, post()
, delete()
, put()
, head()
, patch()
, trace()
, and options()
path operations, which correspond to the eight HTTP request methods. And these path operations are decorated or annotated on top of the Python functions that we want to handle the request and response.
In our specimen, the first sample that the REST API created was this:
@app.get("/ch01/index") def index(): return {"message": "Welcome FastAPI Nerds"}
The preceding is a GET
API service method that returns a JSON
object. To locally run our application, we need to execute the following command:
uvicorn main:app --reload
This command will load the forum application to the uvicorn live server through the application’s main.py
file with FastAPI object referencing. Live reload is allowed by adding the --reload
option, which enables the restart of the development server whenever there are changes in the code.
Figure 1.1 – The uvicorn console log
Figure 1.1 shows that uvicorn uses localhost
to run the application with the default port 8000
. We can access our index page through http://localhost:8000/ch01/index
. To stop the server, you just need to press the Ctrl + C keyboard keys.
After running our first endpoint, let us now explore how to implement the other types of HTTP methods, namely POST
, DELETE
, PUT
, and PATCH
.
Designing and implementing REST APIs
The Representation State Transfer (REST) API makes up the rules, processes, and tools that allow interaction among microservices. These are method services that are identified and executed through their endpoint URLs. Nowadays, focusing on API methods before building a whole application is one of the most popular and effective microservices design strategies. This approach, called an API-first microservices development, focuses first on the client’s needs and then later identifies what API service methods we need to implement for these client requirements.
In our online academic discussion forum app, software functionality such as user sign-up, login, profile management, message posting, and managing post replies are some of the crucial needs we prioritized. In a FastAPI framework, these features are implemented as services using functions that are defined using Python’s def
keyword, with the association of the appropriate HTTP request method through the path operations provided by @app
.
The login
service, which requires username
and password
request parameters from the user, is implemented as a GET
API method:
@app.get("/ch01/login/") def login(username: str, password: str): if valid_users.get(username) == None: return {"message": "user does not exist"} else: user = valid_users.get(username) if checkpw(password.encode(), user.passphrase.encode()): return user else: return {"message": "invalid user"}
This login service uses bcrypt’s checkpw()
function to check whether the password of the user is valid. Conversely, the sign-up service, which also requires user credentials from the client in the form of request parameters, is created as a POST
API method:
@app.post("/ch01/login/signup") def signup(uname: str, passwd: str): if (uname == None and passwd == None): return {"message": "invalid user"} elif not valid_users.get(uname) == None: return {"message": "user exists"} else: user = User(username=uname, password=passwd) pending_users[uname] = user return user
Among the profile management services, the following update_profile()
service serves as a PUT
API service, which requires the user to use an entirely new model object for profile information replacement and the client’s username to serve as the key:
@app.put("/ch01/account/profile/update/{username}") def update_profile(username: str, id: UUID, new_profile: UserProfile): if valid_users.get(username) == None: return {"message": "user does not exist"} else: user = valid_users.get(username) if user.id == id: valid_profiles[username] = new_profile return {"message": "successfully updated"} else: return {"message": "user does not exist"}
Not all services that carry out updates are PUT
API methods, such as the following update_profile_name()
service, which only requires the user to submit a new first name, last name, and middle initial for partial replacement of a client’s profile. This HTTP request, which is handier and more lightweight than a full-blown PUT
method, only requires a PATCH
action:
@app.patch("/ch01/account/profile/update/names/{username}") def update_profile_names(username: str, id: UUID, new_names: Dict[str, str]): if valid_users.get(username) == None: return {"message": "user does not exist"} elif new_names == None: return {"message": "new names are required"} else: user = valid_users.get(username) if user.id == id: profile = valid_profiles[username] profile.firstname = new_names['fname'] profile.lastname = new_names['lname'] profile.middle_initial = new_names['mi'] valid_profiles[username] = profile return {"message": "successfully updated"} else: return {"message": "user does not exist"}
The last essential HTTP services that we included before building the application are the DELETE
API methods. We use these services to delete records or information given a unique identification, such as username
and a hashed id
. An example is the following delete_post_discussion()
service that allows a user to delete a posted discussion when given a username and the UUID (Universally Unique Identifier) of the posted message:
@app.delete("/ch01/discussion/posts/remove/{username}") def delete_discussion(username: str, id: UUID): if valid_users.get(username) == None: return {"message": "user does not exist"} elif discussion_posts.get(id) == None: return {"message": "post does not exist"} else: del discussion_posts[id] return {"message": "main post deleted"}
All path operations require a unique endpoint URL in the str
format. A good practice is to start all URLs with the same top-level base path, such as /ch01
, and then differ when reaching their respective subdirectories. After running the uvicorn server, we can check and validate whether all our URLs are valid and running by accessing the documentation URL, http://localhost:8000/docs
. This path will show us a OpenAPI dashboard, as shown in Figure 1.2, listing all the API methods created for the application. Discussions on the OpenAPI will be covered in Chapter 9, Utilizing Other Advanced Features.
Figure 1.2 – A Swagger OpenAPI dashboard
After creating the endpoint services, let us scrutinize how FastAPI manages its incoming request body and the outgoing response.
Managing user requests and server response
Clients can pass their request data to FastAPI endpoint URLs through path parameters, query parameters, or headers to pursue service transactions. There are standards and ways to use these parameters to obtain incoming requests. Depending on the goal of the services, we use these parameters to influence and build the necessary responses the clients need. But before we discuss these various parameter types, let us explore first how we use type hinting in FastAPI’s local parameter declaration.
Parameter type declaration
All request parameters are required to be type-declared in the method signature of the service method applying the PEP 484 standard called type hints. FastAPI supports common types such as None
, bool
, int
, and float
and container types such as list
, tuple
, dict
, set
, frozenset
, and deque
. Other complex Python types such as datetime.date
, datetime.time
, datetime.datetime
, datetime.delta
, UUID
, bytes
, and Decimal
are also supported.
The framework also supports the data types included in Python’s typing
module, responsible for type hints. These data types are standard notations for Python and variable type annotations that can help to pursue type checking and model validation during compilation, such as Optional
, List
, Dict
, Set
, Union
, Tuple
, FrozenSet
, Iterable
, and Deque
.
Path parameters
FastAPI allows you to obtain request data from the endpoint URL of an API through a path parameter or path variable that makes the URL somewhat dynamic. This parameter holds a value that becomes part of a URL indicated by curly braces ({}
). After setting off these path parameters within the URL, FastAPI requires these parameters to be declared by applying type hints.
The following delete_user()
service is a DELETE
API method that uses a username
path parameter to search for a login record for deletion:
@app.delete("/ch01/login/remove/{username}") def delete_user(username: str): if username == None: return {"message": "invalid user"} else: del valid_users[username] return {"message": "deleted user"}
Multiple path parameters are acceptable if the leftmost variables are more likely to be filled with values than the rightmost variables. In other words, the importance of the leftmost path variables will make the process more relevant and correct than those on the right. This standard is applied to ensure that the endpoint URL will not look like other URLs, which might cause some conflicts and confusion. The following login_with_token()
service follows this standard, since username
is a primary key and is as strong as, or even stronger than, its next parameter, password
. There is an assurance that the URL will always look unique every time the endpoint is accessed because username
will always be required, as well as password
:
@app.get("/ch01/login/{username}/{password}") def login_with_token(username: str, password:str, id: UUID): if valid_users.get(username) == None: return {"message": "user does not exist"} else: user = valid_users[username] if user.id == id and checkpw(password.encode(), user.passphrase): return user else: return {"message": "invalid user"}
Unlike other web frameworks, FastAPI is not friendly with endpoint URLs that belong to base paths or top-level domain paths with different subdirectories. This occurrence happens when we have dynamic URL patterns that look the same as the other fixed endpoint URLs when assigned a specific path variable. These fixed URLs are implemented sequentially after these dynamic URLs. An example of these are the following services:
@app.get("/ch01/login/{username}/{password}") def login_with_token(username: str, password:str, id: UUID): if valid_users.get(username) == None: return {"message": "user does not exist"} else: user = valid_users[username] if user.id == id and checkpw(password.encode(), user.passphrase.encode()): return user else: return {"message": "invalid user"} @app.get("/ch01/login/details/info") def login_info(): return {"message": "username and password are needed"}
This will give us an HTTP Status Code 422 (Unprocessable Entity) when accessing http://localhost:8080/ch01/login/details/info
. There should be no problem accessing the URL, since the API service is almost a stub or trivial JSON data. What happened in this scenario is that the fixed path’s details
and info
path directories were treated as username
and password
parameter values, respectively. Because of confusion, the built-in data validation of FastAPI will show us a JSON-formatted error message that says, {"detail":[{"loc":["query","id"],"msg":"field required","type":"value_error.missing"}]}
. To fix this problem, all fixed paths should be declared first before the dynamic endpoint URLs with path parameters. Thus, the preceding login_info()
service should be declared first before login_with_token()
.
Query parameters
A query parameter is a key–value pair supplied after the end of an endpoint URL, indicated by a question mark (?
). Just like the path parameter, this also holds the request data. An API service can manage a series of query parameters separated by an ampersand (&
). Like in path parameters, all query parameters are also declared in the service method. The following login service is a perfect specimen that uses query parameters:
@app.get("/ch01/login/") def login(username: str, password: str): if valid_users.get(username) == None: return {"message": "user does not exist"} else: user = valid_users.get(username) if checkpw(password.encode(), user.passphrase.encode()): return user else: return {"message": "invalid user"}
The login
service method uses username
and password
as query parameters in the str
types. Both are required parameters, and assigning them with None
as parameter values will give a compiler error.
FastAPI supports query parameters that are complex types, such as list
and dict
. But these Python collection types cannot specify the type of objects to store unless we apply the generic type hints for Python collections. The following delete_users()
and update_profile_names()
APIs use generic type hints, List
and Dict
, in declaring query parameters that are container types with type checking and data validation:
from typing import Optional, List, Dict @app.delete("/ch01/login/remove/all") def delete_users(usernames: List[str]): for user in usernames: del valid_users[user] return {"message": "deleted users"} @app.patch("/ch01/account/profile/update/names/{username}") def update_profile_names(username: str, id: UUID, new_names: Dict[str, str]): if valid_users.get(username) == None: return {"message": "user does not exist"} elif new_names == None: return {"message": "new names are required"} else: user = valid_users.get(username) if user.id == id: profile = valid_profiles[username] profile.firstname = new_names['fname'] profile.lastname = new_names['lname'] profile.middle_initial = new_names['mi'] valid_profiles[username] = profile return {"message": "successfully updated"} else: return {"message": "user does not exist"}
FastAPI also allows you to explicitly assign default values to service function parameters.
Default parameters
There are times that we need to specify default values to the query parameter(s) and path parameter(s) of some API services to avoid validation error messages such as field required
and value_error.missing
. Setting default values to parameters will allow the execution of an API method with or without supplying the parameter values. Depending on the requirement, assigned default values are usually 0
for numeric types, False
for bool types, empty string for string types, an empty list ([]
) for List types, and an empty dictionary ({}
) for Dict
types. The following delete pending users()
and change_password()
services show us how to apply default values to the query parameter(s) and path parameter(s):
@app.delete("/ch01/delete/users/pending") def delete_pending_users(accounts: List[str] = []): for user in accounts: del pending_users[user] return {"message": "deleted pending users"} @app.get("/ch01/login/password/change") def change_password(username: str, old_passw: str = '', new_passw: str = ''): passwd_len = 8 if valid_users.get(username) == None: return {"message": "user does not exist"} elif old_passw == '' or new_passw == '': characters = ascii_lowercase temporary_passwd = ''.join(random.choice(characters) for i in range(passwd_len)) user = valid_users.get(username) user.password = temporary_passwd user.passphrase = hashpw(temporary_passwd.encode(),gensalt()) return user else: user = valid_users.get(username) if user.password == old_passw: user.password = new_passw user.passphrase = hashpw(new_pass.encode(),gensalt()) return user else: return {"message": "invalid user"}
delete_pending_users()
can be executed even without passing any accounts argument, since accounts will be always an empty List
by default. Likewise, change_password()
can still continue its process without passing any old_passwd
and new_passw
, since they are both always defaulted to empty str
. hashpw()
is a bcrypt
utility function that generates a hashed passphrase from an autogenerated salt.
Optional parameters
If the path and/or query parameter(s) of a service is/are not necessarily needed to be supplied by the user, meaning the API transactions can proceed with or without their inclusion in the request transaction, then we set them as optional. To declare an optional parameter, we need to import the Optional
type from the typing
module and then use it to set the parameter. It should wrap the supposed data type of the parameter using brackets ([]
) and can have any default value if needed. Assigning the Optional
parameter to a None
value indicates that its exclusion from the parameter passing is allowed by the service, but it will hold a None
value. The following services depict the use of optional parameters:
from typing import Optional, List, Dict @app.post("/ch01/login/username/unlock") def unlock_username(id: Optional[UUID] = None): if id == None: return {"message": "token needed"} else: for key, val in valid_users.items(): if val.id == id: return {"username": val.username} return {"message": "user does not exist"} @app.post("/ch01/login/password/unlock") def unlock_password(username: Optional[str] = None, id: Optional[UUID] = None): if username == None: return {"message": "username is required"} elif valid_users.get(username) == None: return {"message": "user does not exist"} else: if id == None: return {"message": "token needed"} else: user = valid_users.get(username) if user.id == id: return {"password": user.password} else: return {"message": "invalid token"}
In the online academic discussion forum application, we have services such as the preceding unlock_username()
and unlock_password()
services that declare all their parameters as optional
. Just do not forget to apply exception handling or defensive validation in your implementation when dealing with these kinds of parameters to avoid HTTP Status 500 (Internal Server Error).
Important note
The FastAPI framework does not allow you to directly assign the None
value to a parameter just to declare an optional parameter. Although this is allowed with the old Python behavior, this is no longer recommended in the current Python versions for the purpose of built-in type checking and model validation.
Mixing all types of parameters
If you are planning to implement an API service method that declares optional, required, and default query and path parameters altogether, you can pursue it because the framework supports it, but approach it with some caution due to some standards and rules:
@app.patch("/ch01/account/profile/update/names/{username}") def update_profile_names(id: UUID, username: str = '' , new_names: Optional[Dict[str, str]] = None): if valid_users.get(username) == None: return {"message": "user does not exist"} elif new_names == None: return {"message": "new names are required"} else: user = valid_users.get(username) if user.id == id: profile = valid_profiles[username] profile.firstname = new_names['fname'] profile.lastname = new_names['lname'] profile.middle_initial = new_names['mi'] valid_profiles[username] = profile return {"message": "successfully updated"} else: return {"message": "user does not exist"}
The updated version of the preceding update_profile_names()
service declares a username
path parameter, a UUID
id query parameter, and an optional Dict[str, str]
type. With mixed parameter types, all required parameters should be declared first, followed by default parameters, and last in the parameter list should be the optional types. Disregarding this ordering rule will generate a compiler error.
Request body
A request body is a body of data in bytes transmitted from a client to a server through a POST
, PUT
, DELETE
, or PATCH
HTTP method operation. In FastAPI, a service must declare a model object to represent and capture this request body to be processed for further results.
To implement a model class for the request body, you should first import the BaseModel
class from the pydantic
module. Then, create a subclass of it to utilize all the properties and behavior needed by the path operation in capturing the request body. Here are some of the data models used by our application:
from pydantic import BaseModel class User(BaseModel): username: str password: str class UserProfile(BaseModel): firstname: str lastname: str middle_initial: str age: Optional[int] = 0 salary: Optional[int] = 0 birthday: date user_type: UserType
The attributes of the model classes must be explicitly declared by applying type hints and utilizing the common and complex data types used in the parameter declaration. These attributes can also be set as required, default, and optional, just like in the parameters.
Moreover, the pydantic
module allows the creation of nested models, even the deeply nested ones. A sample of these is shown here:
class ForumPost(BaseModel): id: UUID topic: Optional[str] = None message: str post_type: PostType date_posted: datetime username: str class ForumDiscussion(BaseModel): id: UUID main_post: ForumPost replies: Optional[List[ForumPost]] = None author: UserProfile
As seen in the preceding code, we have a ForumPost
model, which has a PostType
model attribute, and ForumDiscussion
, which has a List
attribute of ForumPost
, a ForumPost
model attribute, and a UserProfile
attribute. This kind of model blueprint is called a nested model approach.
After creating these model classes, you can now inject these objects into the services that are intended to capture the request body from the clients. The following services utilize our User
and UserProfile
model classes to manage the request body:
@app.post("/ch01/login/validate", response_model=ValidUser) def approve_user(user: User): if not valid_users.get(user.username) == None: return ValidUser(id=None, username = None, password = None, passphrase = None) else: valid_user = ValidUser(id=uuid1(), username= user.username, password = user.password, passphrase = hashpw(user.password.encode(), gensalt())) valid_users[user.username] = valid_user del pending_users[user.username] return valid_user @app.put("/ch01/account/profile/update/{username}") def update_profile(username: str, id: UUID, new_profile: UserProfile): if valid_users.get(username) == None: return {"message": "user does not exist"} else: user = valid_users.get(username) if user.id == id: valid_profiles[username] = new_profile return {"message": "successfully updated"} else: return {"message": "user does not exist"}
Models can be declared required, with a default instance value, or optional in the service method, depending on the specification of the API. Missing or incorrect details such as invalid password
or None
values in the approve_user()
service will emit the Status Code 500 (Internal Server Error). How FastAPI handles exceptions will be part of Chapter 2, Exploring the Core Features, discussions.
Important note
There are two essential points we need to emphasize when dealing with BaseModel
class types. First, the pydantic
module has a built-in JSON encoder that converts the JSON-formatted request body to the BaseModel
object. So, there is no need create a custom converter to map the request body to the BaseModel
model. Second, to instantiate a BaseModel
class, all its required attributes must be initialized immediately through the constructor’s named parameters.
Request headers
In a request-response transaction, it is not only the parameters that are accessible by the REST API methods but also the information that describes the context of the client where the request originated. Some common request headers such as User-Agent
, Host
, Accept
, Accept-Language
, Accept-Encoding
, Referer
, and Connection
usually appear with request parameters and values during request transactions.
To access a request header, import first the Header
function from the fastapi
module. Then, declare the variable that has the same name as the header in the method service as str
types and initialize the variable by calling the Header(None)
function. The None
argument enables the Header()
function to declare the variable optionally, which is a best practice. For hyphenated request header names, the hyphen (-
) should be converted to an underscore (_
); otherwise, the Python compiler will flag a syntax error message. It is the task of the Header()
function to convert the underscore (_
) to a hyphen (-
) during request header processing.
Our online academic discussion forum application has a verify_headers()
service that retrieves core request headers needed to verify a client’s access to the application:
from fastapi import Header @app.get("/ch01/headers/verify") def verify_headers(host: Optional[str] = Header(None), accept: Optional[str] = Header(None), accept_language: Optional[str] = Header(None), accept_encoding: Optional[str] = Header(None), user_agent: Optional[str] = Header(None)): request_headers["Host"] = host request_headers["Accept"] = accept request_headers["Accept-Language"] = accept_language request_headers["Accept-Encoding"] = accept_encoding request_headers["User-Agent"] = user_agent return request_headers
Important note
Non-inclusion of the Header()
function call in the declaration will let FastAPI treat the variables as query parameters. Be cautious also with the spelling of the local parameter names, since they are the request header names per se except for the underscore.
Response data
All API services in FastAPI should return JSON
data, or it will be invalid and may return None
by default. These responses can be formed using dict
, BaseModel
, or JSONResponse
objects. Discussions on JSONResponse
will be discussed in the succeeding chapters.
The pydantic
module’s built-in JSON converter will manage the conversion of these custom responses to a JSON object, so there is no need to create a custom JSON encoder:
@app.post("/ch01/discussion/posts/add/{username}") def post_discussion(username: str, post: Post, post_type: PostType): if valid_users.get(username) == None: return {"message": "user does not exist"} elif not (discussion_posts.get(id) == None): return {"message": "post already exists"} else: forum_post = ForumPost(id=uuid1(), topic=post.topic, message=post.message, post_type=post_type, date_posted=post.date_posted, username=username) user = valid_profiles[username] forum = ForumDiscussion(id=uuid1(), main_post=forum_post, author=user, replies=list()) discussion_posts[forum.id] = forum return forum
The preceding post_discussion()
service returns two different hardcoded dict
objects, with message
as the key and an instantiated ForumDiscussion
model.
On the other hand, this framework allows us to specify the return type of a service method. The setting of the return type happens in the response_model
attribute of any of the @app
path operations. Unfortunately, the parameter only recognizes BaseModel
class types:
@app.post("/ch01/login/validate", response_model=ValidUser) def approve_user(user: User): if not valid_users.get(user.username) == None: return ValidUser(id=None, username = None, password = None, passphrase = None) else: valid_user = ValidUser(id=uuid1(), username= user.username, password = user.password, passphrase = hashpw(user.password.encode(), gensalt())) valid_users[user.username] = valid_user del pending_users[user.username] return valid_user
The preceding approve_user()
service specifies the required return of the API method, which is ValidUser
.
Now, let us explore how FastAPI handles form parameters.
Handling form parameters
When API methods are designed to handle web forms, the services involved are required to retrieve form parameters instead of the request body because this form data is normally encoded as an application/x-www-form-urlencoded
media type. These form parameters are conventionally string
types, but the pydantic
module’s JSON encoder can convert each parameter value to its respective valid type.
All the form parameter variables can be declared required, with default values, or optional using the same set of Python types we used previously. Then, the fastapi
module has a Form
function that needs to be imported to initialize these form parameter variables during their declaration. To set these form parameters as required, the Form()
function must have the ellipses (…
) argument, thus calling it as Form(…)
:
from fastapi import FastAPI, Form @app.post("/ch01/account/profile/add", response_model=UserProfile) def add_profile(uname: str, fname: str = Form(...), lname: str = Form(...), mid_init: str = Form(...), user_age: int = Form(...), sal: float = Form(...), bday: str = Form(...), utype: UserType = Form(...)): if valid_users.get(uname) == None: return UserProfile(firstname=None, lastname=None, middle_initial=None, age=None, birthday=None, salary=None, user_type=None) else: profile = UserProfile(firstname=fname, lastname=lname, middle_initial=mid_init, age=user_age, birthday=datetime.strptime(bday, '%m/%d/%Y'), salary=sal, user_type=utype) valid_profiles[uname] = profile return profile
The preceding add_profile()
service shows us how to call the Form(…)
function to return a Form
object during the parameter declaration.
Important note
Form-handling services will not work if the python-multipart
module is not installed.
Sometimes, we need browser cookies to establish an identity for our application, leave trails in the browser for every user transaction, or store product information for a purpose. If FastAPI can manage form data, it can also do the same with cookies.
Managing cookies
A cookie is a piece of information stored in the browser to pursue some purpose, such as login user authorization, web agent response generation, and session handling-related tasks. One cookie is always a key-value pair that are both string types.
FastAPI allows services to create cookies individually through the Response
library class from its fastapi
module. To use it, it needs to appear as the first local parameter of the service, but we do not let the application or client pass an argument to it. Using the dependency injection principle, the framework will provide the Response
instance to the service and not the application. When the service has other parameters to declare, the additional declaration should happen right after the declaration of the Response
parameter.
The Response
object has a set_cookie()
method that contains two required named parameters: the key, which sets the cookie name, and the value, which stores the cookie value. This method only generates one cookie and stores it in the browser afterward:
@app.post("/ch01/login/rememberme/create/") def create_cookies(resp: Response, id: UUID, username: str = ''): resp.set_cookie(key="userkey", value=username) resp.set_cookie(key="identity", value=str(id)) return {"message": "remember-me tokens created"}
The preceding create_cookies()
method shows us the creation of remember-me tokens such as userkey
and identity
for the remember-me authorization of our online academic discussion forum project.
To retrieve these cookies, local parameters that have the same name as the cookies are declared in the service method as str
types, since cookie values are always strings. As with Header
and Form
, the fastapi
module also provides a Cookie
function that is needed to initialize each declared cookie parameter variable. The Cookie()
function should always have the None
argument to set the parameters optionally, ensuring that the API method executes without problems whenever the headers are not present in the request transaction. The following access_cookie()
service retrieves all the remember-me authorization cookies created by the previous service:
@app.get("/ch01/login/cookies") def access_cookie(userkey: Optional[str] = Cookie(None), identity: Optional[str] = Cookie(None)): cookies["userkey"] = userkey cookies["identity"] = identity return cookies
Summary
This chapter is essential to familiarize ourselves with FastAPI and understand its basic components. The concept that we can get from this chapter can measure how much adjustment and effort we need to invest into translating or rewriting some existing applications to FastAPI. Knowing its basics will help us learn how to install its modules, structure the project directories, and learn the core library classes and functions needed to build a simple enterprise-grade application.
With the help of our recipe online academic discussion forum application, this chapter showed us how to build different REST APIs associated with HTTP methods using the FastAPI module class and Python def
functions. From there, we learned how to capture incoming request data and headers using the local parameters of the API methods and how these API methods should return a response to the client. And through this chapter, we saw how easy it is for FastAPI to capture form data from <form></form>
of any UI templates and that is using the Form
function. Aside from the Form
function, the FastAPI module also has the Cookie
function to help us create and retrieve cookies from the browser, and Header
to retrieve the request header part of an incoming request transaction.
Overall, this chapter has prepared us for advanced discussions that will center on other features of FastAPI that can help us upgrade our simple applications to full-blown ones. The next chapter will cover these essential core features, which will provide our application with the needed response encoder and generator, exception handlers, middleware, and other components related to asynchronous transactions.