Sonic APIs with FastAPI, SQLModel, FastAPI-crudrouter and testcontainers

In this post, we’ll take a look at three cool FastAPI components that will allow you to write self-documented endpoints with zero boilerplate code. At the end of the post, we’ll also take a peak at an opinionated way of using testcontainers to perform integration tests on your service.

Dependency overview

FastAPI - python framework. It’ll automatically generate your service’s OpenAPI schema based on your pydantic models and routes. Also, FastAPI allows you to use either async or sync routes without enforcing them.

SQLModel - built on top of sqlalchemy and pydantic, by the same striking creator of FastAPI, SQLModel allows you to define your database models (ORM) on top of your pydantic models. Having data validation and model definitions in the same place allows us to write less code.

FastAPI-crudrouter - automatically generates CRUD routes for you based on your pydantic models and Backends / ORMs. (In this post we’ll take a look at SQLAlchemy since that’s what SQLModel uses by default).

Testcontainers - launch containers in order to preform integration testing.

Setup

# requirements.txt
fastapi==0.70.0
uvicorn[standard]==0.15.0
sqlmodel==0.0.4
psycopg2==2.9.1
psycopg2-binary==2.9.1
fastapi-crudrouter==0.8.4
testcontainers==3.4.2

Just mkdir and run the following to set up your directory

virtualenv -p python3.8 -v venv
source venv/bin/activate
pip3 install -r requirements.txt

Application code

Take a look at the code below and the comments under it.

# main.py

import os
from datetime import time

from fastapi import FastAPI
from fastapi_crudrouter import SQLAlchemyCRUDRouter
from sqlmodel import Field, Session, SQLModel, create_engine


class DemoIn(SQLModel):
    description: str
    init: time
    end: time

class Demo(DemoIn, table=True):
    id: int = Field(primary_key=True)


DATABASE: str = os.getenv("DATABASE", "db")
DB_USER: str = os.getenv("DB_USER", "user")
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "password")
DB_HOST: str = os.getenv("DB_HOST", "localhost")
DB_PORT: str = os.getenv("DB_PORT", "5432")

SQLALCHEMY_DATABASE_URL = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DATABASE}"

engine = create_engine(SQLALCHEMY_DATABASE_URL)

# Dependency
def get_db():
    db = Session(engine)
    try:
        yield db
    finally:
        db.close()


demo_router = SQLAlchemyCRUDRouter(
    schema=Demo,
    create_schema=DemoIn,
    db_model=Demo,
    db=get_db
)

app = FastAPI()
app.include_router(demo_router)


@app.on_event("startup")
async def startup_event():
    SQLModel.metadata.create_all(engine)

# Run the code
docker run --name postgres -p 5432:5432 -e POSTGRES_USER=user -e POSTGRES_PASSWORD=password -e POSTGRES_DB=db -d postgres:13
uvicorn main:app --reload

Once we have our database container up, we can launch an uvicorn worker and access our service’s /docs endpoint. We’ll find 6 CRUD routes provided by fastapi-crudrouter, automatically generated. We can then use the Swagger UI in order to trigger our endpoints and make sure everything is working as expected.

logo

On main.py we create two classes that inherit from SQLModel, DemoIn that inherits directly and Demo because it inherits from DemoIn. SQLModel takes care of SQLAlchemy and Pydantic for us, under the hood. We then create an instance of SQLAlchemyCRUDRoute. Provide our db session and db_model (defining table=True makes tells SQLModel to map that class as a table in your database). Provide our schema and optionally create_schema.

Taking a look at the contents of our Postgres container we can see that a table named demo was created with the proper fields (id, description, init and end).

~/tmp » docker exec -it postgres psql -d db -U user -c "\d+ demo"                         
                                                          Table "public.demo"
   Column    |          Type          | Collation | Nullable |             Default              | Storage  | Stats target | Description 
-------------+------------------------+-----------+----------+----------------------------------+----------+--------------+-------------
 description | character varying      |           | not null |                                  | extended |              | 
 init        | time without time zone |           | not null |                                  | plain    |              | 
 end         | time without time zone |           | not null |                                  | plain    |              | 
 id          | integer                |           | not null | nextval('demo_id_seq'::regclass) | plain    |              | 
Indexes:
    "demo_pkey" PRIMARY KEY, btree (id)
    "ix_demo_description" btree (description)
    "ix_demo_end" btree ("end")
    "ix_demo_id" btree (id)
    "ix_demo_init" btree (init)
Access method: heap

Taking a closer look we’ll see that all the columns are indexed with btree, interesting 🤔. Turns out this is actually a bug which should be on its way to getting fixed (SQLModel is only in the 0.0.4 version at the time of writing).

Testing

# integration_test.py

import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm.session import Session
from sqlmodel import Session, SQLModel, create_engine
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import wait_for_logs

from main import app, get_db

POSTGRES_IMAGE = "postgres:13"
POSTGRES_USER = "postgres"
POSTGRES_PASSWORD = "test_password"
POSTGRES_DATABASE = "test_database"
POSTGRES_CONTAINER_PORT = 5432


@pytest.fixture(scope="function")
def postgres_container() -> DockerContainer:
    """
    Setup postgres container
    """
    postgres = DockerContainer(image=POSTGRES_IMAGE) \
        .with_bind_ports(container=POSTGRES_CONTAINER_PORT) \
        .with_env("POSTGRES_PASSWORD", POSTGRES_PASSWORD) \
        .with_env("POSTGRES_DB", POSTGRES_DATABASE)

    with postgres:
        wait_for_logs(
            postgres, r"UTC \[1\] LOG:  database system is ready to accept connections", 10)

        yield postgres


@pytest.fixture(scope="function")
def http_client(postgres_container: DockerContainer):

    def get_db_override() -> Session:
        url = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{postgres_container.get_container_host_ip()}:{postgres_container.get_exposed_port(POSTGRES_CONTAINER_PORT)}/{POSTGRES_DATABASE}"
        engine = create_engine(url)
        SQLModel.metadata.create_all(engine)
        with Session(engine) as session:
            yield session

    app.dependency_overrides[get_db] = get_db_override

    with TestClient(app) as client:
        yield client
    app.dependency_overrides.clear()


# Test our CRUD routes using the http client provided by FastApi
def test_demo_crud(http_client: TestClient):

    post_resp = http_client.post("/demo", json={
        "description": "Finally free, found the God in me, And I want you to see, I can walk on water 🌬",
        "init": "10:00:00",
        "end": "18:00:00",
    })
    assert post_resp.status_code == 200

    get_resp = http_client.get("/demo/1")
    assert get_resp.status_code == 200

    # Let's transform our json response into a Demo object and preform some validations 🤝
    from main import Demo

    demo = Demo(**get_resp.json())

    assert demo.id == 1
    assert demo.end > demo.init
    assert "God" in demo.description
    assert "Enemy" not in demo.description

Above we have integration testing for our service. This is an opinionated way on how to structure your tests using, pytest fixtures, testcontainers, FastAPI’s TestClient.

First of all, we define a function fixture postgres_container, this function is responsible for launching our postgres containers and making sure that they are ready before being yielded (notice the wait_for_logs call).

We then define http_client (another function fixture), and we provide postgres_container as a dependency for it (notice the dependency injection http_client: TestClient), we could use the same pattern to inject other dependencies needed for our service, think blob storage containers, containers containing some sort of message queuing system, etc. Notice how we override get_db with get_db_override, this is crucial so that our http client knows where to point to in order to reach our postgres container (Notice that each container is exposing a different port on the host machine).

With this setup, each of our tests that receive http_client as a dependency will receive their own postgres container and http client, leaving them totally isolated from other tests. test_demo_crud serves as a dummy example for us to understand how to interact with our service.

Conclusion

I hope this post was useful for you. We took a look at a spicy combination of libraries/framworks that allow for really quick development and testing. I wouldn’t say that this stack is the best choice for production critical systems, but it might be useful in many scenarios, take your conclusions 🤓.

· python, fastapi, sqlmodel, fastapi-crudrouter