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