Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/api/fastapi/app.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from fastapi import FastAPI

from app.api.fastapi.handlers import add_exception_handlers
from app.api.fastapi.exceptions import add_exception_handlers
from app.api.fastapi.items.router import router as items_router
from app.api.fastapi.utils import lifespan_factory
from app.api.fastapi.lifespan import lifespan_factory
from app.core.settings import Settings


Expand Down
2 changes: 1 addition & 1 deletion app/api/fastapi/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from sqlalchemy.orm import Session
from starlette.requests import Request

from app.core.fastapi.context import Context
from app.core.settings import Settings
from app.core.sqlalchemy.context import Context
from app.infrastructure.sqlalchemy.utils import managed_session


Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion app/api/fastapi/items/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from fastapi import APIRouter, Depends, status

from app.api.fastapi.dependencies import get_context
from app.core.fastapi.context import Context
from app.core.sqlalchemy.context import Context
from app.domain.entities import EntityId
from app.domain.items.commands import (
create_item_command,
Expand Down
14 changes: 5 additions & 9 deletions app/api/fastapi/utils.py → app/api/fastapi/lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
from contextlib import AbstractAsyncContextManager, asynccontextmanager

from fastapi import FastAPI
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.api.logger import logger
from app.core.settings import Settings
from app.infrastructure.sqlalchemy.utils import create_sql_resource


def lifespan_factory(
Expand All @@ -15,17 +14,14 @@ def lifespan_factory(

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
engine = create_engine(
url=str(settings.postgres_dsn),
**settings.postgres_params,
)
app.state.sql_engine = engine
app.state.sql_session_factory = sessionmaker(bind=engine)
sql_resource = create_sql_resource(settings=settings)
app.state.sql_engine = sql_resource.engine
app.state.sql_session_factory = sql_resource.session_factory
logger.info("Application startup complete")

yield

engine.dispose()
sql_resource.release()
logger.info("Application shutdown complete")

return lifespan
File renamed without changes.
16 changes: 16 additions & 0 deletions app/api/flask/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from flask import Flask

from app.api.flask.exceptions import add_exception_handlers
from app.api.flask.items.router import router as items_router
from app.api.flask.utils import init_app
from app.core.settings import Settings


def create_flask_app(settings: Settings) -> Flask:
app = Flask(__name__)

add_exception_handlers(app=app)
app.register_blueprint(items_router)
init_app(settings=settings, app=app)

return app
21 changes: 21 additions & 0 deletions app/api/flask/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from flask import current_app, g

from app.core.sqlalchemy.context import Context


def get_context() -> Context:
if "sql_session" not in g:
sql_session_factory = current_app.config["SQL_SESSION_FACTORY"]
g.sql_session = sql_session_factory()

return Context(sql_session=g.sql_session)


def sql_session_teardown(error: BaseException | None) -> None:
sql_session = g.pop("sql_session", None)
if sql_session is not None:
if error is not None:
sql_session.rollback()
else:
sql_session.commit()
sql_session.close()
30 changes: 30 additions & 0 deletions app/api/flask/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from flask import Flask, Response
from pydantic import ValidationError

from app.api.utils import ERROR_MAPPING
from app.domain.exceptions import DomainError


def add_exception_handlers(app: Flask) -> None:
@app.errorhandler(DomainError)
def domain_exception_handler(exc: DomainError) -> Response:
for error_cls in type(exc).mro():
if issubclass(error_cls, DomainError) and error_cls in ERROR_MAPPING:
return Response(
response="{'detail': str(exc)}",
status=ERROR_MAPPING[error_cls],
)

return Response(
response="Internal Server Error",
status=500,
content_type="text/plain",
)

@app.errorhandler(ValidationError)
def pydantic_validation_exception_handler(exc: ValidationError) -> Response:
return Response(
response=exc.json(),
status=422,
content_type="application/json",
)
Empty file added app/api/flask/items/__init__.py
Empty file.
68 changes: 68 additions & 0 deletions app/api/flask/items/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from typing import Any

from flask import Blueprint, Response, request

from app.api.flask.dependencies import get_context
from app.domain.entities import EntityId
from app.domain.items.commands import (
create_item_command,
delete_item_command,
get_item_command,
get_items_command,
update_item_command,
)
from app.domain.items.entities import ItemCreate, ItemUpdate

router = Blueprint("items", __name__, url_prefix="/items")


@router.get("")
def get_items() -> Any:
context = get_context()
items = get_items_command(context)
return [item.model_dump() for item in items]


@router.get("/<item_id>")
def get_item(item_id: EntityId) -> Response:
context = get_context()
item = get_item_command(context, item_id=item_id)
return Response(
response=item.model_dump_json(),
status=200,
content_type="application/json",
)


@router.post("")
def create_item() -> Response:
context = get_context()
item_create = ItemCreate.model_validate(request.get_json())
item = create_item_command(context, item_create=item_create)
return Response(
response=item.model_dump_json(),
status=201,
content_type="application/json",
)


@router.patch("/<item_id>")
def update_item(item_id: EntityId) -> Response:
context = get_context()
item_update = ItemUpdate.model_validate(request.get_json())
item = update_item_command(context, item_id=item_id, item_update=item_update)
return Response(
response=item.model_dump_json(),
status=200,
content_type="application/json",
)


@router.delete("/<item_id>")
def delete_item(item_id: EntityId) -> Response:
context = get_context()
delete_item_command(context, item_id=item_id)
return Response(
status=204,
content_type="application/json",
)
13 changes: 13 additions & 0 deletions app/api/flask/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from flask import Flask

from app.api.flask.dependencies import sql_session_teardown
from app.core.settings import Settings
from app.infrastructure.sqlalchemy.utils import create_sql_resource


def init_app(settings: Settings, app: Flask) -> None:
sql_resource = create_sql_resource(settings=settings)
app.config["SQL_ENGINE"] = sql_resource.engine
app.config["SQL_SESSION_FACTORY"] = sql_resource.session_factory

app.teardown_appcontext(sql_session_teardown)
Empty file added app/core/sqlalchemy/__init__.py
Empty file.
File renamed without changes.
35 changes: 35 additions & 0 deletions app/infrastructure/sqlalchemy/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,44 @@
from collections.abc import Iterator
from contextlib import contextmanager

from pydantic import BaseModel, ConfigDict
from sqlalchemy import Engine, create_engine, text
from sqlalchemy.orm import Session, sessionmaker

from app.core.settings import Settings
from app.infrastructure.sqlalchemy.logger import logger
from app.infrastructure.sqlalchemy.models.base import OrmEntity


class SQLResource(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)

engine: Engine
session_factory: sessionmaker[Session]

def release(self) -> None:
logger.info("SQL engine released")
self.engine.dispose()

def reset(self) -> None:
with self.session_factory() as session:
for table in reversed(OrmEntity.metadata.sorted_tables):
session.execute(table.delete())
session.commit()


def create_sql_resource(settings: Settings) -> SQLResource:
engine = create_engine(
url=str(settings.postgres_dsn),
**settings.postgres_params,
)
with engine.connect() as connection:
connection.execute(text("SELECT 1"))
logger.info("SQL engine up")
return SQLResource(
engine=engine,
session_factory=sessionmaker(bind=engine),
)


@contextmanager
Expand Down
3 changes: 1 addition & 2 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
services:
postgres:
container_name: postgres
image: postgres
image: postgres:18.4
ports:
- "5432:5432"
environment:
Expand Down
11 changes: 6 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@ version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"django==6.0.5",
"fastapi[standard]==0.136.1",
"fastapi[standard]==0.136.3",
"flask==3.1.3",
"gunicorn==26.0.0",
"psycopg[binary]==3.3.4",
"pydantic-settings==2.14.1",
"sqlalchemy>=2.0.49",
"sqlalchemy==2.0.50",
]

[dependency-groups]
dev = [
"django-stubs==6.0.4",
"django-stubs==6.0.5",
"mypy==2.1.0",
"pytest>=9.0.3",
"ruff==0.15.13",
"ty==0.0.37",
"ruff==0.15.14",
"ty==0.0.40",
]

[tool.ruff]
Expand Down
Empty file added tests/api/clients/__init__.py
Empty file.
8 changes: 8 additions & 0 deletions tests/api/clients/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from typing import Any, Protocol


class HTTPClient(Protocol):
def get(self, *args: Any, **kwargs: Any) -> Any: ...
def post(self, *args: Any, **kwargs: Any) -> Any: ...
def patch(self, *args: Any, **kwargs: Any) -> Any: ...
def delete(self, *args: Any, **kwargs: Any) -> Any: ...
28 changes: 28 additions & 0 deletions tests/api/clients/django.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from typing import Any

from django.test import Client

from tests.api.clients.base import HTTPClient


class WrappedDjangoClient(HTTPClient):
def __init__(self, client: Client) -> None:
self._client = client

def get(self, *args: Any, **kwargs: Any) -> Any:
return self._client.get(*args, **kwargs)

def post(self, *args: Any, **kwargs: Any) -> Any:
if "json" in kwargs:
kwargs["data"] = kwargs.pop("json")
kwargs["content_type"] = "application/json"
return self._client.post(*args, **kwargs)

def patch(self, *args: Any, **kwargs: Any) -> Any:
if "json" in kwargs:
kwargs["data"] = kwargs.pop("json")
kwargs["content_type"] = "application/json"
return self._client.patch(*args, **kwargs)

def delete(self, *args: Any, **kwargs: Any) -> Any:
return self._client.delete(*args, **kwargs)
34 changes: 34 additions & 0 deletions tests/api/clients/flask.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from typing import Any

from flask.testing import FlaskClient

from tests.api.clients.base import HTTPClient


class WrappedFlaskResponse:
def __init__(self, response: Any) -> None:
self._response = response

@property
def status_code(self) -> int:
return self._response.status_code

def json(self) -> Any:
return self._response.json


class WrappedFlaskClient(HTTPClient):
def __init__(self, client: FlaskClient) -> None:
self._client = client

def get(self, *args: Any, **kwargs: Any) -> Any:
return WrappedFlaskResponse(self._client.get(*args, **kwargs))

def post(self, *args: Any, **kwargs: Any) -> Any:
return WrappedFlaskResponse(self._client.post(*args, **kwargs))

def patch(self, *args: Any, **kwargs: Any) -> Any:
return WrappedFlaskResponse(self._client.patch(*args, **kwargs))

def delete(self, *args: Any, **kwargs: Any) -> Any:
return WrappedFlaskResponse(self._client.delete(*args, **kwargs))
Loading