Update README
This commit is contained in:
@@ -1,451 +1,54 @@
|
|||||||
# Uv + FastAPI + Tortoise | Template
|
# Nyeki's API
|
||||||
|
|
||||||
A template FastAPI project with Tortoise ORM integration.
|
This is a simple API I built for a few utilities I've found myself recoding over and over or using
|
||||||
|
across projects. The documentation can be found at https://api.nyeki.dev/ and at the root endpoint
|
||||||
|
if you're self-hosting.
|
||||||
|
|
||||||
## Project Structure
|
This API provides no stability guarantees (unless you're self-hosting it and modify it). It's not
|
||||||
|
made for mass usage and with the small set of features it has, if you need something
|
||||||
|
production-ready you'll be better off writing something yourself.
|
||||||
|
|
||||||
This project is divided into 3 sub-modules, `project.api`, `project.db`, and `project.lib`.
|
## Self-hosting
|
||||||
|
|
||||||
- `project.api`: All the API-related code and the public-facing part of your code.
|
This API is quite simple to self-host.
|
||||||
- `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
|
Env variables:
|
||||||
|
|
||||||
The `project.api` module contains all the `FastAPI` code of your project. Routers and
|
- `NYEKI_DEBUG`: Whether to start in debug mode.
|
||||||
schemas all go in here. The module is divided into `v*` sub-modules to version the API
|
- `NYEKI_GITHUB_TOKEN`: A PAT token. You can get one
|
||||||
by default.
|
[here](https://github.com/settings/tokens).
|
||||||
|
- `NYEKI_DISCORD_TOKEN`: A bot token. You can get one
|
||||||
|
[here](https://discord.com/developers/applications/).
|
||||||
|
- `NYEKI_BIND_HOST`: Docker-only. The IP to bind the API to. Defaults to `0.0.0.0`.
|
||||||
|
- `NYEKI_BIND_PORT`: Docker-only. The port to bind the API to. Defaults to `8000`.
|
||||||
|
- `NYEKI_WORKERS`: Docker-only. The amount of async web workers to spawn. Defaults to
|
||||||
|
`$(nproc)` (the amount of CPU cores).
|
||||||
|
- `NYEKI_REDIS_URL`: The Redis URL to use for caching.
|
||||||
|
|
||||||
#### Handler Groups
|
You can run it using uv by doing:
|
||||||
|
|
||||||
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
|
```sh
|
||||||
$ uv run uvicorn project:app --reload --reload-include "docs/**/*.md" --reload-include .env
|
$ uv run uvicorn nyekiapi:app
|
||||||
```
|
```
|
||||||
|
|
||||||
`project` is your import name.
|
To run deploy the API with Docker, just run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ docker run git.moan.dev/nyeki/nyeki-api:v2.0.0 -p 8000:80 \
|
||||||
|
$ -e NYEKI_DISCORD_TOKEN="..." \
|
||||||
|
$ -e NYEKI_GITHUB_TOKEN="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also clone this repository and run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ docker build -t nyeki-api .
|
||||||
|
$ docker run nyeki-api -p 8000:80 \
|
||||||
|
$ -e NYEKI_DISCORD_TOKEN="..." \
|
||||||
|
$ -e NYEKI_GITHUB_TOKEN="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Enjoyed using this? Consider supporting this and other projects like this by
|
||||||
|
[making a donation here](https://ko-fi.com/nyeki). It really helps me keep this up and running!
|
||||||
|
|||||||
Reference in New Issue
Block a user