Template
mirror of
https://github.com/Nekidev/uv-fastapi-tortoise.git
synced 2026-09-12 19:57:25 +00:00
465 lines
15 KiB
Markdown
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" --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.
|