Skip to main content

Minimal FastAPI Project

This article demonstrates how to start even the smallest FastAPI project in the best possible way, with a proper structure, using a virtual environment, code linting, continuous integration (GitHub Actions), version control, and automated testing. From there, it's possible to expand the project according to the needs, using it for serverless, data science, REST API, programming education, as a foundation for new templates, and other purposes.

The main difference between this project and other templates is that it contains only a minimal set of functionalities and dependencies to form a solid foundation for other projects.

Introduction

The best way to start a new project is by using a template. This saves time and prevents common configuration errors, as the template contains tested and approved solutions based on the experience of other developers.

There are several project templates available for FastAPI, some of which are listed in the project's documentation. However, it can be challenging to find a template that precisely meets your needs. The most common situation is to choose the one that comes closest and then adjust it, but this can be time-consuming and frustrating because removing or replacing predefined implementation decisions is complicated.

Instead of creating yet another feature-packed template with various dependencies, this tutorial builds a minimal FastAPI project that will serve as a solid initial foundation for more complex and specific projects. We'll start with a basic "Hello World" application, which will be progressively enhanced until it reaches the ideal state that can serve as a template for initial FastAPI projects.

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:

$ curl http://localhost:8000
{"message":"Hello World"}

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:

(.venv) $ pip install pytest httpx
...

Then run the command:

(.venv) $ pytest
================================= test session starts ==================================
platform linux -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /tmp/hello_world
plugins: anyio-3.7.1
collected 1 item

test_hello.py .                                                                  [100%]

================================== 1 passed in 0.74s ===================================

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 based on software engineering best practices.

Python Perfect Project + FastAPI

The article How to Set up a Perfect Python Project presents the creation of an initial foundation to be used with any Python project. Since every FastAPI project is also a Python project, we can apply the same structure and configuration to the minimal FastAPI project. By doing so, we automatically incorporate the following features:

  1. Virtual environment for the project.
  2. Directory structure with separation into different modules and files.
  3. Code linting using tools like ruff and mypy.
  4. Automated testing using frameworks like pytest, integrated with version control and GitHub Actions to ensure that the code is functioning correctly.

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

This is not the final structure and configuration yet. Further changes are still needed for a FastAPI-based project:

  1. Installation of specific FastAPI application dependencies.
  2. Reorganization of the main.py file.
  3. Configuration of the application.
  4. Reorganization of tests.

We will address each of these steps in the following sections.

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=="*" python-dotenv=="*" uvloop=="*"
$ poetry add --group=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
  • 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):

from fastapi import FastAPI
from . import config
from .resources import lifespan
from .routers import hello
app = FastAPI(
    title='Hello World',
    lifespan=lifespan,
)
routers = (
    hello.router,
)
for router in routers:
    app.include_router(router)

The routers are imported (line 5), grouped (lines 11 to 13), and then included in the application (lines 14 and 15).

Line 4 imports lifespan from the resources module, which is then used in creating the application (line 9). lifespan is an async context manager that coordinates the events of the application's startup and shutdown lifecycle. Details about this topic will be covered in the next section.

resources.py

A more complex application will require additional resources such as database connections, caching, queues, etc., which need to be started and shut down properly for the application to function correctly. Even though the minimal FastAPI project doesn't make use of any additional resources, it's essential that the code is prepared for when they are needed in the future. Therefore, the functions handling additional resources will be concentrated in the resources.py module:

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from loguru import logger
from . import config
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator:  # noqa: ARG001
    await startup()
    try:
        yield
    finally:
        await shutdown()
async def startup() -> None:
    show_config()
    # connect to the database
    logger.info('started...')
async def shutdown() -> None:
    # disconnect from the database
    logger.info('...shutdown')
def show_config() -> None:
    config_vars = {key: getattr(config, key) for key in sorted(dir(config)) if key.isupper()}
    logger.debug(config_vars)

The lifespan function (line 10) is an async context manager that coordinates the calls to the startup and shutdown functions of the application. The startup function is called before the application starts, and the shutdown function is called after the application terminates. These are the ideal moments to start/terminate connections with other services and allocate/deallocate resources.

As the minimal FastAPI project doesn't use any additional resources, the startup and shutdown functions essentially contain placeholders for future calls (lines 21 and 26).

The startup function also calls the show_config function (line 19), which displays the configuration variables in case of DEBUG (lines 9, 19-26). This display is useful for debugging and testing purposes.

Configuration

Configuration ensures that the application works correctly in different environments, such as development, testing, and production. In order to avoid sensitive information such as addresses and access credentials being exposed in the project source code, it is recommended that configuration be defined through environment variables.

Despite this recommendation, it is common to use a file called .env to store local environment configurations for development and testing environments. This file avoids the need to manually reset environment variables in each terminal, IDE, or after restarting the computer. There are libraries that automatically identify the .env file and load the environment variables defined in it when the project execution starts. However, it is important to configure version control so that the .env file is not tracked.

config.py

The config.py module is responsible for extracting the environment variables and making necessary checks and adjustments to the configuration:

import os
from dotenv import load_dotenv
load_dotenv()
ENV: str = os.getenv('ENV', 'production').lower()
if ENV not in ('production', 'development', 'testing'):
    raise ValueError(
        f'ENV={ENV} is not valid. '
        "It should be 'production', 'development' or 'testing'"
    )
DEBUG: bool = ENV != 'production'
TESTING: bool = ENV == 'testing'
LOG_LEVEL: str = os.getenv('LOG_LEVEL') or (DEBUG and 'DEBUG') or 'INFO'
os.environ['LOGURU_DEBUG_COLOR'] = '<fg #777>'

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

Synchronous tests, such as the one used in the test_hello_world.py file, significantly limit the ability to test applications based on asynchronous processing. For example, it may be necessary to make asynchronous calls during tests to confirm whether certain information was correctly written to a database after an API call.

Although it is possible to make asynchronous calls in synchronous tests or functions, this requires some programming hacks or the use of additional libraries. On the other hand, these issues do not exist in asynchronous tests, as calling a synchronous function in an asynchronous context is trivial.

To adopt asynchronous tests, it is necessary to:

  1. 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 with pytest.mark.asyncio.
  2. replace TestClient with httpx.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:

import os
from collections.abc import AsyncIterable
from asgi_lifespan import LifespanManager
from fastapi import FastAPI
from httpx import AsyncClient
from pytest import fixture
os.environ['ENV'] = 'testing'
from hello_world.main import app as _app  # noqa: E402
@fixture
async def app() -> AsyncIterable[FastAPI]:
    async with LifespanManager(_app):
        yield _app
@fixture
async def client(app: FastAPI) -> AsyncIterable[AsyncClient]:
    async with AsyncClient(app=app, base_url='http://test') as client:
        yield client

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 to the Makefile that makes it easier to run the application without having to remember the hypercorn parameters:

run_dev:
    ENV=development hypercorn --reload --config=hypercorn.toml 'hello_world.main:app'

To keep the command line short, part of the hypercorn parameters stays in a configuration file called hypercorn.toml:

worker_class = "uvloop"
bind = "0.0.0.0:5000"
accesslog = "-"
errorlog = "-"
access_log_format = "%(t)s %(h)s %(S)s %(r)s %(s)s"

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 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:

$ pipx run cookiecutter gh:andredias/perfect_python_project \
       -c fastapi-minimum

Next article: Packaging and Distribution of the Minimal FastAPI Project

Previous article: How to Set up a Perfect Python Project

Comments

Comments powered by Disqus