Files
party/README.md
T
2026-07-24 22:27:22 +00:00

465 lines
15 KiB
Markdown

# 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:
```py
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:
```py
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](https://fastapi.tiangolo.com/tutorial/response-model/).
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:
```py
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:
```py
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:
```py
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:
```py
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:
```py
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:
```py
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:
```py
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:
```py
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:
```json
{
"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:
```py
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:
```py
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](https://tortoise.github.io/), 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.py` for `User` models, `books.py` for `Book` and
`Author` models (e.g. in a books-related application).
- `project.db.migrations`: Contains migrations, autogenerated by the `tortoise` CLI.
- `project.db.fields`: Custom DB fields and field aliases. It contains a
`NanoIDField` function which aliases to a nanoid `CharField`. Add any custom DB
fields here.
- `project.db.lifespan`: Contains `on_startup()` and `on_shutdown()`. They get called
from `project.api.lifespan` when 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:
```py
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:
```py
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:
```py
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:
1. Delete the `project/api/v1/example` directory.
2. Delete the `docs/v1/Example.md` file.
3. Open `project/api/v1/__init__.py` and remove the inclusion of the example handler.
4. Delete the `project/db/models/example.py` file.
5. Open `project/db/settings.py` and remove `project.db.models.example` from the list
of models.
6. Open `project/db/__init__.py` and remove the re-export of the `Book` example 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:
1. Replace all case-sensitive appearances of `project` under the `project/` directory
with your new import name.
2. Replace all case-sensitive appearances of `Project` under the `project/` directory
with your project's name.
3. Rename the `project/` directory to your new import name.
4. Rename your project in `pyproject.toml`.
5. Update the `[tool.tortoise]` section in your `pyproject.toml` file 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.
6. Empty the `docs/` directory.
7. Delete the `project/api/v1/example/` directory.
8. Remove `project.api.v1.example` imports and router inclusion from
`project/api/v1/__init__.py`.
9. Delete the `project/db/models/example.py` file.
10. Remove the `project.db.models.example.*` re-exports from `project/db/__init__.py`.
11. Remove `"project.db.models.example"` from the `DATABASE` object in
`project/lib/settings.py`.
12. Delete the `project/db/migrations/` directory.
### Run the Server
To start the server, run:
```sh
$ uv run uvicorn project:app --reload --reload-include "docs/**/*.md"
```
`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.