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
11 changes: 5 additions & 6 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`

Expand Down Expand Up @@ -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
Expand Down
95 changes: 48 additions & 47 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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/**" = [
Expand All @@ -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\..*',
]
4 changes: 2 additions & 2 deletions src/app/application/commands/activate_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'.",
Expand Down
2 changes: 1 addition & 1 deletion src/app/application/commands/change_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down
2 changes: 1 addition & 1 deletion src/app/application/commands/create_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'.",
Expand Down
4 changes: 2 additions & 2 deletions src/app/application/commands/deactivate_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'.",
Expand Down
4 changes: 2 additions & 2 deletions src/app/application/commands/grant_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'.",
Expand Down
4 changes: 2 additions & 2 deletions src/app/application/commands/revoke_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'.",
Expand Down
4 changes: 2 additions & 2 deletions src/app/domain/services/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@ 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)
user.is_active = is_active

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)
Expand Down
2 changes: 1 addition & 1 deletion src/app/infrastructure/auth/handlers/log_in.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion src/app/infrastructure/auth/handlers/sign_up.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
65 changes: 36 additions & 29 deletions src/app/presentation/http/controllers/account/log_in.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading