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:
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user