From d7351c296135a9327ce767c2c6a3db6fe806ce2b Mon Sep 17 00:00:00 2001 From: ivan-borovets <130386813+ivan-borovets@users.noreply.github.com> Date: Tue, 5 Aug 2025 21:59:01 +0500 Subject: [PATCH 1/2] Presentation layer improvements * Local error handling * Got rid of global routers * Small tooling improvements --- .github/workflows/ci.yaml | 11 +-- README.md | 6 +- pyproject.toml | 95 ++++++++++--------- src/app/application/commands/activate_user.py | 4 +- .../application/commands/change_password.py | 2 +- src/app/application/commands/create_user.py | 2 +- .../application/commands/deactivate_user.py | 4 +- src/app/application/commands/grant_admin.py | 4 +- src/app/application/commands/revoke_admin.py | 4 +- src/app/domain/services/user.py | 4 +- .../infrastructure/auth/handlers/log_in.py | 2 +- .../infrastructure/auth/handlers/sign_up.py | 2 +- .../http/controllers/account/log_in.py | 65 +++++++------ .../http/controllers/account/log_out.py | 60 +++++++----- .../http/controllers/account/router.py | 35 ++++--- .../http/controllers/account/sign_up.py | 73 ++++++++------ .../http/controllers/api_v1_router.py | 29 +++--- .../http/controllers/general/healthcheck.py | 19 ++-- .../http/controllers/general/router.py | 21 ++-- .../http/controllers/root_router.py | 26 ++--- .../http/controllers/users/activate_user.py | 74 ++++++++------- .../http/controllers/users/change_password.py | 75 ++++++++------- .../http/controllers/users/create_user.py | 82 +++++++++------- .../http/controllers/users/deactivate_user.py | 74 ++++++++------- .../http/controllers/users/grant_admin.py | 74 ++++++++------- .../http/controllers/users/list_users.py | 83 +++++++++------- .../http/controllers/users/revoke_admin.py | 74 ++++++++------- .../http/controllers/users/router.py | 51 +++++----- .../http/{exceptions => errors}/__init__.py | 0 src/app/presentation/http/errors/callbacks.py | 11 +++ .../presentation/http/errors/translators.py | 12 +++ .../presentation/http/exceptions/constants.py | 56 ----------- .../presentation/http/exceptions/handlers.py | 72 -------------- .../presentation/http/exceptions/schemas.py | 12 --- src/app/run.py | 4 +- src/app/setup/app_factory.py | 4 +- uv.lock | 15 +++ 37 files changed, 633 insertions(+), 608 deletions(-) rename src/app/presentation/http/{exceptions => errors}/__init__.py (100%) create mode 100644 src/app/presentation/http/errors/callbacks.py create mode 100644 src/app/presentation/http/errors/translators.py delete mode 100644 src/app/presentation/http/exceptions/constants.py delete mode 100644 src/app/presentation/http/exceptions/handlers.py delete mode 100644 src/app/presentation/http/exceptions/schemas.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9623a28..e0450d6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,20 +5,19 @@ on: [ push, pull_request ] jobs: build-and-test: runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: 3.12.0 + python-version: "3.12" - - name: Install UV + - name: Install uv uses: astral-sh/setup-uv@v6 - with: - version: "0.8.3" - - name: Install project editable with extras + - name: Install dependencies run: uv pip install -e '.[dev,test]' --system - name: Check code diff --git a/README.md b/README.md index 34bad1c..473eba3 100644 --- a/README.md +++ b/README.md @@ -452,7 +452,7 @@ natural. │ └── http/ # http interface │ ├── auth/... # web auth logic │ ├── controllers/... # controllers and routers - │ └── exceptions/... # exception schemas and handlers + │ └── errors/... # error handling helpers │ ├── setup/ │ ├── ioc/... # dependency injection setup @@ -465,8 +465,8 @@ natural. ## Technology Stack - **Python**: `3.12` -- **Core**: `alembic`, `alembic-postgresql-enum`, `bcrypt`, `dishka`, `fastapi`, `orjson`, `psycopg3[binary]`, - `pydantic[email]`, `pyjwt[crypto]`, `rtoml`, `sqlalchemy[mypy]`, `uuid6`, `uvicorn`, `uvloop` +- **Core**: `alembic`, `alembic-postgresql-enum`, `bcrypt`, `dishka`, `fastapi-error-map`, `fastapi`, `orjson`, + `psycopg3[binary]`, `pydantic[email]`, `pyjwt[crypto]`, `rtoml`, `sqlalchemy[mypy]`, `uuid6`, `uvicorn`, `uvloop` - **Development**: `mypy`, `pre-commit`, `ruff`, `slotscheck` - **Testing**: `coverage`, `line-profiler`, `pytest`, `pytest-asyncio` diff --git a/pyproject.toml b/pyproject.toml index e857342..21c51a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [build-system] -requires = ["setuptools == 80.0.0"] -build-backend = "setuptools.build_meta" +requires = ["hatchling>=1.13"] +build-backend = "hatchling.build" -[tool.setuptools] -package-dir = { "" = "src" } +[tool.hatch.build] +sources = ["src"] -[tool.setuptools.packages.find] -where = ["src"] +[tool.hatch.build.targets.wheel] +packages = ["src/app"] [project] name = "fastapi-clean-example" @@ -23,6 +23,7 @@ dependencies = [ "alembic-postgresql-enum==1.8.0", "bcrypt==4.3.0", "dishka==1.6.0", + "fastapi-error-map==0.9.2", "fastapi==0.116.1", "orjson==3.11.0", "psycopg[binary]==3.2.9", @@ -49,6 +50,33 @@ test = [ "pytest-asyncio==1.1.0" ] +[tool.coverage.report] +show_missing = true +skip_empty = true +exclude_also = [ + "if __name__ == .__main__.:", + '@(abc\.)?abstractmethod', + "pass", + '\.\.\.', + "from .*", + "import .*", + 'logging\..*', + 'log\..*', +] + +[tool.coverage.run] +source = ["src", ] +omit = [ + "**/__init__.py", + "**/alembic/**", +] +concurrency = [ + "multiprocessing", + "thread", +] +parallel = true +branch = true + [tool.mypy] files = [ "config", @@ -66,10 +94,19 @@ plugins = [ "sqlalchemy.ext.mypy.plugin", ] +[tool.pytest.ini_options] +testpaths = ["tests", ] +markers = ["slow", ] +addopts = "-m 'not slow'" +asyncio_default_fixture_loop_scope = "function" + [tool.ruff] line-length = 88 preview = true # experimental +[tool.ruff.format] +skip-magic-trailing-comma = false + [tool.ruff.lint] select = [ "A", # flake8-builtins https://docs.astral.sh/ruff/rules/#flake8-builtins-a @@ -120,6 +157,11 @@ ignore = [ "UP015", # redundant-open-modes ] +[tool.ruff.lint.isort] +combine-as-imports = true +force-wrap-aliases = true +split-on-trailing-comma = true + [tool.ruff.lint.per-file-ignores] "src/app/infrastructure/persistence_sqla/alembic/**" = ["ALL", ] "tests/**" = [ @@ -139,49 +181,8 @@ ignore = [ "src/app/presentation/http/exceptions/handlers.py" = ["RUF029", ] # unused-async "scripts/dishka/plot_dependencies_data.py" = ["T201", ] # print -[tool.ruff.format] -skip-magic-trailing-comma = false - -[tool.ruff.lint.isort] -combine-as-imports = true -force-wrap-aliases = true -split-on-trailing-comma = true - [tool.slotscheck] strict-imports = true exclude-modules = ''' ^app\.infrastructure\.persistence_sqla\.alembic ''' - -[tool.pytest.ini_options] -testpaths = ["tests", ] -markers = ["slow", ] -addopts = "-m 'not slow'" -asyncio_default_fixture_loop_scope = "function" - -[tool.coverage.run] -source = ["src", ] -omit = [ - "**/__init__.py", - "**/alembic/**", -] -concurrency = [ - "multiprocessing", - "thread", -] -parallel = true -branch = true - -[tool.coverage.report] -show_missing = true -skip_empty = true -exclude_also = [ - "if __name__ == .__main__.:", - '@(abc\.)?abstractmethod', - "pass", - '\.\.\.', - "from .*", - "import .*", - 'logging\..*', - 'log\..*', -] diff --git a/src/app/application/commands/activate_user.py b/src/app/application/commands/activate_user.py index 3760b23..f5a7bb9 100644 --- a/src/app/application/commands/activate_user.py +++ b/src/app/application/commands/activate_user.py @@ -54,8 +54,8 @@ async def execute(self, request_data: ActivateUserRequest) -> None: :raises DataMapperError: :raises AuthorizationError: :raises DomainFieldError: - :raises UserNotFoundByUsername: - :raises ActivationChangeNotPermitted: + :raises UserNotFoundByUsernameError: + :raises ActivationChangeNotPermittedError: """ log.info( "Activate user: started. Username: '%s'.", diff --git a/src/app/application/commands/change_password.py b/src/app/application/commands/change_password.py index f8895ab..70698fa 100644 --- a/src/app/application/commands/change_password.py +++ b/src/app/application/commands/change_password.py @@ -56,7 +56,7 @@ async def execute(self, request_data: ChangePasswordRequest) -> None: :raises DataMapperError: :raises AuthorizationError: :raises DomainFieldError: - :raises UserNotFoundByUsername: + :raises UserNotFoundByUsernameError: """ log.info("Change password: started.") diff --git a/src/app/application/commands/create_user.py b/src/app/application/commands/create_user.py index 77134c1..8ec685b 100644 --- a/src/app/application/commands/create_user.py +++ b/src/app/application/commands/create_user.py @@ -64,7 +64,7 @@ async def execute(self, request_data: CreateUserRequest) -> CreateUserResponse: :raises AuthorizationError: :raises DomainFieldError: :raises RoleAssignmentNotPermittedError: - :raises UsernameAlreadyExists: + :raises UsernameAlreadyExistsError: """ log.info( "Create user: started. Username: '%s'.", diff --git a/src/app/application/commands/deactivate_user.py b/src/app/application/commands/deactivate_user.py index b89e826..6a08494 100644 --- a/src/app/application/commands/deactivate_user.py +++ b/src/app/application/commands/deactivate_user.py @@ -59,8 +59,8 @@ async def execute(self, request_data: DeactivateUserRequest) -> None: :raises DataMapperError: :raises AuthorizationError: :raises DomainFieldError: - :raises UserNotFoundByUsername: - :raises ActivationChangeNotPermitted: + :raises UserNotFoundByUsernameError: + :raises ActivationChangeNotPermittedError: """ log.info( "Deactivate user: started. Username: '%s'.", diff --git a/src/app/application/commands/grant_admin.py b/src/app/application/commands/grant_admin.py index 5a5cf31..f692496 100644 --- a/src/app/application/commands/grant_admin.py +++ b/src/app/application/commands/grant_admin.py @@ -52,8 +52,8 @@ async def execute(self, request_data: GrantAdminRequest) -> None: :raises DataMapperError: :raises AuthorizationError: :raises DomainFieldError: - :raises UserNotFoundByUsername: - :raises RoleChangeNotPermitted: + :raises UserNotFoundByUsernameError: + :raises RoleChangeNotPermittedError: """ log.info( "Grant admin: started. Username: '%s'.", diff --git a/src/app/application/commands/revoke_admin.py b/src/app/application/commands/revoke_admin.py index 3507b2a..6362ae0 100644 --- a/src/app/application/commands/revoke_admin.py +++ b/src/app/application/commands/revoke_admin.py @@ -50,8 +50,8 @@ async def execute(self, request_data: RevokeAdminRequest) -> None: :raises DataMapperError: :raises AuthorizationError: :raises DomainFieldError: - :raises UserNotFoundByUsername: - :raises RoleChangeNotPermitted: + :raises UserNotFoundByUsernameError: + :raises RoleChangeNotPermittedError: """ log.info( "Revoke admin: started. Username: '%s'.", diff --git a/src/app/domain/services/user.py b/src/app/domain/services/user.py index a8c14c7..68a1a01 100644 --- a/src/app/domain/services/user.py +++ b/src/app/domain/services/user.py @@ -58,7 +58,7 @@ def change_password(self, user: User, raw_password: RawPassword) -> None: def toggle_user_activation(self, user: User, *, is_active: bool) -> None: """ - :raises ActivationChangeNotPermitted: + :raises ActivationChangeNotPermittedError: """ if not user.role.is_changeable: raise ActivationChangeNotPermittedError(user.username, user.role) @@ -66,7 +66,7 @@ def toggle_user_activation(self, user: User, *, is_active: bool) -> None: def toggle_user_admin_role(self, user: User, *, is_admin: bool) -> None: """ - :raises RoleChangeNotPermitted: + :raises RoleChangeNotPermittedError: """ if not user.role.is_changeable: raise RoleChangeNotPermittedError(user.username, user.role) diff --git a/src/app/infrastructure/auth/handlers/log_in.py b/src/app/infrastructure/auth/handlers/log_in.py index 5b13f2a..9eb9332 100644 --- a/src/app/infrastructure/auth/handlers/log_in.py +++ b/src/app/infrastructure/auth/handlers/log_in.py @@ -60,7 +60,7 @@ async def execute(self, request_data: LogInRequest) -> None: :raises AuthorizationError: :raises DataMapperError: :raises DomainFieldError: - :raises UserNotFoundByUsername: + :raises UserNotFoundByUsernameError: """ log.info("Log in: started. Username: '%s'.", request_data.username) diff --git a/src/app/infrastructure/auth/handlers/sign_up.py b/src/app/infrastructure/auth/handlers/sign_up.py index a72c1d0..c42374b 100644 --- a/src/app/infrastructure/auth/handlers/sign_up.py +++ b/src/app/infrastructure/auth/handlers/sign_up.py @@ -61,7 +61,7 @@ async def execute(self, request_data: SignUpRequest) -> SignUpResponse: :raises DataMapperError: :raises DomainFieldError: :raises RoleAssignmentNotPermittedError: - :raises UsernameAlreadyExists: + :raises UsernameAlreadyExistsError: """ log.info("Sign up: started. Username: '%s'.", request_data.username) diff --git a/src/app/presentation/http/controllers/account/log_in.py b/src/app/presentation/http/controllers/account/log_in.py index 2f2c3af..96eae0a 100644 --- a/src/app/presentation/http/controllers/account/log_in.py +++ b/src/app/presentation/http/controllers/account/log_in.py @@ -3,38 +3,45 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, status +from fastapi_error_map import ErrorAwareRouter, rule +from app.application.common.exceptions.authorization import AuthorizationError +from app.domain.exceptions.base import DomainFieldError +from app.domain.exceptions.user import UserNotFoundByUsernameError +from app.infrastructure.auth.exceptions import AlreadyAuthenticatedError from app.infrastructure.auth.handlers.log_in import LogInHandler, LogInRequest -from app.presentation.http.exceptions.schemas import ( - ExceptionSchema, - ExceptionSchemaDetailed, +from app.infrastructure.exceptions.gateway import DataMapperError +from app.presentation.http.errors.callbacks import log_error, log_info +from app.presentation.http.errors.translators import ( + ServiceUnavailableTranslator, ) -log_in_router = APIRouter() +def create_log_in_router() -> APIRouter: + router = ErrorAwareRouter() -@log_in_router.post( - "/login", - description=getdoc(LogInHandler), - responses={ - status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, - status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, - status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, - status.HTTP_404_NOT_FOUND: {"model": ExceptionSchema}, - status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaDetailed}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, - status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ExceptionSchema}, - }, - status_code=status.HTTP_204_NO_CONTENT, -) -@inject -async def login( - request_data: LogInRequest, - handler: FromDishka[LogInHandler], -) -> None: - # :raises AlreadyAuthenticatedError 401: - # :raises AuthorizationError 403: - # :raises DataMapperError 503: - # :raises DomainFieldError 400: - # :raises UserNotFoundByUsername 404: - await handler.execute(request_data) + @router.post( + "/login", + description=getdoc(LogInHandler), + error_map={ + AlreadyAuthenticatedError: status.HTTP_403_FORBIDDEN, + AuthorizationError: status.HTTP_403_FORBIDDEN, + DataMapperError: rule( + status=status.HTTP_503_SERVICE_UNAVAILABLE, + translator=ServiceUnavailableTranslator(), + on_error=log_error, + ), + DomainFieldError: status.HTTP_400_BAD_REQUEST, + UserNotFoundByUsernameError: status.HTTP_404_NOT_FOUND, + }, + default_on_error=log_info, + status_code=status.HTTP_204_NO_CONTENT, + ) + @inject + async def login( + request_data: LogInRequest, + handler: FromDishka[LogInHandler], + ) -> None: + await handler.execute(request_data) + + return router diff --git a/src/app/presentation/http/controllers/account/log_out.py b/src/app/presentation/http/controllers/account/log_out.py index 64844c7..c313ea1 100644 --- a/src/app/presentation/http/controllers/account/log_out.py +++ b/src/app/presentation/http/controllers/account/log_out.py @@ -3,35 +3,45 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Security, status +from fastapi_error_map import ErrorAwareRouter, rule +from app.application.common.exceptions.authorization import AuthorizationError +from app.infrastructure.auth.exceptions import AuthenticationError from app.infrastructure.auth.handlers.log_out import LogOutHandler +from app.infrastructure.exceptions.gateway import DataMapperError from app.presentation.http.auth.fastapi_openapi_markers import cookie_scheme -from app.presentation.http.exceptions.schemas import ( - ExceptionSchema, - ExceptionSchemaDetailed, +from app.presentation.http.errors.callbacks import ( + log_error, + log_info, +) +from app.presentation.http.errors.translators import ( + ServiceUnavailableTranslator, ) -log_out_router = APIRouter() +def create_log_out_router() -> APIRouter: + router = ErrorAwareRouter() -@log_out_router.delete( - "/logout", - description=getdoc(LogOutHandler), - responses={ - status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, - status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, - status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaDetailed}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, - status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ExceptionSchema}, - }, - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Security(cookie_scheme)], -) -@inject -async def logout( - handler: FromDishka[LogOutHandler], -) -> None: - # :raises AuthenticationError 401: - # :raises AuthorizationError 403: - # :raises DataMapperError 503: - await handler.execute() + @router.delete( + "/logout", + description=getdoc(LogOutHandler), + error_map={ + AuthenticationError: status.HTTP_401_UNAUTHORIZED, + AuthorizationError: status.HTTP_403_FORBIDDEN, + DataMapperError: rule( + status=status.HTTP_503_SERVICE_UNAVAILABLE, + translator=ServiceUnavailableTranslator(), + on_error=log_error, + ), + }, + default_on_error=log_info, + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Security(cookie_scheme)], + ) + @inject + async def logout( + handler: FromDishka[LogOutHandler], + ) -> None: + await handler.execute() + + return router diff --git a/src/app/presentation/http/controllers/account/router.py b/src/app/presentation/http/controllers/account/router.py index 9e31818..761836c 100644 --- a/src/app/presentation/http/controllers/account/router.py +++ b/src/app/presentation/http/controllers/account/router.py @@ -1,18 +1,27 @@ from fastapi import APIRouter -from app.presentation.http.controllers.account.log_in import log_in_router -from app.presentation.http.controllers.account.log_out import log_out_router -from app.presentation.http.controllers.account.sign_up import sign_up_router - -account_router = APIRouter( - prefix="/account", - tags=["Account"], +from app.presentation.http.controllers.account.log_in import create_log_in_router +from app.presentation.http.controllers.account.log_out import ( + create_log_out_router, ) -account_sub_routers: tuple[APIRouter, ...] = ( - sign_up_router, - log_in_router, - log_out_router, +from app.presentation.http.controllers.account.sign_up import ( + create_sign_up_router, ) -for router in account_sub_routers: - account_router.include_router(router) + +def create_account_router() -> APIRouter: + router = APIRouter( + prefix="/account", + tags=["Account"], + ) + + sub_routers = ( + create_sign_up_router(), + create_log_in_router(), + create_log_out_router(), + ) + + for sub_router in sub_routers: + router.include_router(sub_router) + + return router diff --git a/src/app/presentation/http/controllers/account/sign_up.py b/src/app/presentation/http/controllers/account/sign_up.py index 2cf9c29..d464bb5 100644 --- a/src/app/presentation/http/controllers/account/sign_up.py +++ b/src/app/presentation/http/controllers/account/sign_up.py @@ -3,43 +3,56 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, status +from fastapi_error_map import ErrorAwareRouter, rule +from app.application.common.exceptions.authorization import AuthorizationError +from app.domain.exceptions.base import DomainFieldError +from app.domain.exceptions.user import ( + RoleAssignmentNotPermittedError, + UsernameAlreadyExistsError, +) +from app.infrastructure.auth.exceptions import AlreadyAuthenticatedError from app.infrastructure.auth.handlers.sign_up import ( SignUpHandler, SignUpRequest, SignUpResponse, ) -from app.presentation.http.exceptions.schemas import ( - ExceptionSchema, - ExceptionSchemaDetailed, +from app.infrastructure.exceptions.gateway import DataMapperError +from app.presentation.http.errors.callbacks import ( + log_error, + log_info, +) +from app.presentation.http.errors.translators import ( + ServiceUnavailableTranslator, ) -sign_up_router = APIRouter() +def create_sign_up_router() -> APIRouter: + router = ErrorAwareRouter() -@sign_up_router.post( - "/signup", - description=getdoc(SignUpHandler), - responses={ - status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, - status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, - status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, - status.HTTP_409_CONFLICT: {"model": ExceptionSchema}, - status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaDetailed}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, - status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ExceptionSchema}, - }, - status_code=status.HTTP_201_CREATED, -) -@inject -async def sign_up( - request_data: SignUpRequest, - handler: FromDishka[SignUpHandler], -) -> SignUpResponse: - # :raises AlreadyAuthenticatedError 401: - # :raises AuthorizationError 403: - # :raises DataMapperError 503: - # :raises DomainFieldError 400: - # :raises RoleAssignmentNotPermittedError 422: - # :raises UsernameAlreadyExists 409: - return await handler.execute(request_data) + @router.post( + "/signup", + description=getdoc(SignUpHandler), + error_map={ + AlreadyAuthenticatedError: status.HTTP_403_FORBIDDEN, + AuthorizationError: status.HTTP_403_FORBIDDEN, + DataMapperError: rule( + status=status.HTTP_503_SERVICE_UNAVAILABLE, + translator=ServiceUnavailableTranslator(), + on_error=log_error, + ), + DomainFieldError: status.HTTP_400_BAD_REQUEST, + RoleAssignmentNotPermittedError: status.HTTP_422_UNPROCESSABLE_ENTITY, + UsernameAlreadyExistsError: status.HTTP_409_CONFLICT, + }, + default_on_error=log_info, + status_code=status.HTTP_201_CREATED, + ) + @inject + async def sign_up( + request_data: SignUpRequest, + handler: FromDishka[SignUpHandler], + ) -> SignUpResponse: + return await handler.execute(request_data) + + return router diff --git a/src/app/presentation/http/controllers/api_v1_router.py b/src/app/presentation/http/controllers/api_v1_router.py index 428a3cd..122aa5d 100644 --- a/src/app/presentation/http/controllers/api_v1_router.py +++ b/src/app/presentation/http/controllers/api_v1_router.py @@ -1,19 +1,22 @@ from fastapi import APIRouter -from app.presentation.http.controllers.account.router import account_router -from app.presentation.http.controllers.general.router import general_router -from app.presentation.http.controllers.users.router import users_router +from app.presentation.http.controllers.account.router import create_account_router +from app.presentation.http.controllers.general.router import create_general_router +from app.presentation.http.controllers.users.router import create_users_router -api_v1_router = APIRouter( - prefix="/api/v1", -) +def create_api_v1_router() -> APIRouter: + router = APIRouter( + prefix="/api/v1", + ) -api_v1_sub_routers: tuple[APIRouter, ...] = ( - account_router, - general_router, - users_router, -) + sub_routers = ( + create_account_router(), + create_general_router(), + create_users_router(), + ) -for router in api_v1_sub_routers: - api_v1_router.include_router(router) + for sub_router in sub_routers: + router.include_router(sub_router) + + return router diff --git a/src/app/presentation/http/controllers/general/healthcheck.py b/src/app/presentation/http/controllers/general/healthcheck.py index 8e3a472..b295af0 100644 --- a/src/app/presentation/http/controllers/general/healthcheck.py +++ b/src/app/presentation/http/controllers/general/healthcheck.py @@ -1,13 +1,16 @@ from fastapi import APIRouter from starlette.requests import Request -healthcheck_router = APIRouter() +def create_healthcheck_router() -> APIRouter: + router = APIRouter() -@healthcheck_router.get("/") -async def healthcheck(_: Request) -> dict[str, str]: - """ - - Open to everyone. - - Returns `200 OK` if the API is alive. - """ - return {"status": "ok"} + @router.get("/") + async def healthcheck(_: Request) -> dict[str, str]: + """ + - Open to everyone. + - Returns `200 OK` if the API is alive. + """ + return {"status": "ok"} + + return router diff --git a/src/app/presentation/http/controllers/general/router.py b/src/app/presentation/http/controllers/general/router.py index 4fd2274..62533b3 100644 --- a/src/app/presentation/http/controllers/general/router.py +++ b/src/app/presentation/http/controllers/general/router.py @@ -1,11 +1,18 @@ from fastapi import APIRouter -from app.presentation.http.controllers.general.healthcheck import healthcheck_router - -general_router = APIRouter( - tags=["General"], +from app.presentation.http.controllers.general.healthcheck import ( + create_healthcheck_router, ) -general_sub_routers: tuple[APIRouter, ...] = (healthcheck_router,) -for router in general_sub_routers: - general_router.include_router(router) + +def create_general_router() -> APIRouter: + router = APIRouter( + tags=["General"], + ) + + sub_routers = (create_healthcheck_router(),) + + for sub_router in sub_routers: + router.include_router(sub_router) + + return router diff --git a/src/app/presentation/http/controllers/root_router.py b/src/app/presentation/http/controllers/root_router.py index d03189d..fd196e9 100644 --- a/src/app/presentation/http/controllers/root_router.py +++ b/src/app/presentation/http/controllers/root_router.py @@ -1,21 +1,23 @@ from fastapi import APIRouter from fastapi.responses import RedirectResponse -from app.presentation.http.controllers.api_v1_router import api_v1_router +from app.presentation.http.controllers.api_v1_router import create_api_v1_router -root_router = APIRouter() +def create_root_router() -> APIRouter: + router = APIRouter() -@root_router.get("/", tags=["General"]) -async def redirect_to_docs() -> RedirectResponse: - """ - - Open to everyone. - - Redirects to Swagger documentation. - """ - return RedirectResponse(url="docs/") + @router.get("/", tags=["General"]) + async def redirect_to_docs() -> RedirectResponse: + """ + - Open to everyone. + - Redirects to Swagger documentation. + """ + return RedirectResponse(url="docs/") + sub_routers = (create_api_v1_router(),) -root_sub_routers: tuple[APIRouter, ...] = (api_v1_router,) + for sub_router in sub_routers: + router.include_router(sub_router) -for router in root_sub_routers: - root_router.include_router(router) + return router diff --git a/src/app/presentation/http/controllers/users/activate_user.py b/src/app/presentation/http/controllers/users/activate_user.py index f68c7ab..c8f3d13 100644 --- a/src/app/presentation/http/controllers/users/activate_user.py +++ b/src/app/presentation/http/controllers/users/activate_user.py @@ -4,45 +4,55 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Path, Security, status +from fastapi_error_map import ErrorAwareRouter, rule from app.application.commands.activate_user import ( ActivateUserInteractor, ActivateUserRequest, ) +from app.application.common.exceptions.authorization import AuthorizationError +from app.domain.exceptions.base import DomainFieldError +from app.domain.exceptions.user import ( + ActivationChangeNotPermittedError, + UserNotFoundByUsernameError, +) +from app.infrastructure.auth.exceptions import AuthenticationError +from app.infrastructure.exceptions.gateway import DataMapperError from app.presentation.http.auth.fastapi_openapi_markers import cookie_scheme -from app.presentation.http.exceptions.schemas import ( - ExceptionSchema, - ExceptionSchemaDetailed, +from app.presentation.http.errors.callbacks import log_error, log_info +from app.presentation.http.errors.translators import ( + ServiceUnavailableTranslator, ) -activate_user_router = APIRouter() +def create_activate_user_router() -> APIRouter: + router = ErrorAwareRouter() -@activate_user_router.patch( - "/{username}/activate", - description=getdoc(ActivateUserInteractor), - responses={ - status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, - status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, - status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, - status.HTTP_404_NOT_FOUND: {"model": ExceptionSchema}, - status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaDetailed}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, - status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ExceptionSchema}, - }, - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Security(cookie_scheme)], -) -@inject -async def activate_user( - username: Annotated[str, Path()], - interactor: FromDishka[ActivateUserInteractor], -) -> None: - # :raises AuthenticationError 401: - # :raises DataMapperError 503: - # :raises AuthorizationError 403: - # :raises DomainFieldError 400: - # :raises UserNotFoundByUsername 404: - # :raises ActivationChangeNotPermitted 403: - request_data = ActivateUserRequest(username) - await interactor.execute(request_data) + @router.patch( + "/{username}/activate", + description=getdoc(ActivateUserInteractor), + error_map={ + AuthenticationError: status.HTTP_401_UNAUTHORIZED, + DataMapperError: rule( + status=status.HTTP_503_SERVICE_UNAVAILABLE, + translator=ServiceUnavailableTranslator(), + on_error=log_error, + ), + AuthorizationError: status.HTTP_403_FORBIDDEN, + DomainFieldError: status.HTTP_400_BAD_REQUEST, + UserNotFoundByUsernameError: status.HTTP_404_NOT_FOUND, + ActivationChangeNotPermittedError: status.HTTP_403_FORBIDDEN, + }, + default_on_error=log_info, + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Security(cookie_scheme)], + ) + @inject + async def activate_user( + username: Annotated[str, Path()], + interactor: FromDishka[ActivateUserInteractor], + ) -> None: + request_data = ActivateUserRequest(username) + await interactor.execute(request_data) + + return router diff --git a/src/app/presentation/http/controllers/users/change_password.py b/src/app/presentation/http/controllers/users/change_password.py index cfb83db..aa616d7 100644 --- a/src/app/presentation/http/controllers/users/change_password.py +++ b/src/app/presentation/http/controllers/users/change_password.py @@ -4,48 +4,55 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Body, Path, Security, status +from fastapi_error_map import ErrorAwareRouter, rule from app.application.commands.change_password import ( ChangePasswordInteractor, ChangePasswordRequest, ) +from app.application.common.exceptions.authorization import AuthorizationError +from app.domain.exceptions.base import DomainFieldError +from app.domain.exceptions.user import UserNotFoundByUsernameError +from app.infrastructure.auth.exceptions import AuthenticationError +from app.infrastructure.exceptions.gateway import DataMapperError from app.presentation.http.auth.fastapi_openapi_markers import cookie_scheme -from app.presentation.http.exceptions.schemas import ( - ExceptionSchema, - ExceptionSchemaDetailed, +from app.presentation.http.errors.callbacks import log_error, log_info +from app.presentation.http.errors.translators import ( + ServiceUnavailableTranslator, ) -change_password_router = APIRouter() +def create_change_password_router() -> APIRouter: + router = ErrorAwareRouter() -@change_password_router.patch( - "/{username}/password", - description=getdoc(ChangePasswordInteractor), - responses={ - status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, - status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, - status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, - status.HTTP_404_NOT_FOUND: {"model": ExceptionSchema}, - status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaDetailed}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, - status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ExceptionSchema}, - }, - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Security(cookie_scheme)], -) -@inject -async def change_password( - username: Annotated[str, Path()], - password: Annotated[str, Body()], - interactor: FromDishka[ChangePasswordInteractor], -) -> None: - # :raises AuthenticationError 401: - # :raises DataMapperError 503: - # :raises AuthorizationError 403: - # :raises DomainFieldError 400: - # :raises UserNotFoundByUsername 404: - request_data = ChangePasswordRequest( - username=username, - password=password, + @router.patch( + "/{username}/password", + description=getdoc(ChangePasswordInteractor), + error_map={ + AuthenticationError: status.HTTP_401_UNAUTHORIZED, + DataMapperError: rule( + status=status.HTTP_503_SERVICE_UNAVAILABLE, + translator=ServiceUnavailableTranslator(), + on_error=log_error, + ), + AuthorizationError: status.HTTP_403_FORBIDDEN, + DomainFieldError: status.HTTP_400_BAD_REQUEST, + UserNotFoundByUsernameError: status.HTTP_404_NOT_FOUND, + }, + default_on_error=log_info, + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Security(cookie_scheme)], ) - await interactor.execute(request_data) + @inject + async def change_password( + username: Annotated[str, Path()], + password: Annotated[str, Body()], + interactor: FromDishka[ChangePasswordInteractor], + ) -> None: + request_data = ChangePasswordRequest( + username=username, + password=password, + ) + await interactor.execute(request_data) + + return router diff --git a/src/app/presentation/http/controllers/users/create_user.py b/src/app/presentation/http/controllers/users/create_user.py index dc98022..5c4cbae 100644 --- a/src/app/presentation/http/controllers/users/create_user.py +++ b/src/app/presentation/http/controllers/users/create_user.py @@ -3,6 +3,7 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Security, status +from fastapi_error_map import ErrorAwareRouter, rule from pydantic import BaseModel, ConfigDict, Field from app.application.commands.create_user import ( @@ -10,15 +11,21 @@ CreateUserRequest, CreateUserResponse, ) +from app.application.common.exceptions.authorization import AuthorizationError from app.domain.enums.user_role import UserRole +from app.domain.exceptions.base import DomainFieldError +from app.domain.exceptions.user import ( + RoleAssignmentNotPermittedError, + UsernameAlreadyExistsError, +) +from app.infrastructure.auth.exceptions import AuthenticationError +from app.infrastructure.exceptions.gateway import DataMapperError from app.presentation.http.auth.fastapi_openapi_markers import cookie_scheme -from app.presentation.http.exceptions.schemas import ( - ExceptionSchema, - ExceptionSchemaDetailed, +from app.presentation.http.errors.callbacks import log_error, log_info +from app.presentation.http.errors.translators import ( + ServiceUnavailableTranslator, ) -create_user_router = APIRouter() - class CreateUserRequestPydantic(BaseModel): """ @@ -33,35 +40,38 @@ class CreateUserRequestPydantic(BaseModel): role: UserRole = Field(default=UserRole.USER) -@create_user_router.post( - "/", - description=getdoc(CreateUserInteractor), - responses={ - status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, - status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, - status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, - status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaDetailed}, - status.HTTP_409_CONFLICT: {"model": ExceptionSchema}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, - status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ExceptionSchema}, - }, - status_code=status.HTTP_201_CREATED, - dependencies=[Security(cookie_scheme)], -) -@inject -async def create_user( - request_data_pydantic: CreateUserRequestPydantic, - interactor: FromDishka[CreateUserInteractor], -) -> CreateUserResponse: - # :raises AuthenticationError 401: - # :raises DataMapperError 503: - # :raises AuthorizationError 403: - # :raises DomainFieldError 400: - # :raises RoleAssignmentNotPermittedError 422: - # :raises UsernameAlreadyExists 409: - request_data = CreateUserRequest( - username=request_data_pydantic.username, - password=request_data_pydantic.password, - role=request_data_pydantic.role, +def create_create_user_router() -> APIRouter: + router = ErrorAwareRouter() + + @router.post( + "/", + description=getdoc(CreateUserInteractor), + error_map={ + AuthenticationError: status.HTTP_401_UNAUTHORIZED, + DataMapperError: rule( + status=status.HTTP_503_SERVICE_UNAVAILABLE, + translator=ServiceUnavailableTranslator(), + on_error=log_error, + ), + AuthorizationError: status.HTTP_403_FORBIDDEN, + DomainFieldError: status.HTTP_400_BAD_REQUEST, + RoleAssignmentNotPermittedError: status.HTTP_422_UNPROCESSABLE_ENTITY, + UsernameAlreadyExistsError: status.HTTP_409_CONFLICT, + }, + default_on_error=log_info, + status_code=status.HTTP_201_CREATED, + dependencies=[Security(cookie_scheme)], ) - return await interactor.execute(request_data) + @inject + async def create_user( + request_data_pydantic: CreateUserRequestPydantic, + interactor: FromDishka[CreateUserInteractor], + ) -> CreateUserResponse: + request_data = CreateUserRequest( + username=request_data_pydantic.username, + password=request_data_pydantic.password, + role=request_data_pydantic.role, + ) + return await interactor.execute(request_data) + + return router diff --git a/src/app/presentation/http/controllers/users/deactivate_user.py b/src/app/presentation/http/controllers/users/deactivate_user.py index b7a4c9f..6fbc54f 100644 --- a/src/app/presentation/http/controllers/users/deactivate_user.py +++ b/src/app/presentation/http/controllers/users/deactivate_user.py @@ -4,45 +4,55 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Path, Security, status +from fastapi_error_map import ErrorAwareRouter, rule from app.application.commands.deactivate_user import ( DeactivateUserInteractor, DeactivateUserRequest, ) +from app.application.common.exceptions.authorization import AuthorizationError +from app.domain.exceptions.base import DomainFieldError +from app.domain.exceptions.user import ( + ActivationChangeNotPermittedError, + UserNotFoundByUsernameError, +) +from app.infrastructure.auth.exceptions import AuthenticationError +from app.infrastructure.exceptions.gateway import DataMapperError from app.presentation.http.auth.fastapi_openapi_markers import cookie_scheme -from app.presentation.http.exceptions.schemas import ( - ExceptionSchema, - ExceptionSchemaDetailed, +from app.presentation.http.errors.callbacks import log_error, log_info +from app.presentation.http.errors.translators import ( + ServiceUnavailableTranslator, ) -deactivate_user_router = APIRouter() +def create_deactivate_user_router() -> APIRouter: + router = ErrorAwareRouter() -@deactivate_user_router.patch( - "/{username}/deactivate", - description=getdoc(DeactivateUserInteractor), - responses={ - status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, - status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, - status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, - status.HTTP_404_NOT_FOUND: {"model": ExceptionSchema}, - status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaDetailed}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, - status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ExceptionSchema}, - }, - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Security(cookie_scheme)], -) -@inject -async def deactivate_user( - username: Annotated[str, Path()], - interactor: FromDishka[DeactivateUserInteractor], -) -> None: - # :raises AuthenticationError 401: - # :raises DataMapperError 503: - # :raises AuthorizationError 403: - # :raises DomainFieldError 400: - # :raises UserNotFoundByUsername 404: - # :raises ActivationChangeNotPermitted 403: - request_data = DeactivateUserRequest(username) - await interactor.execute(request_data) + @router.patch( + "/{username}/deactivate", + description=getdoc(DeactivateUserInteractor), + error_map={ + AuthenticationError: status.HTTP_401_UNAUTHORIZED, + DataMapperError: rule( + status=status.HTTP_503_SERVICE_UNAVAILABLE, + translator=ServiceUnavailableTranslator(), + on_error=log_error, + ), + AuthorizationError: status.HTTP_403_FORBIDDEN, + DomainFieldError: status.HTTP_400_BAD_REQUEST, + UserNotFoundByUsernameError: status.HTTP_404_NOT_FOUND, + ActivationChangeNotPermittedError: status.HTTP_403_FORBIDDEN, + }, + default_on_error=log_info, + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Security(cookie_scheme)], + ) + @inject + async def deactivate_user( + username: Annotated[str, Path()], + interactor: FromDishka[DeactivateUserInteractor], + ) -> None: + request_data = DeactivateUserRequest(username) + await interactor.execute(request_data) + + return router diff --git a/src/app/presentation/http/controllers/users/grant_admin.py b/src/app/presentation/http/controllers/users/grant_admin.py index 2b0543a..5ccbe2f 100644 --- a/src/app/presentation/http/controllers/users/grant_admin.py +++ b/src/app/presentation/http/controllers/users/grant_admin.py @@ -4,45 +4,55 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Path, Security, status +from fastapi_error_map import ErrorAwareRouter, rule from app.application.commands.grant_admin import ( GrantAdminInteractor, GrantAdminRequest, ) +from app.application.common.exceptions.authorization import AuthorizationError +from app.domain.exceptions.base import DomainFieldError +from app.domain.exceptions.user import ( + RoleChangeNotPermittedError, + UserNotFoundByUsernameError, +) +from app.infrastructure.auth.exceptions import AuthenticationError +from app.infrastructure.exceptions.gateway import DataMapperError from app.presentation.http.auth.fastapi_openapi_markers import cookie_scheme -from app.presentation.http.exceptions.schemas import ( - ExceptionSchema, - ExceptionSchemaDetailed, +from app.presentation.http.errors.callbacks import log_error, log_info +from app.presentation.http.errors.translators import ( + ServiceUnavailableTranslator, ) -grant_admin_router = APIRouter() +def create_grant_admin_router() -> APIRouter: + router = ErrorAwareRouter() -@grant_admin_router.patch( - "/{username}/grant-admin", - description=getdoc(GrantAdminInteractor), - responses={ - status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, - status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, - status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, - status.HTTP_404_NOT_FOUND: {"model": ExceptionSchema}, - status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaDetailed}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, - status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ExceptionSchema}, - }, - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Security(cookie_scheme)], -) -@inject -async def grant_admin( - username: Annotated[str, Path()], - interactor: FromDishka[GrantAdminInteractor], -) -> None: - # :raises AuthenticationError 401: - # :raises DataMapperError 503: - # :raises AuthorizationError 403: - # :raises DomainFieldError 400: - # :raises UserNotFoundByUsername 404: - # :raises RoleChangeNotPermitted 403: - request_data = GrantAdminRequest(username) - await interactor.execute(request_data) + @router.patch( + "/{username}/grant-admin", + description=getdoc(GrantAdminInteractor), + error_map={ + AuthenticationError: status.HTTP_401_UNAUTHORIZED, + DataMapperError: rule( + status=status.HTTP_503_SERVICE_UNAVAILABLE, + translator=ServiceUnavailableTranslator(), + on_error=log_error, + ), + AuthorizationError: status.HTTP_403_FORBIDDEN, + DomainFieldError: status.HTTP_400_BAD_REQUEST, + UserNotFoundByUsernameError: status.HTTP_404_NOT_FOUND, + RoleChangeNotPermittedError: status.HTTP_403_FORBIDDEN, + }, + default_on_error=log_info, + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Security(cookie_scheme)], + ) + @inject + async def grant_admin( + username: Annotated[str, Path()], + interactor: FromDishka[GrantAdminInteractor], + ) -> None: + request_data = GrantAdminRequest(username) + await interactor.execute(request_data) + + return router diff --git a/src/app/presentation/http/controllers/users/list_users.py b/src/app/presentation/http/controllers/users/list_users.py index ab10050..2141920 100644 --- a/src/app/presentation/http/controllers/users/list_users.py +++ b/src/app/presentation/http/controllers/users/list_users.py @@ -4,22 +4,25 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Depends, Security, status +from fastapi_error_map import ErrorAwareRouter, rule from pydantic import BaseModel, ConfigDict, Field +from app.application.common.exceptions.authorization import AuthorizationError +from app.application.common.exceptions.query import PaginationError, SortingError from app.application.common.query_params.sorting import SortingOrder from app.application.queries.list_users import ( ListUsersQueryService, ListUsersRequest, ListUsersResponse, ) +from app.infrastructure.auth.exceptions import AuthenticationError +from app.infrastructure.exceptions.gateway import DataMapperError, ReaderError from app.presentation.http.auth.fastapi_openapi_markers import cookie_scheme -from app.presentation.http.exceptions.schemas import ( - ExceptionSchema, - ExceptionSchemaDetailed, +from app.presentation.http.errors.callbacks import log_error, log_info +from app.presentation.http.errors.translators import ( + ServiceUnavailableTranslator, ) -list_users_router = APIRouter() - class ListUsersRequestPydantic(BaseModel): """ @@ -35,35 +38,43 @@ class ListUsersRequestPydantic(BaseModel): sorting_order: Annotated[SortingOrder, Field()] = SortingOrder.ASC -@list_users_router.get( - "/", - description=getdoc(ListUsersQueryService), - responses={ - status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, - status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, - status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, - status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaDetailed}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, - status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ExceptionSchema}, - }, - status_code=status.HTTP_200_OK, - dependencies=[Security(cookie_scheme)], -) -@inject -async def list_users( - request_data_pydantic: Annotated[ListUsersRequestPydantic, Depends()], - interactor: FromDishka[ListUsersQueryService], -) -> ListUsersResponse: - # :raises AuthenticationError 401: - # :raises DataMapperError 503: - # :raises AuthorizationError 403: - # :raises ReaderError 503: - # :raises PaginationError 500: - # :raises SortingError 400: - request_data = ListUsersRequest( - limit=request_data_pydantic.limit, - offset=request_data_pydantic.offset, - sorting_field=request_data_pydantic.sorting_field, - sorting_order=request_data_pydantic.sorting_order, +def create_list_users_router() -> APIRouter: + router = ErrorAwareRouter() + + @router.get( + "/", + description=getdoc(ListUsersQueryService), + error_map={ + AuthenticationError: status.HTTP_401_UNAUTHORIZED, + DataMapperError: rule( + status=status.HTTP_503_SERVICE_UNAVAILABLE, + translator=ServiceUnavailableTranslator(), + on_error=log_error, + ), + AuthorizationError: status.HTTP_403_FORBIDDEN, + ReaderError: rule( + status=status.HTTP_503_SERVICE_UNAVAILABLE, + translator=ServiceUnavailableTranslator(), + on_error=log_error, + ), + PaginationError: status.HTTP_400_BAD_REQUEST, + SortingError: status.HTTP_400_BAD_REQUEST, + }, + default_on_error=log_info, + status_code=status.HTTP_200_OK, + dependencies=[Security(cookie_scheme)], ) - return await interactor.execute(request_data) + @inject + async def list_users( + request_data_pydantic: Annotated[ListUsersRequestPydantic, Depends()], + interactor: FromDishka[ListUsersQueryService], + ) -> ListUsersResponse: + request_data = ListUsersRequest( + limit=request_data_pydantic.limit, + offset=request_data_pydantic.offset, + sorting_field=request_data_pydantic.sorting_field, + sorting_order=request_data_pydantic.sorting_order, + ) + return await interactor.execute(request_data) + + return router diff --git a/src/app/presentation/http/controllers/users/revoke_admin.py b/src/app/presentation/http/controllers/users/revoke_admin.py index b31e04d..53e4905 100644 --- a/src/app/presentation/http/controllers/users/revoke_admin.py +++ b/src/app/presentation/http/controllers/users/revoke_admin.py @@ -4,45 +4,55 @@ from dishka import FromDishka from dishka.integrations.fastapi import inject from fastapi import APIRouter, Path, Security, status +from fastapi_error_map import ErrorAwareRouter, rule from app.application.commands.revoke_admin import ( RevokeAdminInteractor, RevokeAdminRequest, ) +from app.application.common.exceptions.authorization import AuthorizationError +from app.domain.exceptions.base import DomainFieldError +from app.domain.exceptions.user import ( + RoleChangeNotPermittedError, + UserNotFoundByUsernameError, +) +from app.infrastructure.auth.exceptions import AuthenticationError +from app.infrastructure.exceptions.gateway import DataMapperError from app.presentation.http.auth.fastapi_openapi_markers import cookie_scheme -from app.presentation.http.exceptions.schemas import ( - ExceptionSchema, - ExceptionSchemaDetailed, +from app.presentation.http.errors.callbacks import log_error, log_info +from app.presentation.http.errors.translators import ( + ServiceUnavailableTranslator, ) -revoke_admin_router = APIRouter() +def create_revoke_admin_router() -> APIRouter: + router = ErrorAwareRouter() -@revoke_admin_router.patch( - "/{username}/revoke-admin", - description=getdoc(RevokeAdminInteractor), - responses={ - status.HTTP_400_BAD_REQUEST: {"model": ExceptionSchema}, - status.HTTP_401_UNAUTHORIZED: {"model": ExceptionSchema}, - status.HTTP_403_FORBIDDEN: {"model": ExceptionSchema}, - status.HTTP_404_NOT_FOUND: {"model": ExceptionSchema}, - status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ExceptionSchemaDetailed}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ExceptionSchema}, - status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ExceptionSchema}, - }, - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Security(cookie_scheme)], -) -@inject -async def revoke_admin( - username: Annotated[str, Path()], - interactor: FromDishka[RevokeAdminInteractor], -) -> None: - # :raises AuthenticationError 401: - # :raises DataMapperError 503: - # :raises AuthorizationError 403: - # :raises DomainFieldError 400: - # :raises UserNotFoundByUsername 404: - # :raises RoleChangeNotPermitted 403: - request_data = RevokeAdminRequest(username) - await interactor.execute(request_data) + @router.patch( + "/{username}/revoke-admin", + description=getdoc(RevokeAdminInteractor), + error_map={ + AuthenticationError: status.HTTP_401_UNAUTHORIZED, + DataMapperError: rule( + status=status.HTTP_503_SERVICE_UNAVAILABLE, + translator=ServiceUnavailableTranslator(), + on_error=log_error, + ), + AuthorizationError: status.HTTP_403_FORBIDDEN, + DomainFieldError: status.HTTP_400_BAD_REQUEST, + UserNotFoundByUsernameError: status.HTTP_404_NOT_FOUND, + RoleChangeNotPermittedError: status.HTTP_403_FORBIDDEN, + }, + default_on_error=log_info, + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Security(cookie_scheme)], + ) + @inject + async def revoke_admin( + username: Annotated[str, Path()], + interactor: FromDishka[RevokeAdminInteractor], + ) -> None: + request_data = RevokeAdminRequest(username) + await interactor.execute(request_data) + + return router diff --git a/src/app/presentation/http/controllers/users/router.py b/src/app/presentation/http/controllers/users/router.py index ece706a..0799569 100644 --- a/src/app/presentation/http/controllers/users/router.py +++ b/src/app/presentation/http/controllers/users/router.py @@ -1,38 +1,43 @@ from fastapi import APIRouter from app.presentation.http.controllers.users.activate_user import ( - activate_user_router, + create_activate_user_router, ) from app.presentation.http.controllers.users.change_password import ( - change_password_router, + create_change_password_router, ) from app.presentation.http.controllers.users.create_user import ( - create_user_router, + create_create_user_router, ) from app.presentation.http.controllers.users.deactivate_user import ( - deactivate_user_router, + create_deactivate_user_router, ) -from app.presentation.http.controllers.users.grant_admin import grant_admin_router -from app.presentation.http.controllers.users.list_users import ( - list_users_router, +from app.presentation.http.controllers.users.grant_admin import ( + create_grant_admin_router, ) +from app.presentation.http.controllers.users.list_users import create_list_users_router from app.presentation.http.controllers.users.revoke_admin import ( - revoke_admin_router, + create_revoke_admin_router, ) -users_router = APIRouter( - prefix="/users", - tags=["Users"], -) -users_sub_routers: tuple[APIRouter, ...] = ( - create_user_router, - list_users_router, - change_password_router, - grant_admin_router, - revoke_admin_router, - activate_user_router, - deactivate_user_router, -) -for router in users_sub_routers: - users_router.include_router(router) +def create_users_router() -> APIRouter: + router = APIRouter( + prefix="/users", + tags=["Users"], + ) + + sub_routers = ( + create_create_user_router(), + create_list_users_router(), + create_change_password_router(), + create_grant_admin_router(), + create_revoke_admin_router(), + create_activate_user_router(), + create_deactivate_user_router(), + ) + + for sub_router in sub_routers: + router.include_router(sub_router) + + return router diff --git a/src/app/presentation/http/exceptions/__init__.py b/src/app/presentation/http/errors/__init__.py similarity index 100% rename from src/app/presentation/http/exceptions/__init__.py rename to src/app/presentation/http/errors/__init__.py diff --git a/src/app/presentation/http/errors/callbacks.py b/src/app/presentation/http/errors/callbacks.py new file mode 100644 index 0000000..7e3e40f --- /dev/null +++ b/src/app/presentation/http/errors/callbacks.py @@ -0,0 +1,11 @@ +import logging + +log = logging.getLogger(__name__) + + +def log_info(err: Exception) -> None: + log.info(f"Handled exception: {type(err).__name__} — {err}") + + +def log_error(err: Exception) -> None: + log.error(f"Handled exception: {type(err).__name__} — {err}") diff --git a/src/app/presentation/http/errors/translators.py b/src/app/presentation/http/errors/translators.py new file mode 100644 index 0000000..be64197 --- /dev/null +++ b/src/app/presentation/http/errors/translators.py @@ -0,0 +1,12 @@ +from fastapi_error_map import ErrorTranslator, SimpleErrorResponseModel + + +class ServiceUnavailableTranslator(ErrorTranslator[SimpleErrorResponseModel]): + @property + def error_response_model_cls(self) -> type[SimpleErrorResponseModel]: + return SimpleErrorResponseModel + + def from_error(self, _err: Exception) -> SimpleErrorResponseModel: + return SimpleErrorResponseModel( + error="Service temporarily unavailable. Please try again later." + ) diff --git a/src/app/presentation/http/exceptions/constants.py b/src/app/presentation/http/exceptions/constants.py deleted file mode 100644 index 4ebfbe5..0000000 --- a/src/app/presentation/http/exceptions/constants.py +++ /dev/null @@ -1,56 +0,0 @@ -from collections.abc import Mapping -from types import MappingProxyType -from typing import Final - -import pydantic -from starlette import status - -from app.application.common.exceptions.authorization import AuthorizationError -from app.application.common.exceptions.base import ApplicationError -from app.application.common.exceptions.query import SortingError -from app.domain.exceptions.base import DomainError, DomainFieldError -from app.domain.exceptions.user import ( - ActivationChangeNotPermittedError, - RoleAssignmentNotPermittedError, - RoleChangeNotPermittedError, - UsernameAlreadyExistsError, - UserNotFoundByUsernameError, -) -from app.infrastructure.auth.exceptions import ( - AlreadyAuthenticatedError, - AuthenticationError, -) -from app.infrastructure.exceptions.base import InfrastructureError -from app.infrastructure.exceptions.gateway import DataMapperError, ReaderError - -MSG_INTERNAL_SERVER_ERROR: Final[str] = "Internal server error." -MSG_SERVICE_UNAVAILABLE: Final[str] = ( - "Service temporarily unavailable. Please try again later." -) - -ERROR_STATUS_MAPPING: Final[Mapping[type[Exception], int]] = MappingProxyType({ - # 400 - DomainFieldError: status.HTTP_400_BAD_REQUEST, - SortingError: status.HTTP_400_BAD_REQUEST, - # 401 - AlreadyAuthenticatedError: status.HTTP_401_UNAUTHORIZED, - AuthenticationError: status.HTTP_401_UNAUTHORIZED, - # 403 - ActivationChangeNotPermittedError: status.HTTP_403_FORBIDDEN, - AuthorizationError: status.HTTP_403_FORBIDDEN, - RoleChangeNotPermittedError: status.HTTP_403_FORBIDDEN, - # 404 - UserNotFoundByUsernameError: status.HTTP_404_NOT_FOUND, - # 409 - UsernameAlreadyExistsError: status.HTTP_409_CONFLICT, - # 422 - pydantic.ValidationError: status.HTTP_422_UNPROCESSABLE_ENTITY, - RoleAssignmentNotPermittedError: status.HTTP_422_UNPROCESSABLE_ENTITY, - # 500 - ApplicationError: status.HTTP_500_INTERNAL_SERVER_ERROR, - DomainError: status.HTTP_500_INTERNAL_SERVER_ERROR, - InfrastructureError: status.HTTP_500_INTERNAL_SERVER_ERROR, - # 503 - DataMapperError: status.HTTP_503_SERVICE_UNAVAILABLE, - ReaderError: status.HTTP_503_SERVICE_UNAVAILABLE, -}) diff --git a/src/app/presentation/http/exceptions/handlers.py b/src/app/presentation/http/exceptions/handlers.py deleted file mode 100644 index 0da9e7d..0000000 --- a/src/app/presentation/http/exceptions/handlers.py +++ /dev/null @@ -1,72 +0,0 @@ -import logging -from typing import Any, cast - -import pydantic -from fastapi import FastAPI -from fastapi.encoders import jsonable_encoder -from fastapi.responses import ORJSONResponse -from starlette import status -from starlette.requests import Request - -from app.presentation.http.exceptions.constants import ( - ERROR_STATUS_MAPPING, - MSG_INTERNAL_SERVER_ERROR, - MSG_SERVICE_UNAVAILABLE, -) -from app.presentation.http.exceptions.schemas import ( - ExceptionSchema, - ExceptionSchemaDetailed, -) - -log = logging.getLogger(__name__) - - -def setup_handlers(app: FastAPI) -> None: - for exc_class in ERROR_STATUS_MAPPING: - app.add_exception_handler(exc_class, handle_exception) - app.add_exception_handler(Exception, handle_exception) - - -async def handle_exception(_: Request, exc: Exception) -> ORJSONResponse: - """ - Async as recommended by FastAPI for exception handlers. - https://fastapi.tiangolo.com/tutorial/handling-errors/ - """ - status_code = resolve_status_code(exc) - response = build_exception_response(exc, status_code) - log_exception(exc, status_code) - return ORJSONResponse(status_code=status_code, content=jsonable_encoder(response)) - - -def resolve_status_code(exc: Exception) -> int: - return ERROR_STATUS_MAPPING.get(type(exc), status.HTTP_500_INTERNAL_SERVER_ERROR) - - -def build_exception_response(exc: Exception, status_code: int) -> ExceptionSchema: - if isinstance(exc, pydantic.ValidationError): - return ExceptionSchemaDetailed( - description=str(exc), - details=cast(list[dict[str, Any]], exc.errors()), - ) - - if status_code == status.HTTP_503_SERVICE_UNAVAILABLE: - return ExceptionSchema(MSG_SERVICE_UNAVAILABLE) - - description = ( - str(exc) - if status_code < status.HTTP_500_INTERNAL_SERVER_ERROR - else MSG_INTERNAL_SERVER_ERROR - ) - return ExceptionSchema(description) - - -def log_exception(exc: Exception, status_code: int) -> None: - is_server_error = status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR - log_func = log.error if is_server_error else log.warning - - log_func( - "Exception '%s' occurred: '%s'.", - type(exc).__name__, - exc, - exc_info=exc if is_server_error else None, - ) diff --git a/src/app/presentation/http/exceptions/schemas.py b/src/app/presentation/http/exceptions/schemas.py deleted file mode 100644 index 8000977..0000000 --- a/src/app/presentation/http/exceptions/schemas.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass -from typing import Any - - -@dataclass(frozen=True, slots=True) -class ExceptionSchema: - description: str - - -@dataclass(frozen=True, slots=True) -class ExceptionSchemaDetailed(ExceptionSchema): - details: list[dict[str, Any]] | None = None diff --git a/src/app/run.py b/src/app/run.py index 99b5586..ac27798 100644 --- a/src/app/run.py +++ b/src/app/run.py @@ -2,7 +2,7 @@ from dishka.integrations.fastapi import setup_dishka from fastapi import FastAPI -from app.presentation.http.controllers.root_router import root_router +from app.presentation.http.controllers.root_router import create_root_router from app.setup.app_factory import configure_app, create_app, create_async_ioc_container from app.setup.config.logs import configure_logging from app.setup.config.settings import AppSettings, load_settings @@ -20,7 +20,7 @@ def make_app( configure_logging(level=settings.logs.level) app: FastAPI = create_app() - configure_app(app=app, root_router=root_router) + configure_app(app=app, root_router=create_root_router()) async_ioc_container = create_async_ioc_container( providers=(*get_providers(), *di_providers), diff --git a/src/app/setup/app_factory.py b/src/app/setup/app_factory.py index 91eab51..63ea38c 100644 --- a/src/app/setup/app_factory.py +++ b/src/app/setup/app_factory.py @@ -9,7 +9,6 @@ from app.presentation.http.auth.asgi_middleware import ( ASGIAuthMiddleware, ) -from app.presentation.http.exceptions.handlers import setup_handlers from app.setup.config.settings import AppSettings @@ -35,7 +34,8 @@ def configure_app( app.include_router(root_router) app.add_middleware(ASGIAuthMiddleware) # https://github.com/encode/starlette/discussions/2451 - setup_handlers(app) + + # Good place to register global exception handlers def create_async_ioc_container( diff --git a/uv.lock b/uv.lock index ae1fe42..6b69b90 100644 --- a/uv.lock +++ b/uv.lock @@ -258,6 +258,7 @@ dependencies = [ { name = "bcrypt" }, { name = "dishka" }, { name = "fastapi" }, + { name = "fastapi-error-map" }, { name = "orjson" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic", extra = ["email"] }, @@ -291,6 +292,7 @@ requires-dist = [ { name = "coverage", marker = "extra == 'test'", specifier = "==7.10.0" }, { name = "dishka", specifier = "==1.6.0" }, { name = "fastapi", specifier = "==0.116.1" }, + { name = "fastapi-error-map", specifier = "==0.9.2" }, { name = "line-profiler", marker = "extra == 'test'", specifier = "==5.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.17.0" }, { name = "orjson", specifier = "==3.11.0" }, @@ -309,6 +311,19 @@ requires-dist = [ { name = "uvloop", specifier = "==0.21.0" }, ] +[[package]] +name = "fastapi-error-map" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "orjson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/73/0d56f328395e6dc0fe7ce2375f65e9f3560ce19bf33a62936b3675bd04af/fastapi_error_map-0.9.2.tar.gz", hash = "sha256:4b105cd5fafb6099c24440f65c70f5529953a639c0ad7b605e32334205f71def", size = 376268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/05/3d52dc1153659f5eee026bc3b793dae61cfecbcf8a9433cf0d37ca9bf3b4/fastapi_error_map-0.9.2-py3-none-any.whl", hash = "sha256:226ea0688c582c26efb8f5f857c1a4cd99f35f5a8eaa0f414c91a71491b81d15", size = 19217 }, +] + [[package]] name = "filelock" version = "3.18.0" From 5a058fc0f568904f45ecd32d89780aebe0968a07 Mon Sep 17 00:00:00 2001 From: ivan-borovets <130386813+ivan-borovets@users.noreply.github.com> Date: Wed, 6 Aug 2025 00:33:51 +0500 Subject: [PATCH 2/2] Mention good library in docs --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 473eba3..832f621 100644 --- a/README.md +++ b/README.md @@ -695,6 +695,9 @@ Makefile commands. If you find this project useful, please give it a star or share it! Your support means a lot. +👉 Check out the amazing [fastapi-error-map](https://github.com/ivan-borovets/fastapi-error-map), used here to enable +contextual, per-route error handling with automatic OpenAPI schema generation. + 💬 Feel free to open issues, ask questions, or submit pull requests. # Acknowledgements