Minimal Project in FastAPI
This article shows the creation of a minimal FastAPI project, basically a very robust "Hello World". The idea is to start even the smallest FastAPI project in the best possible way, with an adequate structure, using a virtual environment, code linting, continuous integration (GitHub Actions), version control and automated tests. Then, you can expand the project however you see fit, using it for serverless applications, data science, REST API, teaching programming, other templates, etc.
The main difference compared to other templates is that this one only has the smallest possible set of functionalities according to good software engineering practices.
There are several templates available for FastAPI and some are listed in the project documentation itself. Each of them represents a different opinion of how the ideal project is and this opinion reflects in the features, libraries and frameworks adopted. That is great if the project you want to create fits what the template offers. On the other hand, it is hard to find a perfect match between your needs and the available templates. Even finding a similar one, it is still very difficult to customize it by removing or replacing part of its implementation decisions.
Rather than creating yet another opinionated template, we are going to build a minimal FastAPI project to serve as a basis for more complex and specific applications. We are going to use a "Hello World" application as a starting point because it is the smallest possible functional application in a language or framework. Then, we will make incremental improvements until we reach the state that will serve as the basis for the template of a minimal FastAPI project.
Initial Application
The most elementar Hello World
project in FastAPI consists of a main file (main.py
) and a test file (test_hello_world.py
).
Hello World Elementar ===================== hello_world ├── main.py └── test_hello_world.py
The file main.py
contains:
from fastapi import FastAPI app = FastAPI() @app.get('/') def say_hello() -> dict[str, str]: return {'message': 'Hello World'}
As there is no specific Python
or FastAPI
command to run the application, it is necessary to use an ASGI web server such as Hypercorn or Uvicorn. Installing FastAPI
and hypercorn
in a virtual environment and running the hypercorn
command is shown below:
|
$ python -m venv .venv
|
|
$ source .venv/bin/activate
|
|
(.venv) $ pip install fastapi hypercorn
|
|
...
|
|
(.venv) $ hypercorn main:app
|
|
[...] [INFO] Running on http://127.0.0.1:8000 (CTRL + C to quit)
|
where main:app
(line 5) specifies the use of the app
variable within the main.py
module.
Accessing http://localhost:8000
through httpie, we get the following result:
$ http :8000 HTTP/1.1 200 content-length: 25 content-type: application/json date: ... server: hypercorn-h11 { "message": "Hello World" }
You prefer, you can use curl
instead:
The file test_hello_world.py
contains:
from fastapi.testclient import TestClient from main import app client = TestClient(app) def test_say_hello() -> None: response = client.get('/') assert response.status_code == 200 assert response.json() == {'message': 'Hello World'}
To run the tests, you also need pytest
and httpx
to be installed:
Then run the command:
(.venv) $ pytest ================================= test session starts ================================== platform linux -- Python 3.10.7, pytest-7.2.0, pluggy-1.0.0 rootdir: /tmp/hello_world plugins: anyio-3.6.2 collected 1 item test_hello.py . [100%] ================================== 1 passed in 0.23s ===================================
These two files are sufficient for an illustrative example, but they do not form a project that can be used in production. We will improve the project in the next sections.
Python Perfect Project + FastAPI
Since every FastAPI project is also a Python project, you can take advantage of all the structure and configuration set up in the article How to Set up a Perfect Python Project and apply it to the Hello World
FastAPI project. That automatically fixes:
- Virtual environment
- Version control
- Linting
- Automated testing
- Continuous integration
The listing below shows the structure of the Hello, World
project side by side before (left side) and after (right side) applying the perfect Python project
template:
Hello World Elementar Hello World + Perfect Python Project ===================== ==================================== hello_world hello_world │ ├── .github │ │ └── workflows │ │ └── continuous_integration.yml │ ├── .hgignore │ ├── hello_world │ │ ├── __init__.py ├── main.py │ └── main.py │ ├── Makefile │ ├── poetry.lock │ ├── pyproject.toml │ ├── README.rst │ ├── scripts │ │ └── install_hooks.sh │ └── tests └── test_hello_world.py └── test_hello_world.py
Installation of Dependencies
The dependencies that came from the template are only related to testing and linting. It is still necessary to install specific dependencies that we will use in the minimal FastAPI project:
$ poetry add fastapi=="*" hypercorn=="*" loguru=="*" orjson=="*" python-dotenv=="*" uvloop=="*" $ poetry add --dev asgi-lifespan=="*" alt-pytest-asyncio=="*" httpx=="*"
The first command installs libraries that are necessary for the application to work in production. The second one installs libraries only used during application development and testing.
Main libraries:
- FastAPI
- Hypercorn is an ASGI web server
- Loguru is a library that aims to make logging more enjoyable
- orjson is a high efficiency JSON library
-
python-dotenv reads
name=value
pairs from a.env
file and sets corresponding environment variables -
uvloop is a high-efficiency implementation that replaces the default solution used in
asyncio
Development libraries:
-
alt-pytest-asyncio is a
pytest
plugin that enables asynchronous fixtures and testing - asgi-lifespan programmatically send startup/shutdown lifespan events into ASGI applications. It allows mocking or testing ASGI applications without having to spin up an ASGI server.
- httpx is a synchronous and asynchronous HTTP library
Reorganizing main.py
The original main.py
file only contains the declaration of the project's single route. However, the number of routes tends to grow over time, and main.py
will eventually become unmanageable.
It is essential to prepare the project so that it can grow in an organized way. A better structure is obtained by declaring routes, models, schemes, etc. in directories and files specific to each abstraction.
The directory structure can be organized by function or entity. The organization by function of a FastAPI project containing a user
entity would look like this:
. ├── models │ ├── __init__.py │ └── user.py ├── routers │ ├── __init__.py │ └── user.py └── schemas ├── __init__.py └── user.py
The other option is to group by entity instead of function. In this case, models, routes and schemas live inside the user
directory:
user ├── __init__.py ├── models.py ├── routers.py └── schemas.py
Using one structure or another is a matter of preference. I prefer to use the grouping structure by function rather than by entity because it is easier to group Python imports this way.
As we only have one route to hello world
and there are no templates or schemas the resulting structure is:
routers ├── __init__.py └── hello.py
hello.py
contains a route to the /hello
endpoint, extracted from main.py
:
from fastapi import APIRouter from loguru import logger router = APIRouter() @router.get('/hello') async def hello_world() -> dict[str, str]: logger.info('Hello world!') return {'message': 'Hello World'}
main.py
The purpose of the new main.py
is to coordinate the configuration of the application, which encompasses the import of the routes, adjust some optimizations and include functions associated with the application's startup and termination events (startup and shutdown):
The ORJSONResponse
class imported on line 2 is a subclass of Response
. It provides an optimization in converting responses to JSON
format as it is based on the orjson library. Line 14 defines ORJSONResponse as the default response class.
Routers are imported (line 6), grouped (lines 8 to 10) and then included in the application (lines 18 and 19). As new routers are created, just keep the default import and grouping in the routers
list.
The main difference compared to the main file is the configuration of the start and end events (lines 15 and 16), related to the lifecycle of an ASGI
application (Lifespan Protocol). These events are triggered by the ASGI
server when instantiating and terminating the application and are the ideal points to create/close connections to databases and other services.
The startup
and shutdown
functions will be implemented in the resources.py
module to keep main.py
as lean as possible.
resources.py
This module concentrates the functions that manage application initiation and termination events. Since this is a minimal FastAPI project, we still don't have connections to databases and other services but the placeholders are there:
Displaying configuration settings in case of DEBUG
(lines 9, 19-26) is not critical, but it's something I often use to help with debugging and tests.
Configuration
Configuration is everything that needs to be tweaked so the application works in a certain environment (development, test, production), such as addresses and credentials to access other services. As recommended by The Twelve-Factor App, configuration should be obtained from environment variables rather than being maintained directly in the project's source code.
.env
File
Despite the recommendation not to keep configuration in files, it is very common to use an .env
file in local development and test environments because keeping environment variables is not very practical: every time you change terminals, close the IDE or restart your computer, you have to set all environment variables again. On the other hand, it is very convenient to keep the local settings in the .env
file and load it automatically during project initiation.
This pattern does not violate the 12-factor-app
principles as long as the .env
file is not tracked by version control.
config.py
The config.py
module is responsible for extracting the environment variables and making necessary checks and adjustments to the configuration:
On line 5, load_dotenv
loads settings from the .env
file, if it exists. By default, load_dotenv
does not overwrite existing environment variables.
On line 7, ENV
holds the environment type where the project is running. It could be production
, development
or testing
. If no value is defined, the default value is production
.
On line 13, DEBUG
holds whether the project is in development mode. Similarly, TESTING
stores whether the project is in test mode (line 14). DEBUG
is often used to influence the information detail level (LOG_LEVEL
), while TESTING
usually signals when to perform some actions such as creating a mass of tests or rolling back database transactions at the end of each test.
On line 17, LOG_LEVEL
indicates the log level of the project. If not set in environment variable, or the configuration is not in development mode, then the default value is INFO
.
On line 18, os.environ['LOGURU_DEBUG_COLOR']
sets the color of DEBUG level log messages that will be used by loguru. It's just a matter of aesthetic preference. It's not essential.
Tests
The biggest problem with synchronous tests such as test_hello_world.py
is that it limits and makes it difficult to test an application based on asynchronous processing. For example, if your application uses a asynchronous database library such as Encode/Databases or aioredis, you may need to make an asynchronous call in a test to confirm that certain information was correctly written to the database after an API call.
These problems just go away if we switch from synchronous to asynchronous tests because making a synchronous call into an async function is effortless.
To adopt asynchronous tests, it will be necessary:
- install an additional pytest plugin for asynchronous tests. There are three options: pytest-asyncio, alt-pytest-asyncio, and anyio. We are going to use
alt-pytest-asyncio
in this project because it solves the problem and doesn't require any additional configuration to use. It's not even necessary to mark the tests withpytest.mark.asyncio
. - replace
TestClient
withhttpx.AsyncClient
as base class for tests
The asynchronous test equivalent of test_hello_world.py
is:
from httpx import AsyncClient from hello_world.main import app async def test_say_hello() -> None: async with AsyncClient(app=app, base_url='http://test') as client: response = await client.get('/') assert response.status_code == 200 assert response.json() == {'message': 'Hello World'}
As a configured AsynClient
instance will be used frequently, let's define it once in a fixture in conftest.py and receive it as a parameter in all tests where necessary.
Using the fixture, test_hello_world.py
is:
from httpx import AsyncClient async def test_say_hello(client: AsyncClient) -> None: response = await client.get('/') assert response.status_code == 200 assert response.json() == {'message': 'Hello World'}
During the reorganization of the project structure, test_hello_world.py
becomes tests/routes/test_hello.py
since the test directory structure mirrors the application directory structure.
conftest.py
conftest.py
is where we define the test fixtures:
Line 9 ensures that the project will run in test mode. Note that this line must be before application import on line 11 for other modules to be correctly configured.
Lines 14 to 17 define the app
fixture that triggers application initiation and termination events. This firing does not happen automatically during tests otherwise, not even by the context manager created in the client
fixture (lines 20 to 23). We need the asgi-lifespan library and the LifespanManager
class for that (line 16).
Aditional Development Automated Tasks
In addition to the test
, lint
, format
and install_hooks
tasks inherited from the perfect Python project
template, let's add a new action that makes it easier to run the application without having to remember the hypercorn
parameters:
To keep the command line short, part of the hypercorn
parameters stays in a configuration file called hypercorn.toml
:
Final Structure
The initial "Hello World" project evolved by first absorbing the structure of the perfect Python project
, and then it was changed to have a more suitable structure for a FastAPI application. The difference between the previous and final directory structure is presented in the listing below:
Hello World + Perfect Python Project Minimum FastaAPI Project ==================================== ======================== hello_world hello_world ├── .github ├── .github │ └── workflows │ └── workflows │ └── continuous_integration.yml │ └── continuous_integration.yml ├── .hgignore ├── .hgignore ├── hello_world ├── hello_world │ ├── __init__.py │ ├── __init__.py │ │ │ ├── config.py │ └── main.py │ ├── main.py │ │ ├── resources.py │ │ └── routers │ │ ├── __init__.py │ │ └── hello.py │ ├── hypercorn.toml ├── Makefile ├── Makefile ├── poetry.lock ├── poetry.lock ├── pyproject.toml ├── pyproject.toml ├── README.rst ├── README.rst ├── scripts ├── scripts │ └── install_hooks.sh │ └── install_hooks.sh └── tests └── tests ├── __init__.py ├── __init__.py └── test_hello_world.py │ ├── conftest.py └── routers ├── __init__.py └── test_hello.py
Final Considerations
During the creation of the minimal FastAPI project, some choices were made based on my personal preferences. For example, the adoption of orjson
and uvloop
for optimization, and alt-pytest-asyncio
to allow asynchronous tests. But as they are few and generic, they compromise neither the objective nor the extensibility of the template.
The minimal FastAPI project, as the name implies, aims to provide a basis for new projects, be they serverless, data science, that use different types of databases, for building REST APIs and even for other templates.
Instead of manually typing all the presented code, use the template available on GitHub.
To instantiate a new project, you need to use cookiecutter. I recommend combining it with pipx:
Comments
Comments powered by Disqus