Skip to content

Commit 589a73b

Browse files
committed
Minor fixes
1 parent 7c7e294 commit 589a73b

23 files changed

+177
-42
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ This is for educational purposes only and the code is not production-ready.
1515

1616
## Stack
1717

18-
- Python 3.9
18+
- Python 3.10
1919
- FastAPI
2020
- MongoDB
2121

example.env

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
ENVIRONMENT=dev
12
MONGO_CONNECTION_URL=mongodb://localhost:27017/
23
MONGO_DATABASE_NAME=hexagonal_architecture_python
34
S3_CLIENTS_BUCKET=client-reports
45
DROPBOX_ACCESS_TOKEN=xxxx
56
BUCKET_REGION=eu-west-1
67
AWS_DEFAULT_REGION=eu-west-1
78
AWS_ACCESS_KEY_ID=test
8-
AWS_SECRET_ACCESS_KEY=test
9+
AWS_SECRET_ACCESS_KEY=test

pyproject.toml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ requires = ["poetry-core"]
66

77
[tool.black]
88
line-length = 120
9-
target-version = ["py39"]
9+
target-version = ["py310"]
1010

1111
[tool.coverage.report]
1212
exclude_lines = [
@@ -49,7 +49,7 @@ warn_unused_configs = true
4949
authors = ["Szymon Miks <miks.szymon@gmail.com>"]
5050
description = ""
5151
name = "hexagonal-architecture-python"
52-
packages = [{include = "hexagonal_architecture_python"}]
52+
packages = [{include = "src"}]
5353
readme = "README.md"
5454
version = "0.1.0"
5555

@@ -65,8 +65,8 @@ mypy-boto3-sns = "^1.24.68"
6565
pymongo = "^4.2.0"
6666
python = "^3.10"
6767
python-dateutil = "^2.8.2"
68-
requests = "^2.28.1"
6968
python-dotenv = "^0.21.0"
69+
requests = "^2.28.1"
7070

7171
[tool.poetry.group.dev.dependencies]
7272
black = "^22.10.0"
@@ -85,15 +85,19 @@ toml-sort = "^0.20.1"
8585
good-names = "id,i,j,k"
8686

8787
[tool.pylint.DESIGN]
88-
max-args = 5
88+
max-args = 7
8989
max-attributes = 8
9090
min-public-methods = 1
9191

9292
[tool.pylint.FORMAT]
9393
max-line-length = 120
9494

95+
[tool.pylint.MASTER]
96+
extension-pkg-whitelist = "pydantic"
97+
9598
[tool.pylint."MESSAGES CONTROL"]
9699
disable = "missing-docstring, line-too-long, logging-fstring-interpolation, duplicate-code"
100+
extension-pkg-whitelist = "pydantic"
97101

98102
[tool.pylint.MISCELLANEOUS]
99103
notes = "XXX"

src/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
from dotenv import load_dotenv
22

3-
load_dotenv()
3+
load_dotenv()

src/app.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,64 @@
1+
from typing import Any
12

23
import uvicorn
34
from fastapi import FastAPI
5+
from fastapi.openapi.utils import get_openapi
6+
from fastapi.requests import Request
7+
from fastapi.responses import JSONResponse
48

9+
from src.building_blocks.errors import APIErrorMessage, DomainError, RepositoryError, ResourceNotFound
510
from src.clients.controllers import router as clients_router
611
from src.gym_classes.controllers import router as gym_classes_router
712
from src.gym_passes.controllers import router as gym_passes_router
813

9-
1014
app = FastAPI()
1115
app.include_router(gym_classes_router)
1216
app.include_router(clients_router)
1317
app.include_router(gym_passes_router)
1418

1519

20+
@app.exception_handler(DomainError)
21+
async def domain_error_handler(request: Request, exc: DomainError) -> JSONResponse:
22+
error_msg = APIErrorMessage(type=exc.__class__.__name__, message=f"Oops! {exc}")
23+
return JSONResponse(
24+
status_code=400,
25+
content=error_msg.dict(),
26+
)
27+
28+
29+
@app.exception_handler(ResourceNotFound)
30+
async def resource_not_found_handler(request: Request, exc: ResourceNotFound) -> JSONResponse:
31+
error_msg = APIErrorMessage(type=exc.__class__.__name__, message=str(exc))
32+
return JSONResponse(status_code=404, content=error_msg.dict())
33+
34+
35+
@app.exception_handler(RepositoryError)
36+
async def repository_error_handler(request: Request, exc: RepositoryError) -> JSONResponse:
37+
error_msg = APIErrorMessage(
38+
type=exc.__class__.__name__, message="Oops! Something went wrong, please try again later..."
39+
)
40+
return JSONResponse(
41+
status_code=500,
42+
content=error_msg.dict(),
43+
)
44+
45+
46+
def custom_openapi() -> dict[str, Any]:
47+
if app.openapi_schema:
48+
return app.openapi_schema # type: ignore
49+
50+
openapi_schema = get_openapi(
51+
title="hexagonal-architecture-python",
52+
version="1.0.0",
53+
description="Hexagonal architecture in Python build on top of FastAPI",
54+
routes=app.routes,
55+
)
56+
app.openapi_schema = openapi_schema
57+
58+
return app.openapi_schema # type: ignore
59+
60+
61+
app.openapi = custom_openapi # type: ignore
62+
1663
if __name__ == "__main__":
1764
uvicorn.run(app, host="localhost", port=8000)

src/building_blocks/errors.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
from pydantic import BaseModel
2+
3+
14
class DomainError(Exception):
25
pass
36

47

8+
class ResourceNotFound(DomainError):
9+
pass
10+
11+
512
class RepositoryError(DomainError):
613
@classmethod
714
def save_operation_failed(cls) -> "RepositoryError":
@@ -10,3 +17,8 @@ def save_operation_failed(cls) -> "RepositoryError":
1017
@classmethod
1118
def get_operation_failed(cls) -> "RepositoryError":
1219
return cls("An error occurred while retrieving the data from the database!")
20+
21+
22+
class APIErrorMessage(BaseModel):
23+
type: str
24+
message: str

src/clients/application/client_service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def __init__(
3434

3535
def create(self, input_dto: CreateClientDTO) -> ClientDTO:
3636
client = Client.create(input_dto.first_name, input_dto.last_name, input_dto.email)
37+
3738
self._client_repo.save(client)
3839

3940
snapshot = client.to_snapshot()
@@ -54,7 +55,9 @@ def change_personal_data(self, input_dto: ChangeClientPersonalDataDTO) -> Client
5455
def archive(self, input_dto: ArchiveClientDTO) -> None:
5556
client = self._client_repo.get(ClientId.of(input_dto.client_id))
5657
client.archive()
58+
5759
self._client_repo.save(client)
60+
5861
self._gym_pass_facade.disable_for(str(client.id.value))
5962

6063
def export(self, input_dto: ExportClientsDTO) -> None:

src/clients/application/exporter_factory.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
from enum import Enum, unique
23

34
import boto3
45
from dropbox import Dropbox
@@ -8,15 +9,22 @@
89
from src.clients.infrastructure.s3_clients_exporter import S3ClientsExporter
910

1011

12+
@unique
13+
class Destination(str, Enum):
14+
S3 = "s3"
15+
DROPBOX = "dropbox"
16+
17+
1118
class ExporterFactory:
1219
@staticmethod
13-
def build(destination: str) -> IClientsExporter:
14-
if destination == "s3":
15-
s3_sdk = boto3.client("s3", endpoint_url="http://localhost:4566")
20+
def build(destination: Destination) -> IClientsExporter:
21+
if destination == Destination.S3:
22+
endpoint_url = "http://localhost:4566" if os.getenv("ENVIRONMENT") == "dev" else None
23+
s3_sdk = boto3.client("s3", endpoint_url=endpoint_url)
1624
bucket_name = os.environ["S3_CLIENTS_BUCKET"]
1725
return S3ClientsExporter(s3_sdk, bucket_name)
1826

19-
if destination == "dropbox":
27+
if destination == Destination.DROPBOX:
2028
dropbox_access_token = os.environ["DROPBOX_ACCESS_TOKEN"]
2129
dropbox_client = Dropbox(dropbox_access_token)
2230
return DropboxClientsExporter(dropbox_client)

src/clients/bootstrap.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from src.building_blocks.db import get_mongo_database
44
from src.clients.application.client_service import ClientService
5-
from src.clients.application.exporter_factory import ExporterFactory
5+
from src.clients.application.exporter_factory import Destination, ExporterFactory
66
from src.clients.domain.client_repository import IClientRepository
77
from src.clients.domain.clients_exporter import IClientsExporter
88
from src.clients.infrastructure.mongo_client_repository import MongoDBClientRepository
@@ -11,7 +11,7 @@
1111

1212
def bootstrap_di() -> None:
1313
repository = MongoDBClientRepository(get_mongo_database())
14-
clients_exporter = ExporterFactory.build("s3")
14+
clients_exporter = ExporterFactory.build(Destination.S3)
1515

1616
di[IClientRepository] = repository
1717
di[IClientsExporter] = clients_exporter

src/clients/controllers.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from fastapi.responses import JSONResponse, Response
33
from kink import di
44

5+
from src.building_blocks.errors import APIErrorMessage
56
from src.clients.application.client_service import ClientService
67
from src.clients.application.dto import (
78
ArchiveClientDTO,
@@ -14,15 +15,25 @@
1415
router = APIRouter()
1516

1617

17-
@router.post("/clients", response_model=ClientDTO, tags=["clients"])
18+
@router.post(
19+
"/clients",
20+
response_model=ClientDTO,
21+
responses={400: {"model": APIErrorMessage}, 500: {"model": APIErrorMessage}},
22+
tags=["clients"],
23+
)
1824
async def create_client(
1925
request: CreateClientDTO, service: ClientService = Depends(lambda: di[ClientService])
2026
) -> JSONResponse:
2127
result = service.create(request)
2228
return JSONResponse(content=result.dict(), status_code=status.HTTP_201_CREATED)
2329

2430

25-
@router.put("/clients/{client_id}", response_model=ClientDTO, tags=["clients"])
31+
@router.put(
32+
"/clients/{client_id}",
33+
response_model=ClientDTO,
34+
responses={400: {"model": APIErrorMessage}, 404: {"model": APIErrorMessage}, 500: {"model": APIErrorMessage}},
35+
tags=["clients"],
36+
)
2637
async def change_personal_data(
2738
client_id: str, request: ChangeClientPersonalDataDTO, service: ClientService = Depends(lambda: di[ClientService])
2839
) -> JSONResponse:
@@ -31,15 +42,23 @@ async def change_personal_data(
3142
return JSONResponse(content=result.dict(), status_code=status.HTTP_200_OK)
3243

3344

34-
@router.delete("/clients/{client_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["clients"])
45+
@router.delete(
46+
"/clients/{client_id}",
47+
status_code=status.HTTP_204_NO_CONTENT,
48+
responses={400: {"model": APIErrorMessage}, 404: {"model": APIErrorMessage}, 500: {"model": APIErrorMessage}},
49+
tags=["clients"],
50+
)
3551
async def archive(client_id: str, service: ClientService = Depends(lambda: di[ClientService])) -> Response:
3652
service.archive(ArchiveClientDTO(client_id=client_id))
3753
return Response(status_code=status.HTTP_204_NO_CONTENT)
3854

3955

40-
@router.post("/clients/exports", status_code=status.HTTP_204_NO_CONTENT, tags=["clients"])
41-
async def export(
42-
request: ExportClientsDTO, service: ClientService = Depends(lambda: di[ClientService])
43-
) -> Response:
56+
@router.post(
57+
"/clients/exports",
58+
status_code=status.HTTP_204_NO_CONTENT,
59+
responses={400: {"model": APIErrorMessage}, 500: {"model": APIErrorMessage}},
60+
tags=["clients"],
61+
)
62+
async def export(request: ExportClientsDTO, service: ClientService = Depends(lambda: di[ClientService])) -> Response:
4463
service.export(request)
4564
return Response(status_code=status.HTTP_204_NO_CONTENT)

0 commit comments

Comments
 (0)