First version

This commit is contained in:
2026-01-28 08:59:33 -03:00
commit 3e73a3494b
24 changed files with 2114 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
from project.api import app as app
+27
View File
@@ -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)
+27
View File
@@ -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,
}
)
+94
View File
@@ -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
View File
+13
View File
@@ -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!")
+5
View File
@@ -0,0 +1,5 @@
from pydantic import BaseModel
class MessageSchema(BaseModel):
message: str
+14
View File
@@ -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()
+29
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
from project.db.models.example import Book as Book
+26
View File
@@ -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,
)
+29
View File
@@ -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="
)
View File
+16
View File
@@ -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)
View File
+25
View File
@@ -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",
}