Template
mirror of
https://github.com/Nekidev/uv-fastapi-tortoise.git
synced 2026-09-12 19:57:25 +00:00
First version
This commit is contained in:
+216
@@ -0,0 +1,216 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[codz]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
# lib/ # Commented out to allow inclusion of project.lib modules.
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
# Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
# poetry.lock
|
||||||
|
# poetry.toml
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||||
|
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||||
|
# pdm.lock
|
||||||
|
# pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# pixi
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||||
|
# pixi.lock
|
||||||
|
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||||
|
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||||
|
.pixi
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
*.rdb
|
||||||
|
*.aof
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# RabbitMQ
|
||||||
|
mnesia/
|
||||||
|
rabbitmq/
|
||||||
|
rabbitmq-data/
|
||||||
|
|
||||||
|
# ActiveMQ
|
||||||
|
activemq-data/
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.envrc
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
# .idea/
|
||||||
|
|
||||||
|
# Abstra
|
||||||
|
# Abstra is an AI-powered process automation framework.
|
||||||
|
# Ignore directories containing user credentials, local state, and settings.
|
||||||
|
# Learn more at https://abstra.io/docs
|
||||||
|
.abstra/
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||||
|
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||||
|
# you could uncomment the following to ignore the entire vscode folder
|
||||||
|
# .vscode/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# Marimo
|
||||||
|
marimo/_static/
|
||||||
|
marimo/_lsp/
|
||||||
|
__marimo__/
|
||||||
|
|
||||||
|
# Streamlit
|
||||||
|
.streamlit/secrets.toml
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
#### Handler Groups
|
||||||
|
|
||||||
|
The API 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.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.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/example/router.py`.
|
||||||
|
|
||||||
|
The code snippet above references `project.api.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/` 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/__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 API-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/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/schemas.py` would look like
|
||||||
|
this:
|
||||||
|
|
||||||
|
```py
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseSchema[T](BaseModel):
|
||||||
|
data: T
|
||||||
|
```
|
||||||
|
|
||||||
|
The included `project/api/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.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.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.errors import NotFoundError
|
||||||
|
from project.api.schemas import ErrorSchema, NanoID
|
||||||
|
from project.api.books.schemas import BookSchema
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/books/{book_id}", responses={
|
||||||
|
200: BookSchema,
|
||||||
|
404: ErrorSchema,
|
||||||
|
422: ErrorSchema,
|
||||||
|
500: ErrorSchema,
|
||||||
|
})
|
||||||
|
async def get_book_by_id(id: NanoID) -> BookSchema:
|
||||||
|
"""Fetches a book by ID."""
|
||||||
|
|
||||||
|
book = Book.get_or_none(id=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.errors import NotFoundError
|
||||||
|
from project.api.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.errors import ImATeapotError
|
||||||
|
from project.api.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.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",
|
||||||
|
"aerich.models", # Keep this one for migrations.
|
||||||
|
],
|
||||||
|
"default_connection": "default",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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 [aerich](https://github.com/tortoise/aerich) to handle migrations.
|
||||||
|
Migrations are automatically-generated and stored under `project/db/migrations/models`.
|
||||||
|
|
||||||
|
Migrations are database-specific, meaning that your SQLite migrations won't work on
|
||||||
|
PostgreSQL, and neither will any other DB combination that doesn't mirror the SQL
|
||||||
|
language implementation perfectly.
|
||||||
|
|
||||||
|
The migrations folder is initialized automatically when you start the server if the
|
||||||
|
`project/db/migrations` folder is missing. If you wish to initalize the directory
|
||||||
|
manaully without running the server, run the following in your terminal:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ uv run aerich init-migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
That'll automatically create the migrations folder in the proper location and a first
|
||||||
|
migration file.
|
||||||
|
|
||||||
|
To create a new migration after you make an update, use `aerich migrate`.
|
||||||
|
|
||||||
|
The server automatically creates the `project/db/migrations/` folder if missing and
|
||||||
|
applies any pending migrations on startup.
|
||||||
|
|
||||||
|
Since migrations are database-specific, you'll need to delete the
|
||||||
|
`project/db/migrations/` folder completely when switching database management systems.
|
||||||
|
Note that migrations keep a record of the migrations applied, so deleting the folder
|
||||||
|
means you won't be able to keep making changes on a database following the now-deleted
|
||||||
|
migrations unless you kept a backup of them somewhere and move those back to the
|
||||||
|
`project/db/migrations/` folder back.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## 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/example` directory.
|
||||||
|
2. Delete the `docs/Example.md` file.
|
||||||
|
3. Open `project/api/__init__.py` and remove the inclusion of the example handler.
|
||||||
|
4. Delete the `project/db/models/example.py` file.
|
||||||
|
5. Open `project/db/setup.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.aerich]` 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/example/` directory.
|
||||||
|
8. Remove `project.api.example` imports and router inclusion from
|
||||||
|
`project/api/__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 fastapi run project
|
||||||
|
```
|
||||||
|
|
||||||
|
`project` is your import name.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Some example endpoints for you to get started with. Check out
|
||||||
|
`project/api/example` for the code and `docs/Example.md` for this tag's docs.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
A template FastAPI project with Tortoise ORM integration.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from project.api import app as app
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import ORJSONResponse
|
||||||
|
|
||||||
|
from project.lib import settings
|
||||||
|
from project.api import docs
|
||||||
|
from project.api.errors import BaseError, NotFoundError
|
||||||
|
from project.api.lifespan import lifespan
|
||||||
|
from project.api.example.router import router as example_router
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
debug=settings.DEBUG,
|
||||||
|
title="Project API",
|
||||||
|
description=docs.OPENAPI_DESCRIPTION,
|
||||||
|
version="1.0.0",
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url="/docs",
|
||||||
|
lifespan=lifespan,
|
||||||
|
openapi_tags=docs.OPENAPI_TAGS,
|
||||||
|
default_response_class=ORJSONResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(example_router)
|
||||||
|
|
||||||
|
app.add_exception_handler(BaseError, BaseError.handler)
|
||||||
|
app.add_exception_handler(404, NotFoundError.handler)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
OPENAPI_TAGS = []
|
||||||
|
|
||||||
|
OPENAPI_DESCRIPTION = ""
|
||||||
|
|
||||||
|
|
||||||
|
for doc_file in os.listdir("./docs"):
|
||||||
|
if not doc_file.endswith(".md"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
with open(f"./docs/{doc_file}", "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
name = doc_file.split(".")[0]
|
||||||
|
|
||||||
|
if name == "_Project":
|
||||||
|
OPENAPI_DESCRIPTION = content
|
||||||
|
|
||||||
|
else:
|
||||||
|
OPENAPI_TAGS.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"description": content,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from fastapi.responses import ORJSONResponse
|
||||||
|
|
||||||
|
from project.api.schemas import ErrorSchema
|
||||||
|
|
||||||
|
|
||||||
|
class BaseError(Exception):
|
||||||
|
status_code: int = 500
|
||||||
|
title: str = "Error"
|
||||||
|
message: str = "An error occurred."
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
title: str = ...,
|
||||||
|
message: str = ...,
|
||||||
|
status_code: int = ...,
|
||||||
|
):
|
||||||
|
if title is not ...:
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
if message is not ...:
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
if status_code is not ...:
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def handler(cls, _request, exc: "BaseError"):
|
||||||
|
"""Handles the exception and returns a standardized error response."""
|
||||||
|
|
||||||
|
return ORJSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=ErrorSchema(
|
||||||
|
title=exc.title if hasattr(exc, "title") else cls.title,
|
||||||
|
message=exc.message if hasattr(exc, "message") else cls.message,
|
||||||
|
).model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"{self.__class__.__name__}(title={self.title!r}, message={self.message!r}, status_code={self.status_code!r})"
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorInitMixin:
|
||||||
|
def __init__(self, message: str = ..., *, title: str = ...):
|
||||||
|
if title is not ...:
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
if message is not ...:
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
class InternalServerError(ErrorInitMixin, BaseError):
|
||||||
|
title: str = "Internal Server Error"
|
||||||
|
message: str = "An unexpected error occurred on the server."
|
||||||
|
status_code: int = 500
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(ErrorInitMixin, BaseError):
|
||||||
|
title: str = "Validation Error"
|
||||||
|
message: str = "One or more validation errors occurred."
|
||||||
|
status_code: int = 422
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(ErrorInitMixin, BaseError):
|
||||||
|
title: str = "Conflict"
|
||||||
|
message: str = "The request could not be completed due to a conflict with the current state of the resource."
|
||||||
|
status_code: int = 409
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(BaseError):
|
||||||
|
title: str = "Not Found"
|
||||||
|
message: str = "The requested resource was not found."
|
||||||
|
status_code: int = 404
|
||||||
|
|
||||||
|
def __init__(self, resource: str = "resource"):
|
||||||
|
self.message = f"The requested {resource} was not found."
|
||||||
|
|
||||||
|
|
||||||
|
class ForbiddenError(ErrorInitMixin, BaseError):
|
||||||
|
title: str = "Forbidden"
|
||||||
|
message: str = "You do not have permission to access this resource."
|
||||||
|
status_code: int = 403
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentRequiredError(ErrorInitMixin, BaseError):
|
||||||
|
title: str = "Payment Required"
|
||||||
|
message: str = "Payment is required to access this resource."
|
||||||
|
status_code: int = 402
|
||||||
|
|
||||||
|
|
||||||
|
class UnauthorizedError(ErrorInitMixin, BaseError):
|
||||||
|
title: str = "Unauthorized"
|
||||||
|
message: str = "You must be authenticated to access this resource."
|
||||||
|
status_code: int = 401
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from project.api.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!")
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MessageSchema(BaseModel):
|
||||||
|
message: str
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from project.db.lifespan import on_startup, on_shutdown
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await on_startup()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
await on_shutdown()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
NanoID = Annotated[
|
||||||
|
str, Field(min_length=21, max_length=21, pattern="^[A-Za-z0-9_-]{21}$")
|
||||||
|
]
|
||||||
|
"""A nanoid identifier type for pydantic models.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from project.api.schemas import NanoID
|
||||||
|
|
||||||
|
|
||||||
|
class BookSchema(BaseModel):
|
||||||
|
id: NanoID
|
||||||
|
title: str
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorSchema(BaseModel):
|
||||||
|
"""A standard error response schema."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from project.db.models.example import Book as Book
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import nanoid
|
||||||
|
|
||||||
|
from tortoise import fields
|
||||||
|
|
||||||
|
|
||||||
|
def NanoIDField(*args, **kwargs):
|
||||||
|
"""A nanoid DB field.
|
||||||
|
|
||||||
|
This is an alias for a CharField with a max length of 21 characters, using
|
||||||
|
`nanoid.generate` as the default value generator. The arguments and keyword
|
||||||
|
arguments are passed directly to the CharField constructor.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
*args: Positional arguments for the CharField.
|
||||||
|
**kwargs: Keyword arguments for the CharField.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tortoise.fields.CharField: A CharField configured as a nanoid primary key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return fields.CharField(
|
||||||
|
max_length=21,
|
||||||
|
default=nanoid.generate,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aerich import Command
|
||||||
|
|
||||||
|
from tortoise import Tortoise, connections
|
||||||
|
|
||||||
|
from project.lib import settings
|
||||||
|
|
||||||
|
|
||||||
|
async def on_startup():
|
||||||
|
"""Connects to the database and creates the missing schemas on FastAPI startup."""
|
||||||
|
|
||||||
|
async with Command(
|
||||||
|
tortoise_config=settings.DATABASE, app="models", location="project/db/migrations"
|
||||||
|
) as command:
|
||||||
|
parent_dir = Path(__file__).parent
|
||||||
|
|
||||||
|
if not (parent_dir / "migrations").exists():
|
||||||
|
await command.init_migrations(safe=True)
|
||||||
|
|
||||||
|
await command.upgrade()
|
||||||
|
|
||||||
|
await Tortoise.init(config=settings.CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
async def on_shutdown():
|
||||||
|
"""Closes the database connections on FastAPI shutdown."""
|
||||||
|
|
||||||
|
await connections.close_all()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
CREATE TABLE IF NOT EXISTS "book" (
|
||||||
|
"id" VARCHAR(21) NOT NULL PRIMARY KEY,
|
||||||
|
"title" VARCHAR(255) NOT NULL,
|
||||||
|
"author" VARCHAR(255) NOT NULL,
|
||||||
|
"published_at" TIMESTAMP,
|
||||||
|
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS "aerich" (
|
||||||
|
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
"version" VARCHAR(255) NOT NULL,
|
||||||
|
"app" VARCHAR(100) NOT NULL,
|
||||||
|
"content" JSON NOT NULL
|
||||||
|
);"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
"eJztlm1v2jAQx79KlFdM6lDJoK2maVJgTGVaYWrZg1pVkYlNsEjsNHHaoorvPp9JYhIeBi"
|
||||||
|
"0arcS75H939t3vLso9mQHHxI+rTc7H5kfjyWQoIPKhoB8ZJgpDrYIg0MBXjoPMYxCLCLlC"
|
||||||
|
"akPkx0RKmMRuRENBOZMqS3wfRO5KR8o8LSWM3iXEEdwjYkQiabi5lTJlmDySOHsNx86QEh"
|
||||||
|
"8X0qQY7la6Iyah0lojFH1VnnDdwHG5nwRMe4cTMeIsd5fZgOoRRiIkCJ4rAPJL68ykWa5S"
|
||||||
|
"EFFC8iSxFjAZosQHDOanYcJcqN5giHGKq9kd+cNncwtMLmeAmDIRKwYBenR8wjwxkq9WbT"
|
||||||
|
"qrVrOYeUEiv+zL1rl9WbFq7+BCLvs0a143tVjKNFVHIIFmhyjyGrWgQgZtQTsP2A3wTNDE"
|
||||||
|
"9ZxlyDN4OyHaaGyCtNFYzRRsAFVDRIlEEW1DUUccMOYYw2Tg03hEsIPEIswvEoegAVkOtB"
|
||||||
|
"xbworT4Gr2sAHk9NP/j4zXIO13LtpXffviByQexPGdr5jY/TZYLKVOSmrlpEQ/P8T43emf"
|
||||||
|
"G/BqXPe6bQWMx8KL1I3ar39tQk5yWrnD+IOD8HzZmZxJhWa6EQG0z2hlMXIHjdzH1yJrwD"
|
||||||
|
"3mT9I5eiOdTUd+bWOTED+zscXIQ2P32liVPGxgw/HcYgDCALnjBxRhZ8HCLb7Kd9EUWEFZ"
|
||||||
|
"QQx5qivAFrJM91GbRNQdLdtUU8vaXRVpn1ezrXaY2GJZlcNVnva0YS/bVV846h7c8t6q1U"
|
||||||
|
"/rZx9O6mfSRWWSK6drpr/T7f9j9bwnUQwpbbE2zYUc9ia9fspPY5vdc+b+NgHWjo83ACi9"
|
||||||
|
"VgJUtiJAeaMgbMn/7NtVr7tiSdEhJZA/mSzwBlNXHBlyJRW3rxPrGopQdeGflcGrXNh/yl"
|
||||||
|
"xb33vN8s8IDmhKxnv9vUz/AqoDb/I="
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "t")
|
||||||
|
|
||||||
|
|
||||||
|
DATABASE = {
|
||||||
|
"connections": {"default": os.getenv("DATABASE_URL", "sqlite://db.sqlite3")},
|
||||||
|
"apps": {
|
||||||
|
"models": {
|
||||||
|
"models": [
|
||||||
|
"project.db.models.example",
|
||||||
|
"aerich.models", # Keep this one for migrations.
|
||||||
|
],
|
||||||
|
"default_connection": "default",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"use_tz": True,
|
||||||
|
"timezone": "UTC",
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[project]
|
||||||
|
name = "project"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"aerich[toml]>=0.9.2",
|
||||||
|
"fastapi[standard]>=0.128.0",
|
||||||
|
"nanoid>=2.0.0",
|
||||||
|
"orjson>=3.11.5",
|
||||||
|
"python-dotenv>=1.2.1",
|
||||||
|
"tortoise-orm[accel,asyncpg]>=0.25.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.aerich]
|
||||||
|
tortoise_orm = "project.lib.settings.DATABASE"
|
||||||
|
location = "project/db/migrations"
|
||||||
|
src_folder = "./."
|
||||||
Reference in New Issue
Block a user