Add versioning support by default, replace _Project.md with _Summary.md and _Description.md, upgrade to the latest Tortoise ORM version

This commit is contained in:
2026-07-15 23:43:31 -03:00
parent f8f6dad237
commit 6d1f73772e
22 changed files with 242 additions and 388 deletions
+6 -11
View File
@@ -2,26 +2,21 @@ 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
from project.api.v1 import app as app_v1
from project.api.v1.errors import NotFoundError
app = FastAPI(
debug=settings.DEBUG,
title="Project API",
description=docs.OPENAPI_DESCRIPTION,
version="1.0.0",
docs_url=None,
redoc_url="/docs",
redoc_url=None,
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)
app.mount("/v1", app_v1, name="v1")
+127 -17
View File
@@ -1,27 +1,137 @@
import os
from enum import Enum
OPENAPI_TAGS = []
from dataclasses import dataclass
OPENAPI_DESCRIPTION = ""
from fastapi import FastAPI
from fastapi.routing import APIRoute
for doc_file in os.listdir("./docs"):
if not doc_file.endswith(".md"):
continue
@dataclass
class OpenAPI:
summary: str | None
description: str | None
tags: list["OpenAPITag"]
with open(f"./docs/{doc_file}", "r", encoding="utf-8") as f:
content = f.read()
@classmethod
def load(cls, base_path: str) -> "OpenAPI":
"""Loads the OpenAPI documentation strings from a path.
name = doc_file.split(".")[0]
Inside that path:
_Summary.md -> The OpenAPI summary.
_Description.md -> The OpenAPI description.
*.md -> Tags, the file name without the prefix is the tag name and the file
contents is the description.
if name == "_Project":
OPENAPI_DESCRIPTION = content
Args:
base_path (str): The path to the directory where the files are.
else:
OPENAPI_TAGS.append(
{
"name": name,
"description": content,
}
)
Returns:
OpenAPI: The initialized OpenAPI object.
"""
summary = None
description = None
tags = []
dirs = os.listdir(base_path)
dirs.sort()
for doc_file in dirs:
if not doc_file.endswith(".md"):
continue
with open(f"{base_path}/{doc_file}", "r", encoding="utf-8") as f:
content = f.read()
name = doc_file.split(".")[0]
if name == "_Summary":
summary = content
elif name == "_Description":
description = content
else:
tags.append(OpenAPITag(name=name, description=content))
return cls(summary=summary, description=description, tags=tags)
@dataclass
class OpenAPITag:
name: str
description: str
def load(base_path: str) -> OpenAPI:
"""Loads the OpenAPI documentation strings from a path.
Inside that path:
_Summary.md -> The OpenAPI summary.
_Description.md -> The OpenAPI description.
*.md -> Tags, the file name without the prefix is the tag name and the file
contents is the description.
Args:
base_path (str): The path to the directory where the files are.
Returns:
OpenAPI: The initialized OpenAPI object.
"""
return OpenAPI.load(base_path)
def load_onto_fastapi(
base_path: str,
app: FastAPI,
*,
only_tags: list[str | Enum] = None,
only_used_tags: bool = True,
) -> None:
"""Loads the OpenAPI summaries and descriptions and loads them onto a `FastAPI`
application.
Read `load()`'s documentation for information about structure.
Args:
base_path (str): The path to the directory where the files are.
app (FastAPI): The app to load the docs onto.
only_tags (list[str | Enum], optional): A list of tags to load. If not
provided, all tags will be loaded. Defaults to None.
only_used_tags (bool, optional): If True, only tags that are used in the
app's routes will be loaded. Defaults to True.
"""
openapi = OpenAPI.load(base_path)
if only_tags is not None and only_used_tags:
raise ValueError("Cannot use both `only_tags` and `only_used_tags`.")
if only_used_tags:
used_tags = set()
for route in app.routes:
if isinstance(route, APIRoute):
for tag in route.tags:
used_tags.add(tag)
openapi.tags = [tag for tag in openapi.tags if tag.name in used_tags]
elif only_tags is not None:
only_tags = [tag.value if isinstance(tag, Enum) else tag for tag in only_tags]
openapi.tags = [tag for tag in openapi.tags if tag.name in only_tags]
app.summary = openapi.summary
app.description = openapi.description
app.openapi_tags = []
for tag in openapi.tags:
app.openapi_tags.append(
{
"name": tag.name,
"description": tag.description,
}
)
+3 -3
View File
@@ -2,13 +2,13 @@ from fastapi import FastAPI
from contextlib import asynccontextmanager
from project.db.lifespan import on_startup, on_shutdown
from project.db import lifespan as db
@asynccontextmanager
async def lifespan(app: FastAPI):
await on_startup()
await db.on_startup()
yield
await on_shutdown()
await db.on_shutdown()
+26
View File
@@ -0,0 +1,26 @@
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from project.lib import settings
from project.api import docs
from project.api.v1.errors import BaseError, NotFoundError
from project.api.v1.example.router import router as example_router
app = FastAPI(
debug=settings.DEBUG,
title="Project API",
version="1.0.0",
docs_url=None,
redoc_url="/docs",
default_response_class=ORJSONResponse,
)
app.include_router(example_router)
app.add_exception_handler(BaseError, BaseError.handler)
app.add_exception_handler(404, NotFoundError.handler)
docs.load_onto_fastapi("docs/v1", app)
@@ -1,6 +1,6 @@
from fastapi.responses import ORJSONResponse
from project.api.schemas import ErrorSchema
from project.api.v1.schemas import ErrorSchema
class BaseError(Exception):
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from project.api.example.schemas import MessageSchema
from project.api.v1.example.schemas import MessageSchema
router = APIRouter(tags=["Example"])
@@ -1,7 +1,11 @@
from typing import Annotated
from fastapi import Query
from pydantic import BaseModel, Field
from tortoise.queryset import QuerySet
NanoID = Annotated[
str, Field(min_length=21, max_length=21, pattern="^[A-Za-z0-9_-]{21}$")
+5 -17
View File
@@ -1,8 +1,5 @@
from pathlib import Path
from aerich import Command
from tortoise import Tortoise, connections
from tortoise import Tortoise
from tortoise.migrations import api as migrations
from project.lib import settings
@@ -10,20 +7,11 @@ 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.DATABASE)
await migrations.migrate(config=settings.DATABASE)
await Tortoise.init(config=settings.DATABASE, _enable_global_fallback=True)
async def on_shutdown():
"""Closes the database connections on FastAPI shutdown."""
await connections.close_all()
await Tortoise.close_connections()
View File
@@ -1,44 +0,0 @@
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="
)
+1 -1
View File
@@ -15,9 +15,9 @@ DATABASE = {
"models": {
"models": [
"project.db.models.example",
"aerich.models", # Keep this one for migrations.
],
"default_connection": "default",
"migrations": "project.db.migrations",
}
},
"use_tz": True,