FastAPI Examples

This is an example of the Tortoise-ORM FastAPI integration

Usage:

uvicorn main:app --reload

Basic non-relational example

models.py

from tortoise import fields, models


class Users(models.Model):
    """
    The User model
    """

    id = fields.IntField(primary_key=True)
    #: This is a username
    username = fields.CharField(max_length=20, unique=True)
    name = fields.CharField(max_length=50, null=True)
    family_name = fields.CharField(max_length=50, null=True)
    category = fields.CharField(max_length=30, default="misc")
    password_hash = fields.CharField(max_length=128, null=True)
    created_at = fields.DatetimeField(auto_now_add=True)
    modified_at = fields.DatetimeField(auto_now=True)

    def full_name(self) -> str:
        """
        Returns the best name
        """
        if self.name or self.family_name:
            return f"{self.name or ''} {self.family_name or ''}".strip()
        return self.username

    class PydanticMeta:
        computed = ["full_name"]
        exclude = ["password_hash"]

tests.py

# mypy: no-disallow-untyped-decorators
# pylint: disable=E0611,E0401
import multiprocessing
from collections.abc import AsyncGenerator
from concurrent.futures import ProcessPoolExecutor
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path

import anyio
import pytest
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient

from tortoise.contrib.test import truncate_all_models
from tortoise.fields.data import JSON_LOADS
from tortoise.timezone import UTC, localtime

try:
    from config import register_orm
    from main import app
    from main_custom_timezone import app as app_east
    from models import Users
    from schemas import User_Pydantic
except ImportError:
    if (cwd := Path.cwd()) == (parent := Path(__file__).parent):
        dirpath = "."
    else:
        dirpath = str(parent.relative_to(cwd))
    print(f"You may need to explicitly declare python path:\n\nexport PYTHONPATH={dirpath}\n")
    raise

ClientManagerType = AsyncGenerator[AsyncClient, None]


@pytest.fixture(scope="module")
def anyio_backend() -> str:
    return "asyncio"


@asynccontextmanager
async def client_manager(app, base_url="http://test", **kw) -> ClientManagerType:
    async with LifespanManager(app):
        transport = ASGITransport(app=app)
        async with AsyncClient(transport=transport, base_url=base_url, **kw) as c:
            yield c


@pytest.fixture(scope="module")
async def client() -> ClientManagerType:
    async with client_manager(app) as c:
        await truncate_all_models()
        yield c


@pytest.fixture(scope="module")
async def client_east() -> ClientManagerType:
    # app_east uses _enable_global_fallback=False, so we need to explicitly
    # enter the context from app.state to make it current for tests
    async with client_manager(app_east) as c:
        ctx = app_east.state._tortoise_context
        with ctx:  # Enter context to make it current via contextvar
            await truncate_all_models()
            yield c


class UserTester:
    async def create_user(self, async_client: AsyncClient) -> Users:
        response = await async_client.post("/users", json={"username": "admin"})
        assert response.status_code == 200, response.text
        data = response.json()
        assert data["username"] == "admin"
        assert "id" in data
        user_id = data["id"]

        user_obj = await Users.get(id=user_id)
        assert user_obj.id == user_id
        return user_obj

    async def user_list(self, async_client: AsyncClient) -> tuple[datetime, Users, User_Pydantic]:
        utc_now = datetime.now(UTC)
        user_obj = await Users.create(username="test")
        response = await async_client.get("/users")
        assert response.status_code == 200, response.text
        data = response.json()
        assert isinstance(data, list)
        item = await User_Pydantic.from_tortoise_orm(user_obj)
        item_dict = JSON_LOADS(item.model_dump_json())
        api_item = next((x for x in data if x["id"] == user_obj.id), None)
        assert api_item is not None, f"User {user_obj.id} not found in response"
        for key, value in item_dict.items():
            assert key in api_item, f"Key {key!r} missing from API response"
            if key in ("created_at", "modified_at"):
                # Compare as datetime objects to handle timezone format differences
                # (Pydantic normalizes to UTC, FastAPI preserves original timezone)
                # Replace trailing 'Z' with '+00:00' for fromisoformat() compatibility
                a = datetime.fromisoformat(api_item[key].replace("Z", "+00:00"))
                b = datetime.fromisoformat(value.replace("Z", "+00:00"))
                assert a == b, f"Datetime mismatch on {key!r}: {api_item[key]} != {value}"
            else:
                assert api_item[key] == value, f"Mismatch on {key!r}"
        return utc_now, user_obj, item


class TestUser(UserTester):
    @pytest.mark.anyio
    async def test_create_user(self, client: AsyncClient) -> None:  # nosec
        await self.create_user(client)

    @pytest.mark.anyio
    async def test_user_list(self, client: AsyncClient) -> None:  # nosec
        await self.user_list(client)


@pytest.mark.anyio
async def test_404(client: AsyncClient) -> None:
    response = await client.get("/404")
    assert response.status_code == 404, response.text
    data = response.json()
    assert isinstance(data["detail"], str)


@pytest.mark.anyio
async def test_422(client: AsyncClient) -> None:
    response = await client.get("/422")
    assert response.status_code == 422, response.text
    data = response.json()
    assert isinstance(data["detail"], list)
    assert isinstance(data["detail"][0], dict)


class TestUserEast(UserTester):
    timezone = "Asia/Shanghai"
    delta_hours = 8

    @pytest.mark.anyio
    async def test_create_user_east(self, client_east: AsyncClient) -> None:  # nosec
        user_obj = await self.create_user(client_east)
        created_at = user_obj.created_at

        # Verify time zone
        asia_now = localtime(timezone=self.timezone)
        assert created_at.hour - asia_now.hour == 0

        # UTC timezone
        utc_now = localtime(timezone="UTC")
        assert (created_at.hour - utc_now.hour) in [self.delta_hours, self.delta_hours - 24]

    @pytest.mark.anyio
    async def test_user_list(self, client_east: AsyncClient) -> None:  # nosec
        time, user_obj, item = await self.user_list(client_east)
        created_at = user_obj.created_at
        assert (created_at.hour - time.hour) in [self.delta_hours, self.delta_hours - 24]
        assert item.model_dump()["created_at"].hour == created_at.hour


@pytest.mark.anyio
async def test_404_east(client_east: AsyncClient) -> None:
    response = await client_east.get("/404")
    assert response.status_code == 404, response.text
    data = response.json()
    assert isinstance(data["detail"], str)


@pytest.mark.anyio
async def test_422_east(client_east: AsyncClient) -> None:
    response = await client_east.get("/422")
    assert response.status_code == 422, response.text
    data = response.json()
    assert isinstance(data["detail"], list)
    assert isinstance(data["detail"][0], dict)


def query_without_app(pk: int) -> int:
    async def runner() -> bool:
        async with register_orm():
            return await Users.filter(id__gt=pk).count()

    return anyio.run(runner)


def test_query_without_app():
    multiprocessing.set_start_method("spawn")
    with ProcessPoolExecutor(max_workers=1) as executor:
        future = executor.submit(query_without_app, 0)
        assert future.result() >= 0

main.py

# pylint: disable=E0611,E0401
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager

from config import register_orm
from fastapi import FastAPI
from routers import router as users_router

from tortoise.contrib.fastapi import tortoise_exception_handlers


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    async with register_orm(app):
        # db connected
        yield
        # app teardown
    # db connections closed


app = FastAPI(
    title="Tortoise ORM FastAPI example",
    lifespan=lifespan,
    exception_handlers=tortoise_exception_handlers(),
)
app.include_router(users_router, prefix="")