Python and FastAPI
Learning objectives
- Sees how a simple web application could be implemented with Python and FastAPI.
For working with the following example on your computer, you need to have Python (version 3.7 or newer) installed. Python can be downloaded at https://www.python.org/downloads/. You also need a command pip
that is used for downloading libraries -- pip
is included when using the download page at https://www.python.org/downloads/.
When working on Python projects, small virtual environments are created for each project using venv. This way, libraries that are downloaded for the project are project-specific, and do not override or cause clashes with libraries that would otherwise be loaded for the user and used across projects. Creating a virtual environment is done using the command python3 -m venv folder
, where folder refers to the name of a folder into which the virtual environment configuration is added. Then, the virtual environment is activated using the command source folder/bin/activate
.
In the following example, we create a virtual environment into a directory called venv
and then activate the virtual environment. We start with an empty directory.
tree --dirsfirst
.
0 directories, 0 files
python3 -m venv venv
source venv/bin/activate
(venv) $
Exiting from a virtual environment is done with ctrl+C. It can again be activated with the same source folder/bin/active
command (where folder refers to the folder in which the virtual environment configuration resides in).
(venv) $ (mimicking pressing ctrl+c => exit venv)
source venv/bin/activate
(venv) $
When in the virtual environment, we install FastAPI and uvicorn. FastAPI is a Python web framework for building APIs, while uvicorn is a web server that we use to run the applications built with FastAPI. Installing libraries is done using pip
-- when installing a library, we run the command pip install library
, where library refers to the library that we wish to install.
Installing fastapi
and uvicorn
is done as follows.
source venv/bin/activate
(venv) $ pip install fastapi
Collecting fastapi
Downloading ...
// a handful of lines later
Installing collected packages: starlette, dataclasses, pydantic, fastapi
Successfully installed dataclasses-(version) fastapi-(version) pydantic-(version) starlette-(version)
(venv) $ pip install uvicorn
Collecting uvicorn
Downloading ...
// a handful of lines later
Installing collected packages: h11, typing-extensions, click, uvicorn
Successfully installed click-(version) h11-(version) typing-extensions-(version) uvicorn-(version)
(venv) $
Controller and request parameters
When working with FastAPI, requests made to the server are handled by uvicorn and forwarded to FastAPI. Somewhat similar to working with Spring Boot, when using FastAPI, annotations are used to specify which functions handle requests made with specific request methods to specific paths. In Python, these annotations are called decorators.
The following example outlines a simple application that responds to requests with the message "Hello world!".
from fastapi import FastAPI, Response
app = FastAPI()
@app.get("/")
def hello():
data = "Hello world!"
return Response(content=data, media_type="text/plain")
In the above example, we import FastAPI and Response from the fastapi library, then create a FastAPI application, and decorate a function called hello
with a decorator that maps GET requests to the root path of the application to that function. In the function hello
, we create a response with the media type (content-type) "text/plain" and the content "Hello world!".
Save the above content to a file called application.py
and place that file to the same directory with the virtual environment folder. Below, the virtual environment folder is called venv
.
(venv) $ tree --dirsfirst
.
├── venv
│ // plenty of stuff
| └── // plenty of stuff of stuff
└── application.py
Now, we can launch the application using uvicorn. The command uvicorn application:app --port 7777 --reload
starts a web server that uses the app
created in the application.py
for handling requests. The server is started on the port 7777
; the command --reload
is similar to Deno's --watch
, where changes made to the source code will lead to the changes being reloaded and the server restarted.
source venv/bin/activate
(venv) $ uvicorn application:app --port 7777 --reload
INFO: Started reloader process [(id)] using statreload
INFO: Started server process [(id)]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:7777 (Press CTRL+C to quit)
As we see from the logs above, the server is listening to requests on port 7777. Now, opening another console window, we can make a request to the server and check the response.
curl http://localhost:7777
Hello world!%
The server responds to the request with the message Hello world!
as expected.
Next, let us add the functionality for posting data to the server.
To process posted data (form-like data) with FastAPI, we need to add a library called python-multipart, which is used to process multipart request data including forms. Libraries are installed with pip -- let's add python-multipart
to the project.
source venv/bin/activate
(venv) $ pip install python-multipart
Collecting python-multipart
Downloading ...
// ...
Installing collected packages: six, python-multipart
Running setup.py install for python-multipart ... done
Successfully installed python-multipart-(version) six-(version)
When working with form data, we import Form
from the fastapi
library. Variables extracted from data sent to the server are added as function parameters to the functions that are responsible for handling requests. Function parameters are defined in the form variable_name = Form(...)
, where variable_name
refers to the name of a variable sent within the request body.
The following example shows an application that responds to GET requests made to the root path of the application with a message. The message can be changed with a POST request to the root path of the application -- the POST request needs to contain a variable message
. In the setMessage
function below, we refer to the the variable _message
as global to change its value.
from fastapi import FastAPI, Response, Form
from fastapi.responses import RedirectResponse
app = FastAPI()
_message = "world"
@app.get("/")
def hello():
data = "Hello " + _message + "!"
return Response(content=data, media_type="text/plain")
@app.post("/")
def set_message(message = Form(...)):
global _message
_message = message
return RedirectResponse("/")
In the above example, we also respond to POST requests made to the server with a redirect suggestion. To do this, we import RedirectResponse
from fastapi.responses
and use it as a return value, giving the RedirectResponse the target path as a parameter.
Now, when we make a POST request to the server and send a message within the body of the request, the message in the request will be set as the value for the variable _message
. After this, when GET requests are made to the server, the new value is returned as a part of the response.
curl http://localhost:7777
Hello world!%
curl --verbose --request POST --data "message=FastAPI" http://localhost:7777
// ...
< HTTP/1.1 307 Temporary Redirect
< location: /
// ...
curl http://localhost:7777
Hello FastAPI!%
A simple API
Let's again create a simple API that can be used to add and read songs and their ratings in a database. For the API, we use the same database table as previously.
CREATE TABLE songs (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
rating INTEGER NOT NULL
);
For database access, we use SQLAlchemy, which is a library for accessing database and a Object-relational Mapping framework. In addition to SQLAlchemy, we use a binary version of psycopg2 as the PostgreSQL driver. Let's add these to the project.
$ source venv/bin/activate
(venv) $ pip install sqlalchemy
Collecting sqlalchemy
Downloading ...
Installing collected packages: sqlalchemy
Successfully installed sqlalchemy-(version)
(venv) $ pip install psycopg2-binary
Collecting psycopg2-binary
Downloading ...
Installing collected packages: psycopg2-binary
Successfully installed psycopg2-binary-(version)
In the above example, we installed psycopg2-binary
instead of psycopg2
. In practice, for production applications, you would typically install psycopg2
, which potentially requires other dependencies depending on the used operating system.
Let's next create a file for the database configuration. We call the file database.py
-- in the file, we create a database engine
, which will be responsible for handling the database connection specifics and also acts as a connection pool. Then, we create a scoped_session
which provides the actual database session and will help isolate the database connection for the current thread.
When working with psycopg2
, PostgreSQL database url is given in the format postgresql+psycopg2://username:password@hostname:port/database
.
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
DATABASE_URL = "postgresql+psycopg2://my_user:my_password@my_db_host_possibly_at.elephantsql.com:5432/my_database"
engine = create_engine(DATABASE_URL)
Connection = scoped_session(sessionmaker(bind=engine))
In addition, we also create a function db
that is used for retrieving a database connection. After we have created the connection, we yield
it for others to use -- this way, the connection is open until the end of the execution of the function that uses the connection -- after that, code in finally
is executed, and the connection is closed.
def db():
connection = Connection()
try:
yield connection
finally:
connection.close()
Together, the database.py
file looks as follows.
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
DATABASE_URL = "postgresql+psycopg2://my_user:my_password@my_db_host_possibly_at.elephantsql.com:5432/my_database"
engine = create_engine(DATABASE_URL)
Connection = scoped_session(sessionmaker(bind=engine))
def db():
connection = Connection()
try:
yield connection
finally:
connection.close()
Next, we create a model for accessing to database. Similar to when working with Spring, when using SQLAlchemy, we create a class that corresponds to the database table. When defining the class, we also define the table name and the columns -- we use helpers from SQLAlchemy when defining these.
Create a file called models.py
and copy the following content to the file.
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String
Base = declarative_base()
class Song(Base):
__tablename__ = "songs"
id = Column(Integer, primary_key=True)
name = Column(String)
rating = Column(Integer)
In the above example, we create a class Song
that extends a base model from SQLAlchemy. A song corresponds to the table songs
in the database and has an id, a name, and a rating. The id is an integer and also acts as the primary key for the table, while the name is a string and the rating is an integer. As shown in the example above, when working with SQLAlchemy, we explicitly specify the attributes as columns.
Now that we have created the functionality for connecting to the database and the model that corresponds to the database table that we are working with, it is time to create the API. We first create one new route to our application.py
. When a user makes a GET request to the path /songs
, the application returns all songs from the database.
To achieve this, we import the db
function from database.py
and the Song
class from models.py
. When defining the function that retrieves a song, we use a mechanism similar to the one that we saw when working with Spring. The function get_songs
that is responsible for returning songs to the user depends on the database -- when the function is called, a new database session is created and injected for the use of the function.
The concrete query that selects all songs from the database is written as database_session.query(Song).all()
, where database_session
refers to the database session created for the function call. Instead of using SQL (which we could use), we use a domain-specific language from SQLAlchemy for building database queries.
from fastapi import Depends, FastAPI, Response, Form
from fastapi.responses import RedirectResponse
from database import db
from models import Song
app = FastAPI()
# ...
@app.get("/songs")
def get_songs(database_session = Depends(db)):
return database_session.query(Song).all()
Now, when we launch our application, we can list the songs from the database.
curl http://localhost:7777/songs
[{"rating":5,"name":"Songiti song song","id":1},{"rating":5,"name":"Happy song","id":2}]%
Next, we add the functionality for creating a new song object. While with FastAPI, it would be typical to use pydantic for defining objects that could be directly read from the request, in the following example we extract each variable from the request one by one for convenience (otherwise, we could end up with two similar objects, one made with pydantic, and one made with SQLAlchemy).
Extracting data from a request is done using Body
, which works similar to Form
that we used previously. In the following example, we have defined a function called add_song
. The parameters name
and rating
are read from the request body and their values are set when the function is called. The parameter database_session
is used similarly to the previous example.
In the add_song
function, we create a new Song
object, using the values from the request body. Then, we add the newly created song to the database using the method add
. Finally, we commit the changes to the database (i.e. committing the database transaction).
from fastapi import Depends, FastAPI, Response, Form, Body
from fastapi.responses import RedirectResponse
from database import db
from models import Song
app = FastAPI()
# ...
@app.post("/songs")
def add_song(name: str = Body(...), rating: int = Body(...), database_session = Depends(db)):
song = Song(name = name, rating = rating)
database_session.add(song)
database_session.commit()
Now, when we run the application, we can add data to the database.
curl --verbose --header "Content-Type: application/json" --request POST --data "{\"name\":\"Different song\",\"rating\":2}" http://localhost:7777/songs
// ...
< HTTP/1.1 200 OK
curl http://localhost:7777/songs
[{"rating":5,"name":"Songiti song song","id":1},{"rating":5,"name":"Happy song","id":2},{"rating":2,"name":"Different song","id":3}]%
As a whole, the application.py
looks as follows.
from fastapi import Depends, FastAPI, Response, Form, Body
from fastapi.responses import RedirectResponse
from database import db
from models import Song
app = FastAPI()
_message = "world"
@app.get("/")
def hello():
data = "Hello " + _message + "!"
return Response(content=data, media_type="text/plain")
@app.post("/")
def set_message(message = Form(...)):
global _message
_message = message
return RedirectResponse("/")
@app.get("/songs")
def get_songs(database_session = Depends(db)):
return database_session.query(Song).all()
@app.post("/songs")
def add_song(name: str = Body(...), rating: int = Body(...), database_session = Depends(db)):
song = Song(name = name, rating = rating)
database_session.add(song)
database_session.commit()
One of the features of FastAPI is that it automatically creates documentation for the APIs using Swagger. When you run the application, go to the address http://localhost:7777/docs
to see what the automatically created documentation looks like.
Brief summary
When working with FastAPI and Python, we use pip
for handling libraries and a virtual environment created using venv
to restrict the loaded libraries to the current project. Decorators from the FastAPI library are used to specify which requests are mapped to which functions, and content from the request body is added as function parameters using FastAPI functionality. Database connections are also added as function parameters to those functions that require database functionality.
In the above example, we did not go into working with templates, but one could e.g. use jinja. Similar to when working with Spring, proper tooling helps when working with FastAPI (and Python). While in the above examples, we rarely specified the types of the used variables, it is becoming more common to specify types when working with Python code, which also helps IDEs to provide better suggestions.
We also observed that FastAPI creates an API documentation for the project. Swagger can be used in other frameworks and languages as well -- for example, to use Swagger in Spring, we would add the springdoc-openapi dependency to the project. For Deno and oak, which we have mostly used in the course, at the time of writing this material, no functionality exists for automatically generating the API documentation.