Added .env to the reload include options for uvicorn.
Uv + FastAPI + Tortoise | Template
A template FastAPI project with Tortoise ORM integration.
Project Structure
This project is divided into 3 sub-modules, project.api, project.db, and project.lib.
project.api: All the API-related code and the public-facing part of your code.project.db: DB models, fields, migration, setup, and more. The storage part of your code.project.lib: All the business logic of your code. External API calls, internal utilities, and any other internal code goes here.
API
The project.api module contains all the FastAPI code of your project. Routers and
schemas all go in here. The module is divided into v* sub-modules to version the API
by default.
Handler Groups
Each API version module is divided in sub-groups. Each of them usually represents a
single OpenAPI tag, and a group of related handlers. For example, to operate on a
Book resource you may have a project.api.v1.books module with its respective
sub-modules.
Sub-modules inside those tag modules usually include:
router.py: The API router and all the API view handler code.schemas.py: All the handler-specific API schemas.
For example, a minimal router.py file could look like this:
from fastapi import APIRouter
from project.api.v1.example.schemas import MessageSchema
router = APIRouter(tags=["Example"])
@router.get("/hello")
async def hello_world() -> MessageSchema:
"""An example API endpoint that returns a hello world message."""
return MessageSchema(message="Hello, World!")
That example was taken from the example API module bundled by default with this
template. Check it out at project/api/v1/example/router.py.
The code snippet above references project.api.v1.example.schemas, the schemas.py
file mentioned above. The example file looks like this:
from pydantic import BaseModel
class MessageSchema(BaseModel):
message: str
For more information about FastAPI return types and Pydantic models, check FastAPI's tutorial on response types.
To create new handler groups, create a new directory module under project/api/v1 and
name it to your group. Inside it, create an empty __init__.py file and a router.py
file with the following base code:
from fastapi import APIRouter
router = APIRouter()
Next, go to project/api/v1/__init__.py and import the router aliasing it to
{module}_router, e.g. books_router, auth_router, etc.
Last but not least, at the bottom of the file, add the following line:
app.include_router(module_router)
You can check the example module's importing and inclusion lines for a practical example.
API-Wide Schemas
Sometimes, you have version-wide schemas. That may be the response base schema,
pagination schemas, error schemas, or any other schemas the whole API uses. In those
cases, you can use the project/api/v*/schemas.py module and include them there.
For example, given that you want a base response schema where data is always in a
data property in an object (JSON), your project/api/v1/schemas.py would look like
this:
from pydantic import BaseModel
class ResponseSchema[T](BaseModel):
data: T
The included project/api/v1/schemas.py file includes a NanoID type by default. It
represents NanoID fields, as the name conveys, and it's useful when using nanoids for
your models instead of numeric IDs, UUIDs, or any other ID type. You can use it like
follows:
from pydantic import BaseModel, Field
from project.api.v1.schemas import NanoID
class ExampleSchema(BaseModel):
id: NanoID
# Or
id: NanoID = Field(..., description="This example's ID.")
It can also be used as path parameters, query parameters, and anywhere that takes a pydantic model in FastAPI. For example:
from project.api.v1.schemas import NanoID
@router.get("/example/{example_id}")
def get_example(example_id: NanoID) -> None:
...
Schema Conventions
Schemas are always named *Schema in this template to differenciate from models.
For example, a Book model cannot have a Book schema because it'd cause name
conflicts in router.py files. A BookSchema schema allows you to differenciate the
schema from the model easily.
Additionally, model-representing schemas have a from_orm(cls, obj: Model) method that
simplifies the conversion of models to schemas. For example, taking our example Book
model in project/db/models/example.py, a BookSchema would look like follows:
from datetime import datetime
from pydantic import BaseModel
from project.db import Book
from project.api.schemas import NanoID
class BookSchema(BaseModel):
id: NanoID
title: str
author: str
published_at: datetime
created_at: datetime
updated_at: datetime
@classmethod
def from_orm(cls, obj: Book) -> "BookSchema":
return cls(
id=obj.id,
title=obj.title,
author=obj.author,
published_at=obj.published_at,
created_at=obj.created_at,
updated_at=obj.updated_at,
)
Your handler code would then look like:
from project.db import Book
from project.api.v1.errors import NotFoundError
from project.api.v1.schemas import ErrorSchema, NanoID
from project.api.v1.books.schemas import BookSchema
@router.get("/books/{book_id}", responses={
200: BookSchema,
404: ErrorSchema,
422: ErrorSchema,
500: ErrorSchema,
})
async def get_book_by_id(book_id: NanoID) -> BookSchema:
"""Fetches a book by ID."""
book = Book.get_or_none(id=book_id)
if book is None:
raise NotFoundError("book")
return BookSchema.from_orm(book)
Errors
You usually want to have a standard error schema within your API for your clients to easily parse errors. This template makes it easy to raise errors in a standard way.
The project/api/errors.py file contains a few pre-defined error types you can raise
right away from your API handlers to return errors. For example:
from project.api.v1.errors import NotFoundError
from project.api.v1.schemas import ErrorSchema
@router.get("/not-found", status_code=404)
def not_found() -> ErrorSchema:
raise NotFoundError("duck")
When calling that endpoint, the error will look like:
{
"title": "Not Found",
"message": "The requested duck was not found"
}
The response status code will be 404 Not Found.
Any error subclassing project.api.errors.BaseError will be handled and returned
following the schema you saw above. You can follow the default error types provided to
create your own, customize messages, customize the response schema, and more.
For example, to create a 418 I'm a Teapot raisable error type, you'd do:
class ImATeapotError(ErrorInitMixin, BaseError):
title = "I'm a Teapot"
message = "Coffee? That's for losers, we drink Toy Story-themed tea here."
status_code = 418
Your handler will then look something like:
from project.api.v1.errors import ImATeapotError
from project.api.v1.schemas import ErrorSchema
@router.get("/coffee", status_code=418)
def get_coffee() -> ErrorSchema:
raise ImATeapotError()
To customize the error schema, update the ErrorSchema class definition in
project/api/schemas.py and update the project.api.errors.BaseError.handler method
to reflect those changes.
Documentation
The documentation you're reading here has built-in support for writing it using markdown files.
Documentation files live under the docs/ folder, next to the project/ folder at the
top level of the repository. Each file is named after the OpenAPI tag it documents,
like the bundled-in Example.md file. Any markdown files you create in there (prefixed
with .md) will create a tag named after the file's name (without the extension) in
your OpenAPI docs documented with the file's contents.
The only special name there is is the _Project.md file, which documents the API at
the root level.
Database
This template uses Tortoise ORM, a simple and ergonomic ORM that's Django ORM-like but prettier.
This guide does not focus on teaching you how to use Tortoise ORM, rather on the project structure of this template. Check their documentation for more information about ORM usage.
The project.db module has a few sub-modules whose purpose can be inferred based on
the naming. The modules you'll most commonly edit are the following:
project.db(__init__.py): This file re-exports models to make importing elsewhere easier.project.db.models: Contains concern-specific submodules with database model definitions. For example,users.pyforUsermodels,books.pyforBookandAuthormodels (e.g. in a books-related application).project.db.migrations: Contains migrations, autogenerated by thetortoiseCLI.project.db.fields: Custom DB fields and field aliases. It contains aNanoIDFieldfunction which aliases to a nanoidCharField. Add any custom DB fields here.project.db.lifespan: Containson_startup()andon_shutdown(). They get called fromproject.api.lifespanwhen the API goes up and down, applying migrations and initializing DB connections on startup and closing connections on shutdown.
Model Modules
Models are divided into modules. For example, you may create a users.py module for
user-related models like User and Session, and a books.py for Book and Author
models following the books app example.
These modules contain nothing but model definitions. For example, the bundled-in
example.py contains:
from tortoise import fields
from tortoise.models import Model
from project.db.fields import NanoIDField
class Book(Model):
id = NanoIDField(primary_key=True)
title = fields.CharField(max_length=255)
author = fields.CharField(max_length=255)
published_at = fields.DatetimeField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
These modules are pointed at in project.lib.settings.DATABASE, which looks like this
by default:
CONFIG = {
"connections": {"default": os.getenv("DATABASE_URL", "sqlite://db.sqlite3")},
"apps": {
"models": {
"models": [
"project.db.models.example",
],
"default_connection": "default",
"migrations": "project.db.migrations",
}
},
"use_tz": True,
"timezone": "UTC",
}
To create a new models module, create a new module under project/db/models and add it
to the apps.models.models list in the project.lib.settings.DATABASE dict.
Last but not least, re-export your models from project/db/__init__.py. It's a little
QoL thing that makes your life easier later on when importing models. For example, the
default project/db/__init__.py looks like this:
from project.db.models.example import Book as Book
To add a new model, just add it to the imports list.
Migrations
This template uses the Tortoise ORM CLI to handle migrations. Migrations are
automatically-generated and stored under project/db/migrations.
To create a new migration after you make an update, use tortoise migrate.
The server automatically applies any pending migrations on startup. If you wish to
apply such new migrations manually, run tortoise upgrade.
Lib and Business Logic
All your internal business logic goes in the project/lib/ directory.
For example, if you need to add a cache backend to your server, you could create a
cache.py file under project/lib/ containing your caching code, or a directory, you
choose.
Project Settings
The template comes with a settings.py file which works just like a Django
settings.py file. In case you're not familiar with Django, it's just a file with
setting constants. DATABASE_URL, REDIS_URL, THIRD_PARTY_SERVICE_API_KEY, and any
other constants go there.
This template comes with support for .env files by default.
It's a good practice to prefix your project's environment variables with PROJECT_ or
your project's name. It prevents conflicts when running multiple services in the same
environment.
Getting Started
To start with, delete the example models and API router (you can keep it if you want a base to work on).
To do that:
- Delete the
project/api/v1/exampledirectory. - Delete the
docs/v1/Example.mdfile. - Open
project/api/v1/__init__.pyand remove the inclusion of the example handler. - Delete the
project/db/models/example.pyfile. - Open
project/db/settings.pyand removeproject.db.models.examplefrom the list of models. - Open
project/db/__init__.pyand remove the re-export of theBookexample model.
Template Defaults Cleanup
To get started with, you may want to upgrade dependencies and rename the project to something you like better.
To do that, do the following:
- Replace all case-sensitive appearances of
projectunder theproject/directory with your new import name. - Replace all case-sensitive appearances of
Projectunder theproject/directory with your project's name. - Rename the
project/directory to your new import name. - Rename your project in
pyproject.toml. - Update the
[tool.tortoise]section in yourpyproject.tomlfile to point to the new directory and root module name.
The following steps mention deletion, but you can always just update those files
instead if you want to keep them as a base for starting. It also still mentions
project/, which by this time you'll already have renamed. Assume project/ means
your now-renamed source code folder.
- Empty the
docs/directory. - Delete the
project/api/v1/example/directory. - Remove
project.api.v1.exampleimports and router inclusion fromproject/api/v1/__init__.py. - Delete the
project/db/models/example.pyfile. - Remove the
project.db.models.example.*re-exports fromproject/db/__init__.py. - Remove
"project.db.models.example"from theDATABASEobject inproject/lib/settings.py. - Delete the
project/db/migrations/directory.
Run the Server
To start the server, run:
$ uv run uvicorn project:app --reload --reload-include "docs/**/*.md" --reload-include .env
project is your import name.
This template ships with a Dockerfile that will work out of the box by default in
most cases (make sure to update the project's name inside it if you change it). By
default, it uses the following environment variables:
| Name | Description | Possible Values | Default |
|---|---|---|---|
BIND_HOST |
The IP address to bind the server to. | Available IP addresses. | 0.0.0.0 |
BIND_PORT |
The port to bind the server to. | Unused IP address ports. | 8000 |
WORKERS |
The amount of workers to start Uvicorn with. | An integer greater than or equal to 1. | $(nproc) |
If you're prefixing your environment variables with your project's name, make sure to
edit the Dockerfile's CMD statement to reflect those changes.