├── .docker └── localstack │ └── init.sh ├── .gitignore ├── .pre-commit-config.yaml ├── Makefile ├── README.md ├── docker-compose.yml ├── docs └── project-strucutre-all.png ├── example.env ├── http_requests ├── clients.http ├── gym-classes.http ├── gym-passes.http └── http-client.env.json ├── poetry.lock ├── pyproject.toml ├── setup.cfg ├── src ├── __init__.py ├── app.py ├── building_blocks │ ├── __init__.py │ ├── clock.py │ ├── custom_types.py │ ├── db.py │ └── errors.py ├── clients │ ├── __init__.py │ ├── application │ │ ├── __init__.py │ │ ├── client_service.py │ │ ├── clients_parser.py │ │ ├── dto.py │ │ └── exporter_factory.py │ ├── bootstrap.py │ ├── controllers.py │ ├── domain │ │ ├── __init__.py │ │ ├── client.py │ │ ├── client_id.py │ │ ├── client_repository.py │ │ ├── clients_exporter.py │ │ ├── email_address.py │ │ ├── errors.py │ │ ├── report.py │ │ └── status.py │ └── infrastructure │ │ ├── __init__.py │ │ ├── dropbox_clients_exporter.py │ │ ├── in_memory_client_repository.py │ │ ├── mongo_client_repository.py │ │ └── s3_clients_exporter.py ├── gym_classes │ ├── __init__.py │ ├── config.py │ ├── controllers.py │ ├── errors.py │ ├── models.py │ └── service.py └── gym_passes │ ├── __init__.py │ ├── application │ ├── __init__.py │ ├── dto.py │ └── gym_pass_service.py │ ├── bootstrap.py │ ├── controllers.py │ ├── domain │ ├── __init__.py │ ├── date_range.py │ ├── errors.py │ ├── gym_pass.py │ ├── gym_pass_id.py │ ├── gym_pass_repository.py │ ├── pause.py │ └── status.py │ ├── facade.py │ └── infrastructure │ ├── __init__.py │ ├── in_memory_gym_pass_repository.py │ └── mongo_gym_pass_repository.py └── tests ├── __init__.py ├── conftest.py ├── integration ├── __init__.py ├── clients │ ├── __init__.py │ ├── conftest.py │ ├── test_controllers.py │ ├── test_dropbox_clients_exporter.py │ ├── test_mongo_client_repository.py │ ├── test_s3_clients_exporter.py │ └── test_service.py ├── gym_classes │ ├── __init__.py │ ├── conftest.py │ ├── test_controllers.py │ └── test_service.py └── gym_passes │ ├── __init__.py │ ├── conftest.py │ ├── test_gym_pass_service.py │ └── test_mongo_gym_pass_repository.py └── unit ├── __init__.py ├── clients ├── __init__.py ├── test_client.py ├── test_client_id.py └── test_email_address.py └── gym_passes ├── __init__.py ├── test_date_range.py ├── test_gym_pass.py ├── test_gym_pass_id.py └── test_pause.py /.docker/localstack/init.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "localstack setup!" 4 | 5 | echo "Creating S3 bucket reports" 6 | awslocal s3 mb s3://client-reports 7 | 8 | echo "setup finished!" 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | /.idea 131 | .env 132 | /data 133 | 134 | .DS_Store 135 | 136 | http-client.private.env.json -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: check-merge-conflict 7 | - id: check-yaml 8 | args: [--unsafe] 9 | - id: check-json 10 | - id: detect-private-key 11 | - id: end-of-file-fixer 12 | 13 | - repo: https://github.com/timothycrosley/isort 14 | rev: 5.10.1 15 | hooks: 16 | - id: isort 17 | 18 | - repo: https://github.com/psf/black 19 | rev: 22.8.0 20 | hooks: 21 | - id: black 22 | 23 | - repo: https://gitlab.com/pycqa/flake8 24 | rev: 3.9.2 25 | hooks: 26 | - id: flake8 27 | 28 | - repo: https://github.com/pre-commit/mirrors-mypy 29 | rev: v0.971 30 | hooks: 31 | - id: mypy 32 | args: [ --warn-unused-configs, --ignore-missing-imports, --disallow-untyped-defs, --follow-imports=silent, --install-types, --non-interactive ] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := all 2 | 3 | toml_sort: 4 | toml-sort pyproject.toml --all --in-place 5 | 6 | isort: 7 | poetry run isort . 8 | 9 | black: 10 | poetry run black . 11 | 12 | flake8: 13 | poetry run flake8 . 14 | 15 | pylint: 16 | poetry run pylint src 17 | 18 | mypy: 19 | poetry run mypy --install-types --non-interactive . 20 | 21 | test: 22 | poetry run pytest 23 | 24 | lint: toml_sort isort black flake8 mypy 25 | 26 | tests: test 27 | 28 | all: lint tests -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hexagonal-architecture-python 2 | 3 | [![forthebadge made-with-python](http://ForTheBadge.com/images/badges/made-with-python.svg)](https://www.python.org/) 4 | 5 | This project was created as an example for my blog post https://blog.szymonmiks.pl/p/hexagonal-architecture-in-python/ 6 | 7 | I encourage you to read the article first before checking the code. 8 | 9 | This is for educational purposes only and the code is not production-ready. 10 | 11 | ## Intro 12 | 13 | I created a project that uses FastAPI and follows the hexagonal architecture rules. 14 | 15 | This project is a simplified gym management software. We have such functionalities like: 16 | - Gym Clients 17 | - create a gym client 18 | - change client's personal data 19 | - archive a client 20 | - export all clients as a CSV or JSON file to S3 or Dropbox storage 21 | - Gym Passes 22 | - create a gym pass 23 | - pause a gym pass 24 | - renew a gym pass 25 | - check if gym pass is active 26 | - Gym Classes (CRUD) 27 | - create a gym class 28 | - delete a gym class 29 | - modify a gym class 30 | - list all gym classes sorted by time and day of the week 31 | 32 | ## Project structure 33 | 34 | ![project-structure](docs/project-strucutre-all.png) 35 | 36 | ## Stack 37 | 38 | - Python 3.10 39 | - FastAPI 40 | - MongoDB 41 | 42 | ## Prerequisites 43 | 44 | Make sure you have installed all the following prerequisites on your development machine: 45 | 46 | - [Python 3.9](https://www.python.org/downloads/) 47 | - [Poetry](https://python-poetry.org/) 48 | - [GIT](https://git-scm.com/downloads) 49 | - [Make](http://gnuwin32.sourceforge.net/packages/make.htm) 50 | - [Docker version >= 20.10.7](https://www.docker.com/get-started) 51 | - [docker-compose version >= 1.29.2](https://docs.docker.com/compose/install/) 52 | 53 | ## Setup 54 | 55 | 1. Install dependencies: 56 | 57 | ```bash 58 | $ poetry install 59 | ``` 60 | 61 | 2. Setup pre-commit hooks before committing: 62 | 63 | ```bash 64 | $ poetry run pre-commit install 65 | ``` 66 | 67 | ## Running app locally 68 | 69 | 1. In the main project's directory create a new `.env` file and copy all variables from `example.env` there. 70 | 2. Run `docker-compose up -d` 71 | 3. Go to `src/app.py` and run the app 72 | 73 | ## Tests 74 | 75 | 76 | ```bash 77 | $ poetry run pytest 78 | ``` 79 | 80 | or 81 | 82 | ```bash 83 | $ make tests 84 | ``` 85 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | mongodb: 5 | image: mongo:latest 6 | volumes: 7 | - ./data/mongo:/data/db 8 | ports: 9 | - "27017-27019:27017-27019" 10 | 11 | awslocalstack: 12 | image: localstack/localstack 13 | ports: 14 | - "4566:4566" 15 | - "4571:4571" 16 | - "8080:8080" 17 | environment: 18 | - SERVICES=s3 19 | - PORT_WEB_UI=8080 20 | - START_WEB=1 21 | - DEBUG=1 22 | - DEFAULT_REGION=eu-west-1 23 | - DOCKER_HOST=unix:///var/run/docker.sock 24 | volumes: 25 | - "/var/run/docker.sock:/var/run/docker.sock" 26 | - "./.docker/localstack/:/docker-entrypoint-initaws.d/" -------------------------------------------------------------------------------- /docs/project-strucutre-all.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/docs/project-strucutre-all.png -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | ENVIRONMENT=dev 2 | MONGO_CONNECTION_URL=mongodb://localhost:27017/ 3 | MONGO_DATABASE_NAME=hexagonal_architecture_python 4 | S3_CLIENTS_BUCKET=client-reports 5 | DROPBOX_ACCESS_TOKEN=xxxx 6 | BUCKET_REGION=eu-west-1 7 | AWS_DEFAULT_REGION=eu-west-1 8 | AWS_ACCESS_KEY_ID=test 9 | AWS_SECRET_ACCESS_KEY=test 10 | -------------------------------------------------------------------------------- /http_requests/clients.http: -------------------------------------------------------------------------------- 1 | ### Create a client 2 | 3 | POST {{ host }}/clients 4 | Content-Type: application/json 5 | Accept: application/json 6 | 7 | { 8 | "first_name": "John", 9 | "last_name": "Done", 10 | "email": "test@test.com" 11 | } 12 | 13 | ### Change client's data 14 | 15 | PUT {{ host }}/clients/636a7606449a56551fffc596 16 | Content-Type: application/json 17 | Accept: application/json 18 | 19 | { 20 | "first_name": "John", 21 | "last_name": "Done", 22 | "email": "test33@test.com" 23 | } 24 | 25 | ### Archive client 26 | 27 | DELETE {{ host }}/clients/636a7606449a56551fffc596 28 | Content-Type: application/json 29 | Accept: application/json 30 | 31 | {} 32 | 33 | ### Refresh auth token 34 | 35 | POST {{ host }}/clients/exports 36 | Content-Type: application/json 37 | Accept: application/json 38 | 39 | { 40 | "format": "CSV" 41 | } -------------------------------------------------------------------------------- /http_requests/gym-classes.http: -------------------------------------------------------------------------------- 1 | POST {{host}}/classes 2 | Content-Type: application/json 3 | Accept: application/json 4 | 5 | { 6 | "name": "Pilates", 7 | "day": "Monday", 8 | "time": "10:30-11:30", 9 | "coach": "Simon W.", 10 | "description": "Try pilates in our gym" 11 | } 12 | ### 13 | 14 | GET {{host}}/classes 15 | Content-Type: application/json 16 | Accept: application/json 17 | 18 | ### 19 | 20 | GET {{host}}/classes/636a69f6a119088676764d91 21 | Content-Type: application/json 22 | Accept: application/json 23 | 24 | 25 | ### 26 | PUT {{host}}/classes/636a69f6a119088676764d91 27 | Content-Type: application/json 28 | Accept: application/json 29 | 30 | { 31 | "name": "Morning start-up", 32 | "day": "Monday", 33 | "time": "9:00-10:00", 34 | "coach": "Szymon M.", 35 | "description": "" 36 | } 37 | 38 | ### 39 | DELETE {{host}}/classes/636a69f6a119088676764d91 40 | Content-Type: application/json 41 | Accept: application/json -------------------------------------------------------------------------------- /http_requests/gym-passes.http: -------------------------------------------------------------------------------- 1 | POST {{host}}/gym-passes 2 | Content-Type: application/json 3 | Accept: application/json 4 | 5 | { 6 | "owner": "12345678", 7 | "validity": "ONE_MONTH" 8 | } 9 | 10 | ### 11 | POST {{host}}/gym-passes/636b5b3ff573dfa78f0d5a5b/pauses 12 | Content-Type: application/json 13 | Accept: application/json 14 | 15 | { 16 | "days": 7 17 | } 18 | ### 19 | 20 | PUT {{host}}/gym-passes/636b5b3ff573dfa78f0d5a5b/renewal 21 | Content-Type: application/json 22 | Accept: application/json 23 | 24 | 25 | ### 26 | GET {{host}}/gym-passes/636b5b3ff573dfa78f0d5a5b/verification 27 | Content-Type: application/json 28 | Accept: application/json 29 | 30 | ### -------------------------------------------------------------------------------- /http_requests/http-client.env.json: -------------------------------------------------------------------------------- 1 | { 2 | "development": { 3 | "host": "http://localhost:8000" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "poetry.core.masonry.api" 3 | requires = ["poetry-core"] 4 | 5 | [tool] 6 | 7 | [tool.black] 8 | line-length = 120 9 | target-version = ["py310"] 10 | 11 | [tool.coverage.report] 12 | exclude_lines = [ 13 | "pragma: no cover", 14 | "def __repr__", 15 | "raise AssertionError", 16 | "raise NotImplementedError", 17 | "pass", 18 | "if 0:", 19 | "if __name__ == .__main__.:", 20 | "nocov", 21 | "if TYPE_CHECKING:", 22 | ] 23 | fail_under = 80 24 | show_missing = true 25 | 26 | [tool.coverage.run] 27 | branch = true 28 | omit = [ 29 | "tests/*" 30 | ] 31 | 32 | [tool.isort] 33 | combine_as_imports = "true" 34 | force_grid_wrap = 0 35 | include_trailing_comma = "true" 36 | known_first_party = "src" 37 | line_length = 120 38 | multi_line_output = 3 39 | 40 | [tool.mypy] 41 | disallow_untyped_defs = true 42 | follow_imports = "silent" 43 | ignore_missing_imports = true 44 | python_version = "3.10" 45 | warn_return_any = true 46 | warn_unused_configs = true 47 | 48 | [tool.poetry] 49 | authors = ["Szymon Miks "] 50 | description = "" 51 | name = "hexagonal-architecture-python" 52 | packages = [{include = "src"}] 53 | readme = "README.md" 54 | version = "0.1.0" 55 | 56 | [tool.poetry.dependencies] 57 | boto3 = "^1.25.2" 58 | dropbox = "^11.35.0" 59 | email-validator = "^1.3.0" 60 | fastapi = "^0.85.1" 61 | kink = "^0.6.5" 62 | mypy-boto3-s3 = "^1.24.76" 63 | mypy-boto3-ses = "^1.24.36.post1" 64 | mypy-boto3-sns = "^1.24.68" 65 | pymongo = "^4.2.0" 66 | python = "^3.10" 67 | python-dateutil = "^2.8.2" 68 | python-dotenv = "^0.21.0" 69 | requests = "^2.28.1" 70 | 71 | [tool.poetry.group.dev.dependencies] 72 | black = "^22.10.0" 73 | flake8 = "^5.0.4" 74 | ipython = "^8.5.0" 75 | isort = "^5.10.1" 76 | moto = "^4.0.8" 77 | mypy = "^0.982" 78 | pre-commit = "^2.20.0" 79 | pylint = "^2.15.4" 80 | pytest = "^7.1.3" 81 | pytest-cov = "^4.0.0" 82 | toml-sort = "^0.20.1" 83 | 84 | [tool.pylint.BASIC] 85 | good-names = "id,i,j,k" 86 | 87 | [tool.pylint.DESIGN] 88 | max-args = 7 89 | max-attributes = 8 90 | min-public-methods = 1 91 | 92 | [tool.pylint.FORMAT] 93 | max-line-length = 120 94 | 95 | [tool.pylint.MASTER] 96 | extension-pkg-whitelist = "pydantic" 97 | 98 | [tool.pylint."MESSAGES CONTROL"] 99 | disable = "missing-docstring, line-too-long, logging-fstring-interpolation, duplicate-code" 100 | extension-pkg-whitelist = "pydantic" 101 | 102 | [tool.pylint.MISCELLANEOUS] 103 | notes = "XXX" 104 | 105 | [tool.pylint.SIMILARITIES] 106 | ignore-comments = "yes" 107 | ignore-docstrings = "yes" 108 | ignore-imports = "yes" 109 | min-similarity-lines = 6 110 | 111 | [tool.pytest.ini_options] 112 | addopts = "-v --cov=src --cov-report term-missing --no-cov-on-fail" 113 | testpaths = ["tests"] 114 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501, W503, E203 3 | max-line-length = 120 -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | 3 | load_dotenv() 4 | -------------------------------------------------------------------------------- /src/app.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import uvicorn 4 | from fastapi import FastAPI 5 | from fastapi.openapi.utils import get_openapi 6 | from fastapi.requests import Request 7 | from fastapi.responses import JSONResponse 8 | 9 | from src.building_blocks.errors import APIErrorMessage, DomainError, RepositoryError, ResourceNotFound 10 | from src.clients.controllers import router as clients_router 11 | from src.gym_classes.controllers import router as gym_classes_router 12 | from src.gym_passes.controllers import router as gym_passes_router 13 | 14 | app = FastAPI() 15 | app.include_router(gym_classes_router) 16 | app.include_router(clients_router) 17 | app.include_router(gym_passes_router) 18 | 19 | 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 | 63 | if __name__ == "__main__": 64 | uvicorn.run(app, host="localhost", port=8000) 65 | -------------------------------------------------------------------------------- /src/building_blocks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/src/building_blocks/__init__.py -------------------------------------------------------------------------------- /src/building_blocks/clock.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from datetime import datetime 3 | 4 | 5 | class Clock(ABC): 6 | @abstractmethod 7 | def get_current_date(self) -> datetime: 8 | pass 9 | 10 | @staticmethod 11 | def system_clock() -> "Clock": 12 | return SystemClock() 13 | 14 | @staticmethod 15 | def fixed_clock(date: datetime) -> "Clock": 16 | return FixedClock(date) 17 | 18 | 19 | class SystemClock(Clock): 20 | def get_current_date(self) -> datetime: 21 | return datetime.utcnow() 22 | 23 | 24 | class FixedClock(Clock): 25 | def __init__(self, date: datetime) -> None: 26 | self._date = date 27 | 28 | def get_current_date(self) -> datetime: 29 | return self._date 30 | -------------------------------------------------------------------------------- /src/building_blocks/custom_types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Mapping 2 | 3 | from mypy_boto3_s3.client import S3Client 4 | from mypy_boto3_ses.client import SESClient 5 | from mypy_boto3_sns.client import SNSClient 6 | 7 | MongoDocument = Mapping[str, Any] 8 | SNSSdkClient = SNSClient 9 | S3SdkClient = S3Client 10 | SESSdkClient = SESClient 11 | -------------------------------------------------------------------------------- /src/building_blocks/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from pymongo import MongoClient 4 | from pymongo.database import Database 5 | 6 | 7 | def get_mongo_database() -> Database: 8 | connection_str = os.environ.get("MONGO_CONNECTION_URL", "mongodb://localhost:27017/") 9 | db_name = os.environ.get("MONGO_DATABASE_NAME", "hexagonal_architecture_python") 10 | 11 | mongo_client: MongoClient = MongoClient(connection_str, serverSelectionTimeoutMS=5) 12 | database = mongo_client.get_database(db_name) 13 | 14 | return database 15 | -------------------------------------------------------------------------------- /src/building_blocks/errors.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class DomainError(Exception): 5 | pass 6 | 7 | 8 | class ResourceNotFound(DomainError): 9 | pass 10 | 11 | 12 | class RepositoryError(DomainError): 13 | @classmethod 14 | def save_operation_failed(cls) -> "RepositoryError": 15 | return cls("An error occurred during saving to the database!") 16 | 17 | @classmethod 18 | def get_operation_failed(cls) -> "RepositoryError": 19 | return cls("An error occurred while retrieving the data from the database!") 20 | 21 | 22 | class APIErrorMessage(BaseModel): 23 | type: str 24 | message: str 25 | -------------------------------------------------------------------------------- /src/clients/__init__.py: -------------------------------------------------------------------------------- 1 | from src.clients.bootstrap import bootstrap_di 2 | 3 | bootstrap_di() 4 | -------------------------------------------------------------------------------- /src/clients/application/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/src/clients/application/__init__.py -------------------------------------------------------------------------------- /src/clients/application/client_service.py: -------------------------------------------------------------------------------- 1 | from kink import inject 2 | 3 | from src.building_blocks.clock import Clock 4 | from src.clients.application.clients_parser import ParserFactory 5 | from src.clients.application.dto import ( 6 | ArchiveClientDTO, 7 | ChangeClientPersonalDataDTO, 8 | ClientDTO, 9 | CreateClientDTO, 10 | ExportClientsDTO, 11 | ) 12 | from src.clients.domain.client import Client 13 | from src.clients.domain.client_id import ClientId 14 | from src.clients.domain.client_repository import IClientRepository 15 | from src.clients.domain.clients_exporter import IClientsExporter 16 | from src.clients.domain.email_address import EmailAddress 17 | from src.clients.domain.report import Report 18 | from src.gym_passes.facade import GymPassFacade 19 | 20 | 21 | @inject 22 | class ClientService: 23 | def __init__( 24 | self, 25 | client_repo: IClientRepository, 26 | gym_pass_facade: GymPassFacade, 27 | clients_exporter: IClientsExporter, 28 | clock: Clock = Clock.system_clock(), 29 | ) -> None: 30 | self._client_repo = client_repo 31 | self._gym_pass_facade = gym_pass_facade 32 | self._clients_exporter = clients_exporter 33 | self._clock = clock 34 | 35 | def create(self, input_dto: CreateClientDTO) -> ClientDTO: 36 | client = Client.create(input_dto.first_name, input_dto.last_name, input_dto.email) 37 | 38 | self._client_repo.save(client) 39 | 40 | snapshot = client.to_snapshot() 41 | snapshot["id"] = str(snapshot["id"]) 42 | return ClientDTO(**snapshot) 43 | 44 | def change_personal_data(self, input_dto: ChangeClientPersonalDataDTO) -> ClientDTO: 45 | client = self._client_repo.get(ClientId.of(input_dto.client_id)) # type: ignore 46 | client.change_personal_data(input_dto.first_name, input_dto.last_name) 47 | client.change_email(EmailAddress(input_dto.email)) 48 | 49 | self._client_repo.save(client) 50 | 51 | snapshot = client.to_snapshot() 52 | snapshot["id"] = str(snapshot["id"]) 53 | return ClientDTO(**snapshot) 54 | 55 | def archive(self, input_dto: ArchiveClientDTO) -> None: 56 | client = self._client_repo.get(ClientId.of(input_dto.client_id)) 57 | client.archive() 58 | 59 | self._client_repo.save(client) 60 | 61 | self._gym_pass_facade.disable_for(str(client.id.value)) 62 | 63 | def export(self, input_dto: ExportClientsDTO) -> None: 64 | clients = self._client_repo.get_all() 65 | parser = ParserFactory.build(input_dto.format) 66 | 67 | self._clients_exporter.export( 68 | Report(file_name=f"clients_report_{self._clock.get_current_date()}", content=parser.parse(clients)) 69 | ) 70 | -------------------------------------------------------------------------------- /src/clients/application/clients_parser.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import json 3 | from abc import ABC, abstractmethod 4 | from io import StringIO 5 | 6 | from src.clients.domain.client import Client 7 | 8 | 9 | class IClientsParser(ABC): 10 | @abstractmethod 11 | def parse(self, clients: list[Client]) -> StringIO: 12 | pass 13 | 14 | 15 | class CSVClientsParser(IClientsParser): 16 | def parse(self, clients: list[Client]) -> StringIO: 17 | data = [client.to_snapshot() for client in clients] 18 | 19 | output = StringIO() 20 | writer = csv.DictWriter(output, fieldnames=list(data[0].keys())) 21 | writer.writeheader() 22 | writer.writerows(data) 23 | 24 | return output 25 | 26 | 27 | class JSONClientsParser(IClientsParser): 28 | def parse(self, clients: list[Client]) -> StringIO: 29 | data = [client.to_snapshot() for client in clients] 30 | output = StringIO(json.dumps(data)) 31 | 32 | return output 33 | 34 | 35 | class ParserFactory: 36 | @staticmethod 37 | def build(expected_output: str) -> IClientsParser: 38 | if expected_output == "CSV": 39 | return CSVClientsParser() 40 | 41 | if expected_output == "JSON": 42 | return JSONClientsParser() 43 | 44 | raise ValueError("Can not build parser based on provided value!") 45 | -------------------------------------------------------------------------------- /src/clients/application/dto.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel, EmailStr 5 | 6 | 7 | class CreateClientDTO(BaseModel): 8 | first_name: str 9 | last_name: str 10 | email: EmailStr 11 | 12 | 13 | class ClientDTO(BaseModel): 14 | id: str 15 | first_name: str 16 | last_name: str 17 | email: str 18 | status: str 19 | 20 | 21 | class ChangeClientPersonalDataDTO(BaseModel): 22 | client_id: Optional[str] = None 23 | first_name: str 24 | last_name: str 25 | email: EmailStr 26 | 27 | 28 | class ArchiveClientDTO(BaseModel): 29 | client_id: str 30 | 31 | 32 | class ExportFormat(str, Enum): 33 | CSV = "CSV" 34 | JSON = "JSON" 35 | 36 | 37 | class ExportClientsDTO(BaseModel): 38 | format: ExportFormat 39 | -------------------------------------------------------------------------------- /src/clients/application/exporter_factory.py: -------------------------------------------------------------------------------- 1 | import os 2 | from enum import Enum, unique 3 | 4 | import boto3 5 | from dropbox import Dropbox 6 | 7 | from src.clients.domain.clients_exporter import IClientsExporter 8 | from src.clients.infrastructure.dropbox_clients_exporter import DropboxClientsExporter 9 | from src.clients.infrastructure.s3_clients_exporter import S3ClientsExporter 10 | 11 | 12 | @unique 13 | class Destination(str, Enum): 14 | S3 = "s3" 15 | DROPBOX = "dropbox" 16 | 17 | 18 | class ExporterFactory: 19 | @staticmethod 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) 24 | bucket_name = os.environ["S3_CLIENTS_BUCKET"] 25 | return S3ClientsExporter(s3_sdk, bucket_name) 26 | 27 | if destination == Destination.DROPBOX: 28 | dropbox_access_token = os.environ["DROPBOX_ACCESS_TOKEN"] 29 | dropbox_client = Dropbox(dropbox_access_token) 30 | return DropboxClientsExporter(dropbox_client) 31 | 32 | raise ValueError("Can not build importer for provided destination!") 33 | -------------------------------------------------------------------------------- /src/clients/bootstrap.py: -------------------------------------------------------------------------------- 1 | from kink import di 2 | 3 | from src.building_blocks.db import get_mongo_database 4 | from src.clients.application.client_service import ClientService 5 | from src.clients.application.exporter_factory import Destination, ExporterFactory 6 | from src.clients.domain.client_repository import IClientRepository 7 | from src.clients.domain.clients_exporter import IClientsExporter 8 | from src.clients.infrastructure.mongo_client_repository import MongoDBClientRepository 9 | from src.gym_passes.facade import GymPassFacade 10 | 11 | 12 | def bootstrap_di() -> None: 13 | repository = MongoDBClientRepository(get_mongo_database()) 14 | clients_exporter = ExporterFactory.build(Destination.S3) 15 | 16 | di[IClientRepository] = repository 17 | di[IClientsExporter] = clients_exporter 18 | di[ClientService] = ClientService(repository, di[GymPassFacade], clients_exporter) 19 | -------------------------------------------------------------------------------- /src/clients/controllers.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, status 2 | from fastapi.responses import JSONResponse, Response 3 | from kink import di 4 | 5 | from src.building_blocks.errors import APIErrorMessage 6 | from src.clients.application.client_service import ClientService 7 | from src.clients.application.dto import ( 8 | ArchiveClientDTO, 9 | ChangeClientPersonalDataDTO, 10 | ClientDTO, 11 | CreateClientDTO, 12 | ExportClientsDTO, 13 | ) 14 | 15 | router = APIRouter() 16 | 17 | 18 | @router.post( 19 | "/clients", 20 | response_model=ClientDTO, 21 | responses={400: {"model": APIErrorMessage}, 500: {"model": APIErrorMessage}}, 22 | tags=["clients"], 23 | ) 24 | async def create_client( 25 | request: CreateClientDTO, service: ClientService = Depends(lambda: di[ClientService]) 26 | ) -> JSONResponse: 27 | result = service.create(request) 28 | return JSONResponse(content=result.dict(), status_code=status.HTTP_201_CREATED) 29 | 30 | 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 | ) 37 | async def change_personal_data( 38 | client_id: str, request: ChangeClientPersonalDataDTO, service: ClientService = Depends(lambda: di[ClientService]) 39 | ) -> JSONResponse: 40 | request.client_id = client_id 41 | result = service.change_personal_data(request) 42 | return JSONResponse(content=result.dict(), status_code=status.HTTP_200_OK) 43 | 44 | 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 | ) 51 | async def archive(client_id: str, service: ClientService = Depends(lambda: di[ClientService])) -> Response: 52 | service.archive(ArchiveClientDTO(client_id=client_id)) 53 | return Response(status_code=status.HTTP_204_NO_CONTENT) 54 | 55 | 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: 63 | service.export(request) 64 | return Response(status_code=status.HTTP_204_NO_CONTENT) 65 | -------------------------------------------------------------------------------- /src/clients/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/src/clients/domain/__init__.py -------------------------------------------------------------------------------- /src/clients/domain/client.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from src.clients.domain.client_id import ClientId 4 | from src.clients.domain.email_address import EmailAddress 5 | from src.clients.domain.errors import ClientError 6 | from src.clients.domain.status import Status 7 | 8 | ClientSnapshot = dict[str, Any] 9 | 10 | 11 | class Client: 12 | def __init__( 13 | self, client_id: ClientId, first_name: str, last_name: str, email: EmailAddress, status: Status 14 | ) -> None: 15 | self._id = client_id 16 | self._first_name = first_name 17 | self._last_name = last_name 18 | self._email = email 19 | self._status = status 20 | 21 | @property 22 | def id(self) -> ClientId: 23 | return self._id 24 | 25 | @property 26 | def first_name(self) -> str: 27 | return self._first_name 28 | 29 | @property 30 | def last_name(self) -> str: 31 | return self._last_name 32 | 33 | @property 34 | def email(self) -> EmailAddress: 35 | return self._email 36 | 37 | @classmethod 38 | def create(cls, first_name: str, last_name: str, email: str) -> "Client": 39 | return cls(ClientId.new_one(), first_name, last_name, EmailAddress(email), Status.active) 40 | 41 | def change_personal_data(self, new_first_name: str, new_last_name: str) -> None: 42 | if self._status == Status.archived: 43 | raise ClientError("Can not modify personal data for archived client!") 44 | 45 | self._first_name = new_first_name 46 | self._last_name = new_last_name 47 | 48 | def change_email(self, new_email: EmailAddress) -> None: 49 | if self._status == Status.archived: 50 | raise ClientError("Can not change email address for archived client!") 51 | 52 | self._email = new_email 53 | 54 | def archive(self) -> None: 55 | self._status = Status.archived 56 | 57 | def is_active(self) -> bool: 58 | return self._status == Status.active 59 | 60 | def to_snapshot(self) -> ClientSnapshot: 61 | return { 62 | "id": self._id.value, 63 | "first_name": self._first_name, 64 | "last_name": self._last_name, 65 | "email": self._email.value, 66 | "status": self._status.value, 67 | } 68 | -------------------------------------------------------------------------------- /src/clients/domain/client_id.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from bson.errors import InvalidId 4 | from bson.objectid import ObjectId 5 | 6 | from src.clients.domain.errors import ClientError 7 | 8 | 9 | @dataclass(frozen=True) 10 | class ClientId: 11 | value: ObjectId 12 | 13 | @classmethod 14 | def new_one(cls) -> "ClientId": 15 | return cls(ObjectId()) 16 | 17 | @classmethod 18 | def of(cls, id: str) -> "ClientId": 19 | try: 20 | return cls(ObjectId(id)) 21 | except InvalidId as error: 22 | raise ClientError.invalid_id() from error 23 | 24 | def __str__(self) -> str: 25 | return str(self.value) 26 | -------------------------------------------------------------------------------- /src/clients/domain/client_repository.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from src.clients.domain.client import Client 4 | from src.clients.domain.client_id import ClientId 5 | 6 | 7 | class IClientRepository(ABC): 8 | @abstractmethod 9 | def get(self, client_id: ClientId) -> Client: 10 | pass 11 | 12 | @abstractmethod 13 | def get_all(self) -> list[Client]: 14 | pass 15 | 16 | @abstractmethod 17 | def save(self, client: Client) -> None: 18 | pass 19 | -------------------------------------------------------------------------------- /src/clients/domain/clients_exporter.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from src.clients.domain.report import Report 4 | 5 | 6 | class IClientsExporter(ABC): 7 | @abstractmethod 8 | def export(self, report: Report) -> None: 9 | pass 10 | -------------------------------------------------------------------------------- /src/clients/domain/email_address.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from email_validator import EmailNotValidError, validate_email 4 | 5 | 6 | @dataclass(frozen=True) 7 | class EmailAddress: 8 | value: str 9 | 10 | def __post_init__(self) -> None: 11 | try: 12 | validate_email(self.value) 13 | except EmailNotValidError as error: 14 | raise ValueError(f"{self.value} is not a correct e-mail address!") from error 15 | -------------------------------------------------------------------------------- /src/clients/domain/errors.py: -------------------------------------------------------------------------------- 1 | from src.building_blocks.errors import DomainError, ResourceNotFound 2 | 3 | 4 | class ClientError(DomainError): 5 | @classmethod 6 | def invalid_id(cls) -> "ClientError": 7 | return cls("Provided id is not correct according to the ObjectId standard!") 8 | 9 | 10 | class ClientNotFound(ResourceNotFound): 11 | pass 12 | 13 | 14 | class ExportError(DomainError): 15 | pass 16 | -------------------------------------------------------------------------------- /src/clients/domain/report.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from io import StringIO 3 | 4 | 5 | @dataclass(frozen=True) 6 | class Report: 7 | file_name: str 8 | content: StringIO 9 | 10 | def __post_init__(self) -> None: 11 | if not self.file_name: 12 | raise ValueError("file_name can not be an empty string!") 13 | 14 | def content_as_bytes(self) -> bytes: 15 | return bytes(self.content.read(), encoding="utf-8") 16 | -------------------------------------------------------------------------------- /src/clients/domain/status.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | 3 | 4 | @unique 5 | class Status(Enum): 6 | active = "active" 7 | archived = "archived" 8 | -------------------------------------------------------------------------------- /src/clients/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/src/clients/infrastructure/__init__.py -------------------------------------------------------------------------------- /src/clients/infrastructure/dropbox_clients_exporter.py: -------------------------------------------------------------------------------- 1 | from dropbox import Dropbox 2 | from dropbox.exceptions import DropboxException 3 | 4 | from src.clients.domain.clients_exporter import IClientsExporter 5 | from src.clients.domain.errors import ExportError 6 | from src.clients.domain.report import Report 7 | 8 | 9 | class DropboxClientsExporter(IClientsExporter): 10 | def __init__(self, dropbox_client: Dropbox) -> None: 11 | self._client = dropbox_client 12 | 13 | def export(self, report: Report) -> None: 14 | try: 15 | self._client.files_upload(report.content_as_bytes(), report.file_name) 16 | except DropboxException as error: 17 | raise ExportError("Can not export clients report!") from error 18 | -------------------------------------------------------------------------------- /src/clients/infrastructure/in_memory_client_repository.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from src.clients.domain.client import Client 4 | from src.clients.domain.client_id import ClientId 5 | from src.clients.domain.client_repository import IClientRepository 6 | from src.clients.domain.errors import ClientNotFound 7 | 8 | 9 | class InMemoryClientRepository(IClientRepository): 10 | def __init__(self) -> None: 11 | self._clients: dict[ClientId, Client] = {} 12 | 13 | def get(self, client_id: ClientId) -> Client: 14 | try: 15 | return copy.deepcopy(self._clients[client_id]) 16 | except KeyError as error: 17 | raise ClientNotFound(f"Client with id={client_id} was not found!") from error 18 | 19 | def get_all(self) -> list[Client]: 20 | clients = [] 21 | for client in self._clients.values(): 22 | clients.append(copy.deepcopy(client)) 23 | return clients 24 | 25 | def save(self, client: Client) -> None: 26 | self._clients[client.id] = copy.deepcopy(client) 27 | -------------------------------------------------------------------------------- /src/clients/infrastructure/mongo_client_repository.py: -------------------------------------------------------------------------------- 1 | from pymongo.database import Database 2 | from pymongo.errors import PyMongoError 3 | 4 | from src.building_blocks.custom_types import MongoDocument 5 | from src.building_blocks.errors import RepositoryError 6 | from src.clients.domain.client import Client 7 | from src.clients.domain.client_id import ClientId 8 | from src.clients.domain.client_repository import IClientRepository 9 | from src.clients.domain.email_address import EmailAddress 10 | from src.clients.domain.errors import ClientNotFound 11 | from src.clients.domain.status import Status 12 | 13 | 14 | class MongoDBClientRepository(IClientRepository): 15 | def __init__(self, database: Database): 16 | self._collection = database.get_collection("clients") 17 | 18 | def _to_entity(self, document: MongoDocument) -> Client: 19 | return Client( 20 | client_id=ClientId.of(str(document["_id"])), 21 | first_name=document["first_name"], 22 | last_name=document["last_name"], 23 | email=EmailAddress(document["email"]), 24 | status=Status(document["status"]), 25 | ) 26 | 27 | def get_all(self) -> list[Client]: 28 | clients = [] 29 | 30 | try: 31 | documents = self._collection.find({}) 32 | except PyMongoError as error: 33 | raise RepositoryError.get_operation_failed() from error 34 | 35 | for document in documents: 36 | clients.append(self._to_entity(document)) 37 | 38 | return clients 39 | 40 | def get(self, client_id: ClientId) -> Client: 41 | try: 42 | document = self._collection.find_one({"_id": client_id.value}) 43 | 44 | if not document: 45 | raise ClientNotFound(f"Client with id={client_id} was not found!") 46 | 47 | return self._to_entity(document) 48 | except PyMongoError as error: 49 | raise RepositoryError.get_operation_failed() from error 50 | 51 | def save(self, client: Client) -> None: 52 | try: 53 | snapshot = client.to_snapshot() 54 | snapshot["_id"] = snapshot["id"] 55 | del snapshot["id"] 56 | 57 | self._collection.update_one({"_id": snapshot["_id"]}, {"$set": snapshot}, upsert=True) 58 | except PyMongoError as error: 59 | raise RepositoryError.save_operation_failed() from error 60 | -------------------------------------------------------------------------------- /src/clients/infrastructure/s3_clients_exporter.py: -------------------------------------------------------------------------------- 1 | from botocore.exceptions import ClientError 2 | 3 | from src.building_blocks.custom_types import S3SdkClient 4 | from src.clients.domain.clients_exporter import IClientsExporter 5 | from src.clients.domain.errors import ExportError 6 | from src.clients.domain.report import Report 7 | 8 | 9 | class S3ClientsExporter(IClientsExporter): 10 | def __init__(self, s3_sdk_client: S3SdkClient, bucket_name: str) -> None: 11 | self._s3_sdk_client = s3_sdk_client 12 | self._bucket_name = bucket_name 13 | 14 | def export(self, report: Report) -> None: 15 | try: 16 | self._s3_sdk_client.put_object( 17 | Bucket=self._bucket_name, Key=f"reports/{report.file_name}", Body=report.content.read() 18 | ) 19 | except ClientError as error: 20 | raise ExportError("Can not export clients report!") from error 21 | -------------------------------------------------------------------------------- /src/gym_classes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/src/gym_classes/__init__.py -------------------------------------------------------------------------------- /src/gym_classes/config.py: -------------------------------------------------------------------------------- 1 | from src.building_blocks.db import get_mongo_database 2 | from src.gym_classes.service import GymClassService 3 | 4 | 5 | class Config: 6 | @staticmethod 7 | def get_gym_class_service() -> GymClassService: 8 | return GymClassService(get_mongo_database()) 9 | -------------------------------------------------------------------------------- /src/gym_classes/controllers.py: -------------------------------------------------------------------------------- 1 | from bson.objectid import ObjectId 2 | from fastapi import APIRouter, status 3 | from fastapi.responses import JSONResponse 4 | 5 | from src.gym_classes.config import Config 6 | from src.gym_classes.errors import GymClassNotFound 7 | from src.gym_classes.models import CreateOrUpdateClass, GymClass 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.post("/classes", response_model=GymClass, tags=["gym classes"]) 13 | async def create_class(gym_class_request: CreateOrUpdateClass) -> JSONResponse: 14 | service = Config.get_gym_class_service() 15 | result = service.create(gym_class_request) 16 | return JSONResponse(content=result.dict(), status_code=status.HTTP_201_CREATED) 17 | 18 | 19 | @router.get("/classes", response_model=list[GymClass], tags=["gym classes"]) 20 | async def list_classes() -> JSONResponse: 21 | service = Config.get_gym_class_service() 22 | result = service.get_all() 23 | 24 | return JSONResponse(content=[item.dict() for item in result], status_code=status.HTTP_200_OK) 25 | 26 | 27 | @router.get("/classes/{class_id}", response_model=GymClass, tags=["gym classes"]) 28 | async def get_class(class_id: str) -> JSONResponse: 29 | service = Config.get_gym_class_service() 30 | try: 31 | result = service.get(ObjectId(class_id)) 32 | except GymClassNotFound as error: 33 | return JSONResponse(content={"message": str(error)}, status_code=status.HTTP_404_NOT_FOUND) 34 | 35 | return JSONResponse(content=result.dict(), status_code=status.HTTP_200_OK) 36 | 37 | 38 | @router.put("/classes/{class_id}", response_model=GymClass, tags=["gym classes"]) 39 | async def update_class(class_id: str, gym_class_request: CreateOrUpdateClass) -> JSONResponse: 40 | service = Config.get_gym_class_service() 41 | try: 42 | result = service.update(class_id, gym_class_request) 43 | except GymClassNotFound as error: 44 | return JSONResponse(content={"message": str(error)}, status_code=status.HTTP_404_NOT_FOUND) 45 | 46 | return JSONResponse(content=result.dict(), status_code=status.HTTP_200_OK) 47 | 48 | 49 | @router.delete("/classes/{class_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["gym classes"]) 50 | async def delete_class(class_id: str) -> JSONResponse: 51 | service = Config.get_gym_class_service() 52 | service.delete(class_id) 53 | 54 | return JSONResponse(content={}, status_code=status.HTTP_204_NO_CONTENT) 55 | -------------------------------------------------------------------------------- /src/gym_classes/errors.py: -------------------------------------------------------------------------------- 1 | class GymClassNotFound(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/gym_classes/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class GymClass(BaseModel): 5 | id: str 6 | name: str 7 | day: str 8 | time: str 9 | coach: str 10 | description: str | None 11 | 12 | class Config: 13 | schema_extra = { 14 | "example": { 15 | "id": "63500520c1f28686b7d7da2c", 16 | "name": "Pilates", 17 | "day": "Monday", 18 | "time": "10:30-11:30", 19 | "coach": "Alex W.", 20 | "description": "A system of physical exercises invented at the beginning of the 20th century by the German Josef Humbertus Pilates", 21 | } 22 | } 23 | 24 | 25 | class CreateOrUpdateClass(BaseModel): 26 | name: str 27 | day: str 28 | time: str 29 | coach: str 30 | description: str | None 31 | 32 | class Config: 33 | schema_extra = { 34 | "example": { 35 | "name": "Pilates", 36 | "day": "Monday", 37 | "time": "10:30-11:30", 38 | "coach": "Alex W.", 39 | "description": "A system of physical exercises invented at the beginning of the 20th century by the German Josef Humbertus Pilates", 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/gym_classes/service.py: -------------------------------------------------------------------------------- 1 | from bson.objectid import ObjectId 2 | from pymongo.database import Database 3 | 4 | from src.gym_classes.errors import GymClassNotFound 5 | from src.gym_classes.models import CreateOrUpdateClass, GymClass 6 | 7 | 8 | class GymClassService: 9 | def __init__(self, database: Database) -> None: 10 | self._collection = database.get_collection("gym_classes") 11 | 12 | def get(self, gym_class_id: ObjectId) -> GymClass: 13 | document = self._collection.find_one({"_id": gym_class_id}) 14 | 15 | if not document: 16 | raise GymClassNotFound(f"Gym class with id={gym_class_id} was not found!") 17 | 18 | document["id"] = str(document["_id"]) 19 | return GymClass(**document) 20 | 21 | def get_all(self) -> list[GymClass]: 22 | gym_classes = [] 23 | documents = self._collection.find({}) 24 | for document in documents: 25 | document["id"] = str(document["_id"]) 26 | gym_classes.append(GymClass(**document)) 27 | 28 | days_order = { 29 | "Monday": 1, 30 | "Tuesday": 2, 31 | "Wednesday": 3, 32 | "Thursday": 4, 33 | "Friday": 5, 34 | "Saturday": 6, 35 | "Sunday": 7, 36 | } 37 | 38 | return sorted(gym_classes, key=lambda x: (days_order.get(x.day), x.time)) 39 | 40 | def create(self, gym_class_request: CreateOrUpdateClass) -> GymClass: 41 | document = gym_class_request.dict() 42 | result = self._collection.insert_one(document) 43 | 44 | return self.get(result.inserted_id) 45 | 46 | def update(self, gym_class_id: str, gym_class_request: CreateOrUpdateClass) -> GymClass: 47 | gym_class = self.get(ObjectId(gym_class_id)) 48 | gym_class.name = gym_class_request.name 49 | gym_class.day = gym_class_request.day 50 | gym_class.time = gym_class_request.time 51 | gym_class.coach = gym_class_request.coach 52 | gym_class.description = gym_class_request.description 53 | 54 | new_values = gym_class.dict() 55 | del new_values["id"] 56 | 57 | self._collection.update_one({"_id": ObjectId(gym_class_id)}, {"$set": new_values}) 58 | return gym_class 59 | 60 | def delete(self, gym_class_id: str) -> None: 61 | self._collection.delete_one({"_id": ObjectId(gym_class_id)}) 62 | -------------------------------------------------------------------------------- /src/gym_passes/__init__.py: -------------------------------------------------------------------------------- 1 | from src.gym_passes.bootstrap import bootstrap_di 2 | 3 | bootstrap_di() 4 | -------------------------------------------------------------------------------- /src/gym_passes/application/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/src/gym_passes/application/__init__.py -------------------------------------------------------------------------------- /src/gym_passes/application/dto.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | class CreateGymPassDTO(BaseModel): 8 | class Validity(str, Enum): 9 | ONE_MONTH = "ONE_MONTH" 10 | ONE_YEAR = "ONE_YEAR" 11 | 12 | owner: str 13 | validity: Validity 14 | 15 | 16 | class PauseGymPassDTO(BaseModel): 17 | gym_pass_id: Optional[str] 18 | days: int 19 | 20 | 21 | class GymPassDTO(BaseModel): 22 | id: str 23 | is_active: bool 24 | -------------------------------------------------------------------------------- /src/gym_passes/application/gym_pass_service.py: -------------------------------------------------------------------------------- 1 | from kink import inject 2 | 3 | from src.building_blocks.clock import Clock 4 | from src.gym_passes.application.dto import CreateGymPassDTO, GymPassDTO, PauseGymPassDTO 5 | from src.gym_passes.domain.date_range import DateRange 6 | from src.gym_passes.domain.errors import PausingError 7 | from src.gym_passes.domain.gym_pass import GymPass 8 | from src.gym_passes.domain.gym_pass_id import GymPassId 9 | from src.gym_passes.domain.gym_pass_repository import IGymPassRepository 10 | from src.gym_passes.domain.pause import Pause 11 | 12 | 13 | @inject 14 | class GymPassService: 15 | def __init__(self, gym_pass_repo: IGymPassRepository, clock: Clock = Clock.system_clock()) -> None: 16 | self._gym_pass_repo = gym_pass_repo 17 | self._clock = clock 18 | 19 | def create(self, input_dto: CreateGymPassDTO) -> GymPassDTO: 20 | period_of_validity = ( 21 | DateRange.one_month(self._clock) if input_dto.validity.ONE_MONTH else DateRange.one_year(self._clock) 22 | ) 23 | gym_pass = GymPass.create_for(input_dto.owner, period_of_validity, self._clock) 24 | 25 | self._gym_pass_repo.save(gym_pass) 26 | 27 | return GymPassDTO(id=str(gym_pass.id), is_active=gym_pass.active) 28 | 29 | def pause(self, input_dto: PauseGymPassDTO) -> GymPassDTO: 30 | if not input_dto.gym_pass_id: 31 | raise PausingError("Gym pass id not provided!") 32 | 33 | gym_pass = self._gym_pass_repo.get(GymPassId.of(input_dto.gym_pass_id)) 34 | gym_pass.pause(Pause(self._clock.get_current_date(), input_dto.days)) 35 | 36 | self._gym_pass_repo.save(gym_pass) 37 | 38 | return GymPassDTO(id=str(gym_pass.id), is_active=gym_pass.active) 39 | 40 | def renew(self, gym_pass_id: str) -> GymPassDTO: 41 | gym_pass = self._gym_pass_repo.get(GymPassId.of(gym_pass_id)) 42 | gym_pass.renew() 43 | 44 | self._gym_pass_repo.save(gym_pass) 45 | 46 | return GymPassDTO(id=str(gym_pass.id), is_active=gym_pass.active) 47 | 48 | def check(self, gym_pass_id: str) -> GymPassDTO: 49 | gym_pass = self._gym_pass_repo.get(GymPassId.of(gym_pass_id)) 50 | 51 | return GymPassDTO(id=str(gym_pass.id), is_active=gym_pass.active) 52 | -------------------------------------------------------------------------------- /src/gym_passes/bootstrap.py: -------------------------------------------------------------------------------- 1 | from kink import di 2 | 3 | from src.building_blocks.clock import Clock 4 | from src.building_blocks.db import get_mongo_database 5 | from src.gym_passes.application.gym_pass_service import GymPassService 6 | from src.gym_passes.domain.gym_pass_repository import IGymPassRepository 7 | from src.gym_passes.facade import GymPassFacade 8 | from src.gym_passes.infrastructure.mongo_gym_pass_repository import MongoDBGymPassRepository 9 | 10 | 11 | def bootstrap_di() -> None: 12 | repository = MongoDBGymPassRepository(get_mongo_database()) 13 | di[IGymPassRepository] = repository 14 | di[GymPassService] = GymPassService(repository, Clock.system_clock()) 15 | di[GymPassFacade] = GymPassFacade(repository) 16 | -------------------------------------------------------------------------------- /src/gym_passes/controllers.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, status 2 | from fastapi.responses import JSONResponse 3 | from kink import di 4 | 5 | from src.building_blocks.errors import APIErrorMessage 6 | from src.gym_passes.application.dto import CreateGymPassDTO, GymPassDTO, PauseGymPassDTO 7 | from src.gym_passes.application.gym_pass_service import GymPassService 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.post( 13 | "/gym-passes", 14 | response_model=GymPassDTO, 15 | responses={400: {"model": APIErrorMessage}, 500: {"model": APIErrorMessage}}, 16 | tags=["gym_passes"], 17 | ) 18 | async def create_gym_pass( 19 | request: CreateGymPassDTO, service: GymPassService = Depends(lambda: di[GymPassService]) 20 | ) -> JSONResponse: 21 | result = service.create(request) 22 | return JSONResponse(content=result.dict(), status_code=status.HTTP_201_CREATED) 23 | 24 | 25 | @router.post( 26 | "/gym-passes/{gym_pass_id}/pauses", 27 | response_model=GymPassDTO, 28 | responses={400: {"model": APIErrorMessage}, 404: {"model": APIErrorMessage}, 500: {"model": APIErrorMessage}}, 29 | tags=["gym_passes"], 30 | ) 31 | async def pause( 32 | gym_pass_id: str, request: PauseGymPassDTO, service: GymPassService = Depends(lambda: di[GymPassService]) 33 | ) -> JSONResponse: 34 | request.gym_pass_id = gym_pass_id 35 | result = service.pause(request) 36 | return JSONResponse(content=result.dict(), status_code=status.HTTP_201_CREATED) 37 | 38 | 39 | @router.put( 40 | "/gym-passes/{gym_pass_id}/renewal", 41 | response_model=GymPassDTO, 42 | responses={400: {"model": APIErrorMessage}, 404: {"model": APIErrorMessage}, 500: {"model": APIErrorMessage}}, 43 | tags=["gym_passes"], 44 | ) 45 | async def renew(gym_pass_id: str, service: GymPassService = Depends(lambda: di[GymPassService])) -> JSONResponse: 46 | result = service.renew(gym_pass_id) 47 | return JSONResponse(content=result.dict(), status_code=status.HTTP_200_OK) 48 | 49 | 50 | @router.get( 51 | "/gym-passes/{gym_pass_id}/verification", 52 | response_model=GymPassDTO, 53 | responses={400: {"model": APIErrorMessage}, 404: {"model": APIErrorMessage}, 500: {"model": APIErrorMessage}}, 54 | tags=["gym_passes"], 55 | ) 56 | async def check(gym_pass_id: str, service: GymPassService = Depends(lambda: di[GymPassService])) -> JSONResponse: 57 | result = service.check(gym_pass_id) 58 | return JSONResponse(content=result.dict(), status_code=status.HTTP_200_OK) 59 | -------------------------------------------------------------------------------- /src/gym_passes/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/src/gym_passes/domain/__init__.py -------------------------------------------------------------------------------- /src/gym_passes/domain/date_range.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | 4 | from dateutil.relativedelta import relativedelta 5 | 6 | from src.building_blocks.clock import Clock 7 | 8 | 9 | @dataclass(frozen=True) 10 | class DateRange: 11 | start_date: datetime 12 | end_date: datetime 13 | 14 | @classmethod 15 | def one_month(cls, clock: Clock) -> "DateRange": 16 | start_date = clock.get_current_date() 17 | end_date = start_date + relativedelta(months=1) 18 | return cls(start_date, end_date) 19 | 20 | @classmethod 21 | def one_year(cls, clock: Clock) -> "DateRange": 22 | start_date = clock.get_current_date() 23 | end_date = start_date + relativedelta(years=1) 24 | return cls(start_date, end_date) 25 | 26 | def is_within_range(self, date: datetime) -> bool: 27 | return self.start_date <= date <= self.end_date 28 | -------------------------------------------------------------------------------- /src/gym_passes/domain/errors.py: -------------------------------------------------------------------------------- 1 | from src.building_blocks.errors import DomainError, ResourceNotFound 2 | 3 | 4 | class GymPassError(DomainError): 5 | @classmethod 6 | def invalid_id(cls) -> "GymPassError": 7 | return cls("Provided id is not correct according to the ObjectId standard!") 8 | 9 | 10 | class PausingError(DomainError): 11 | pass 12 | 13 | 14 | class GymPassNotFound(ResourceNotFound): 15 | pass 16 | -------------------------------------------------------------------------------- /src/gym_passes/domain/gym_pass.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from dateutil.relativedelta import relativedelta 4 | 5 | from src.building_blocks.clock import Clock 6 | from src.gym_passes.domain.date_range import DateRange 7 | from src.gym_passes.domain.errors import GymPassError 8 | from src.gym_passes.domain.gym_pass_id import GymPassId 9 | from src.gym_passes.domain.pause import Pause 10 | from src.gym_passes.domain.status import Status 11 | 12 | OwnerId = str 13 | GymPassSnapshot = dict[str, Any] 14 | 15 | 16 | class GymPass: 17 | def __init__( 18 | self, 19 | gym_pass_id: GymPassId, 20 | owner_id: OwnerId, 21 | status: Status, 22 | period_of_validity: DateRange, 23 | clock: Clock, 24 | pauses: list[Pause] | None, 25 | ) -> None: 26 | self._gym_pass_id = gym_pass_id 27 | self._owner_id = owner_id 28 | self._status = status 29 | self._period_of_validity = period_of_validity 30 | self._clock = clock 31 | self._pauses = pauses if pauses else [] 32 | 33 | @property 34 | def id(self) -> GymPassId: 35 | return self._gym_pass_id 36 | 37 | @classmethod 38 | def create_for( 39 | cls, owner_id: OwnerId, period_of_validity: DateRange, clock: Clock = Clock.system_clock() 40 | ) -> "GymPass": 41 | return cls( 42 | gym_pass_id=GymPassId.new_one(), 43 | owner_id=owner_id, 44 | status=Status.activated, 45 | period_of_validity=period_of_validity, 46 | clock=clock, 47 | pauses=None, 48 | ) 49 | 50 | def is_owned_by(self, owner_id: OwnerId) -> bool: 51 | return self._owner_id == owner_id 52 | 53 | def activate(self) -> None: 54 | self._status = Status.activated 55 | 56 | def disable(self) -> None: 57 | self._status = Status.disabled 58 | 59 | def pause(self, pause: Pause) -> None: 60 | if not self.active: 61 | raise GymPassError("Can not pause not active gym pass!") 62 | 63 | if len(self._pauses) >= 3: 64 | raise GymPassError("The maximum amount of pauses were exceeded!") 65 | 66 | self._status = Status.paused 67 | self._pauses.append(pause) 68 | self._pauses.sort(key=lambda pause_item: pause_item.paused_at) 69 | 70 | def renew(self) -> None: 71 | if self._status == Status.disabled: 72 | raise GymPassError("Can not renew disabled gym pass!") 73 | 74 | if self._status == Status.activated: 75 | raise GymPassError("Can not renew active gym pass!") 76 | 77 | latest_pause = self._pauses[-1] 78 | self._period_of_validity = DateRange( 79 | start_date=self._period_of_validity.start_date, 80 | end_date=self._period_of_validity.end_date + relativedelta(days=latest_pause.days), 81 | ) 82 | 83 | self._status = Status.activated 84 | 85 | @property 86 | def active(self) -> bool: 87 | if self._status == Status.disabled: 88 | return False 89 | 90 | if self._status == Status.paused: 91 | return False 92 | 93 | return self._period_of_validity.is_within_range(self._clock.get_current_date()) 94 | 95 | def to_snapshot(self) -> GymPassSnapshot: 96 | return { 97 | "id": self._gym_pass_id.value, 98 | "owner_id": self._owner_id, 99 | "status": self._status.value, 100 | "period_of_validity": { 101 | "from": self._period_of_validity.start_date, 102 | "to": self._period_of_validity.end_date, 103 | }, 104 | "pauses": [{"paused_at": pause.paused_at, "days": pause.days} for pause in self._pauses], 105 | } 106 | -------------------------------------------------------------------------------- /src/gym_passes/domain/gym_pass_id.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from bson.errors import InvalidId 4 | from bson.objectid import ObjectId 5 | 6 | from src.gym_passes.domain.errors import GymPassError 7 | 8 | 9 | @dataclass(frozen=True) 10 | class GymPassId: 11 | value: ObjectId 12 | 13 | @classmethod 14 | def new_one(cls) -> "GymPassId": 15 | return GymPassId(ObjectId()) 16 | 17 | @classmethod 18 | def of(cls, id: str) -> "GymPassId": 19 | try: 20 | return cls(ObjectId(id)) 21 | except InvalidId as error: 22 | raise GymPassError.invalid_id() from error 23 | 24 | def __str__(self) -> str: 25 | return str(self.value) 26 | -------------------------------------------------------------------------------- /src/gym_passes/domain/gym_pass_repository.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from src.gym_passes.domain.gym_pass import GymPass, OwnerId 4 | from src.gym_passes.domain.gym_pass_id import GymPassId 5 | 6 | 7 | class IGymPassRepository(ABC): 8 | @abstractmethod 9 | def get(self, gym_pass_id: GymPassId) -> GymPass: 10 | pass 11 | 12 | @abstractmethod 13 | def get_by(self, owner_id: OwnerId) -> GymPass: 14 | pass 15 | 16 | @abstractmethod 17 | def save(self, gym_pass: GymPass) -> None: 18 | pass 19 | -------------------------------------------------------------------------------- /src/gym_passes/domain/pause.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | 4 | from src.gym_passes.domain.errors import PausingError 5 | 6 | 7 | @dataclass(frozen=True) 8 | class Pause: 9 | paused_at: datetime 10 | days: int 11 | 12 | def __post_init__(self) -> None: 13 | if self.days > 45: 14 | raise PausingError("Can not pause gym pass for more than 45 days") 15 | -------------------------------------------------------------------------------- /src/gym_passes/domain/status.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | 3 | 4 | @unique 5 | class Status(Enum): 6 | activated = "activated" 7 | paused = "paused" 8 | disabled = "disabled" 9 | -------------------------------------------------------------------------------- /src/gym_passes/facade.py: -------------------------------------------------------------------------------- 1 | from kink import inject 2 | 3 | from src.gym_passes.domain.errors import GymPassNotFound 4 | from src.gym_passes.domain.gym_pass_repository import IGymPassRepository 5 | 6 | 7 | @inject 8 | class GymPassFacade: 9 | def __init__(self, gym_pass_repo: IGymPassRepository) -> None: 10 | self._gym_pass_repo = gym_pass_repo 11 | 12 | def disable_for(self, owner_id: str) -> None: 13 | try: 14 | gym_pass = self._gym_pass_repo.get_by(owner_id) 15 | gym_pass.disable() 16 | self._gym_pass_repo.save(gym_pass) 17 | except GymPassNotFound: 18 | return 19 | -------------------------------------------------------------------------------- /src/gym_passes/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/src/gym_passes/infrastructure/__init__.py -------------------------------------------------------------------------------- /src/gym_passes/infrastructure/in_memory_gym_pass_repository.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from src.gym_passes.domain.errors import GymPassNotFound 4 | from src.gym_passes.domain.gym_pass import GymPass, OwnerId 5 | from src.gym_passes.domain.gym_pass_id import GymPassId 6 | from src.gym_passes.domain.gym_pass_repository import IGymPassRepository 7 | 8 | 9 | class InMemoryGymPassRepository(IGymPassRepository): 10 | def __init__(self) -> None: 11 | self._gym_passes: dict[GymPassId, GymPass] = {} 12 | 13 | def get(self, gym_pass_id: GymPassId) -> GymPass: 14 | try: 15 | return copy.deepcopy(self._gym_passes[gym_pass_id]) 16 | except KeyError as error: 17 | raise GymPassNotFound(f"Gym pass with id={gym_pass_id} was not found!") from error 18 | 19 | def get_by(self, owner_id: OwnerId) -> GymPass: 20 | for gym_pass in self._gym_passes.values(): 21 | if gym_pass.is_owned_by(owner_id): 22 | return copy.deepcopy(gym_pass) 23 | 24 | raise GymPassNotFound(f"Gym pass with owner_id={owner_id} was not found!") 25 | 26 | def save(self, gym_pass: GymPass) -> None: 27 | self._gym_passes[gym_pass.id] = copy.deepcopy(gym_pass) 28 | -------------------------------------------------------------------------------- /src/gym_passes/infrastructure/mongo_gym_pass_repository.py: -------------------------------------------------------------------------------- 1 | from pymongo.database import Database 2 | from pymongo.errors import PyMongoError 3 | 4 | from src.building_blocks.clock import Clock 5 | from src.building_blocks.custom_types import MongoDocument 6 | from src.building_blocks.errors import RepositoryError 7 | from src.gym_passes.domain.date_range import DateRange 8 | from src.gym_passes.domain.errors import GymPassNotFound 9 | from src.gym_passes.domain.gym_pass import GymPass, OwnerId 10 | from src.gym_passes.domain.gym_pass_id import GymPassId 11 | from src.gym_passes.domain.gym_pass_repository import IGymPassRepository 12 | from src.gym_passes.domain.pause import Pause 13 | from src.gym_passes.domain.status import Status 14 | 15 | 16 | class MongoDBGymPassRepository(IGymPassRepository): 17 | def __init__(self, database: Database): 18 | self._collection = database.get_collection("gym_passes") 19 | 20 | def _to_entity(self, document: MongoDocument) -> GymPass: 21 | pauses = [Pause(paused_at=item["paused_at"], days=item["days"]) for item in document["pauses"]] 22 | return GymPass( 23 | gym_pass_id=GymPassId(document["_id"]), 24 | owner_id=document["owner_id"], 25 | status=Status(document["status"]), 26 | period_of_validity=DateRange(document["period_of_validity"]["from"], document["period_of_validity"]["to"]), 27 | clock=Clock.system_clock(), 28 | pauses=pauses, 29 | ) 30 | 31 | def get(self, gym_pass_id: GymPassId) -> GymPass: 32 | try: 33 | document = self._collection.find_one({"_id": gym_pass_id.value}) 34 | 35 | if not document: 36 | raise GymPassNotFound(f"Gym pass with id={gym_pass_id} was not found!") 37 | 38 | return self._to_entity(document) 39 | except PyMongoError as error: 40 | raise RepositoryError.get_operation_failed() from error 41 | 42 | def get_by(self, owner_id: OwnerId) -> GymPass: 43 | try: 44 | document = self._collection.find_one({"owner_id": owner_id}) 45 | 46 | if not document: 47 | raise GymPassNotFound(f"Gym pass with owner_id={owner_id} was not found!") 48 | 49 | return self._to_entity(document) 50 | except PyMongoError as error: 51 | raise RepositoryError.get_operation_failed() from error 52 | 53 | def save(self, gym_pass: GymPass) -> None: 54 | try: 55 | snapshot = gym_pass.to_snapshot() 56 | snapshot["_id"] = snapshot["id"] 57 | del snapshot["id"] 58 | 59 | self._collection.update_one({"_id": snapshot["_id"]}, {"$set": snapshot}, upsert=True) 60 | except PyMongoError as error: 61 | raise RepositoryError.save_operation_failed() from error 62 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator 2 | 3 | import pytest 4 | from fastapi.testclient import TestClient 5 | from pymongo import MongoClient 6 | from pymongo.database import Database 7 | from pytest import MonkeyPatch 8 | 9 | from src.app import app 10 | 11 | 12 | @pytest.fixture() 13 | def test_client() -> TestClient: 14 | return TestClient(app) 15 | 16 | 17 | @pytest.fixture() 18 | def mongodb(monkeypatch: MonkeyPatch) -> Iterator[Database]: 19 | monkeypatch.setenv("MONGO_DATABASE_NAME", "hexagonal_architecture_python_testing") 20 | 21 | mongo_client: MongoClient = MongoClient("mongodb://localhost:27017/", serverSelectionTimeoutMS=5) 22 | 23 | database = mongo_client.get_database("hexagonal_architecture_python_testing") 24 | 25 | yield database 26 | 27 | mongo_client.drop_database("hexagonal_architecture_python_testing") 28 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/tests/integration/clients/__init__.py -------------------------------------------------------------------------------- /tests/integration/clients/conftest.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Iterator 3 | from unittest.mock import Mock 4 | 5 | import boto3 6 | import pytest 7 | from moto import mock_s3 8 | 9 | from src.building_blocks.clock import Clock 10 | from src.building_blocks.custom_types import S3SdkClient 11 | from src.clients.application.client_service import ClientService 12 | from src.clients.domain.client_repository import IClientRepository 13 | from src.clients.domain.clients_exporter import IClientsExporter 14 | from src.clients.infrastructure.in_memory_client_repository import InMemoryClientRepository 15 | from src.gym_passes.facade import GymPassFacade 16 | 17 | 18 | @pytest.fixture() 19 | def s3_mock() -> Iterator[S3SdkClient]: 20 | with mock_s3(): 21 | s3_sdk_client = boto3.client("s3") 22 | s3_sdk_resource = boto3.resource("s3") 23 | bucket_name = "client_reports" 24 | 25 | s3_sdk_client.create_bucket( 26 | Bucket=bucket_name, 27 | CreateBucketConfiguration={"LocationConstraint": "eu-west-1"}, 28 | ) 29 | 30 | yield s3_sdk_client 31 | 32 | s3_sdk_resource.Bucket(bucket_name).objects.all().delete() 33 | s3_sdk_client.delete_bucket(Bucket=bucket_name) 34 | 35 | 36 | @pytest.fixture() 37 | def gym_pass_facade() -> Mock: 38 | return Mock(spec_set=GymPassFacade) 39 | 40 | 41 | @pytest.fixture() 42 | def client_repo() -> IClientRepository: 43 | return InMemoryClientRepository() 44 | 45 | 46 | @pytest.fixture() 47 | def clients_exporter() -> Mock: 48 | return Mock(spec_set=IClientsExporter) 49 | 50 | 51 | @pytest.fixture() 52 | def client_service(client_repo: IClientRepository, gym_pass_facade: Mock, clients_exporter: Mock) -> ClientService: 53 | return ClientService( 54 | client_repo, gym_pass_facade, clients_exporter, Clock.fixed_clock(datetime.now(tz=timezone.utc)) 55 | ) 56 | -------------------------------------------------------------------------------- /tests/integration/clients/test_controllers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from fastapi.testclient import TestClient 4 | from kink import di 5 | 6 | from src.clients.application.client_service import ClientService 7 | from src.clients.domain.client import Client 8 | from src.clients.domain.client_repository import IClientRepository 9 | 10 | 11 | @pytest.fixture() 12 | def setup_di(client_service: ClientService) -> None: 13 | di[ClientService] = client_service 14 | 15 | 16 | @pytest.mark.usefixtures("setup_di") 17 | def test_can_create_client(test_client: TestClient) -> None: 18 | # given 19 | payload = {"first_name": "John", "last_name": "Done", "email": "test@test.com"} 20 | 21 | # when 22 | response = test_client.post("/clients", json=payload) 23 | 24 | # then 25 | assert response.status_code == status.HTTP_201_CREATED 26 | assert "id" in response.json() 27 | assert response.json()["first_name"] == payload["first_name"] 28 | assert response.json()["last_name"] == payload["last_name"] 29 | assert response.json()["email"] == payload["email"] 30 | assert response.json()["status"] == "active" 31 | 32 | 33 | @pytest.mark.usefixtures("setup_di") 34 | def test_can_change_personal_data(test_client: TestClient, client_repo: IClientRepository) -> None: 35 | # given 36 | client = Client.create("John", "Doe", "test@test.com") 37 | client_repo.save(client) 38 | payload = {"first_name": client.first_name, "last_name": client.last_name, "email": "test1@test.com"} 39 | 40 | # when 41 | response = test_client.put(f"/clients/{client.id}", json=payload) 42 | 43 | # then 44 | assert response.status_code == status.HTTP_200_OK 45 | assert "id" in response.json() 46 | assert response.json()["email"] == payload["email"] 47 | 48 | 49 | @pytest.mark.usefixtures("setup_di") 50 | def test_can_archive_client(test_client: TestClient, client_repo: IClientRepository) -> None: 51 | # given 52 | client = Client.create("John", "Doe", "test@test.com") 53 | client_repo.save(client) 54 | 55 | # when 56 | response = test_client.delete(f"/clients/{client.id}") 57 | 58 | # then 59 | assert response.status_code == status.HTTP_204_NO_CONTENT 60 | 61 | 62 | @pytest.mark.usefixtures("setup_di") 63 | def test_can_export_clients(test_client: TestClient, client_repo: IClientRepository) -> None: 64 | # given 65 | client = Client.create("John", "Doe", "test@test.com") 66 | client_repo.save(client) 67 | 68 | # when 69 | response = test_client.post("/clients/exports", json={"format": "CSV"}) 70 | 71 | # then 72 | assert response.status_code == status.HTTP_204_NO_CONTENT 73 | -------------------------------------------------------------------------------- /tests/integration/clients/test_dropbox_clients_exporter.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from unittest.mock import Mock 3 | 4 | from dropbox import Dropbox 5 | 6 | from src.clients.domain.report import Report 7 | from src.clients.infrastructure.dropbox_clients_exporter import DropboxClientsExporter 8 | 9 | 10 | def test_can_export_clients_report() -> None: 11 | # given 12 | dropbox_client_mock = Mock(spec_set=Dropbox) 13 | exporter = DropboxClientsExporter(dropbox_client_mock) 14 | report = Report("test_report.csv", StringIO("first_name, last_name\nJohn, Doe")) 15 | 16 | # when 17 | exporter.export(report) 18 | 19 | # then 20 | dropbox_client_mock.files_upload.assert_called_once() 21 | -------------------------------------------------------------------------------- /tests/integration/clients/test_mongo_client_repository.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pymongo.database import Database 3 | 4 | from src.clients.domain.client import Client 5 | from src.clients.domain.client_id import ClientId 6 | from src.clients.domain.email_address import EmailAddress 7 | from src.clients.domain.errors import ClientNotFound 8 | from src.clients.infrastructure.mongo_client_repository import MongoDBClientRepository 9 | 10 | 11 | def test_can_get_client(mongodb: Database) -> None: 12 | # given 13 | repository = MongoDBClientRepository(mongodb) 14 | client = Client.create("John", "Doe", "test@test.com") 15 | repository.save(client) 16 | 17 | # when 18 | fetched_client = repository.get(client.id) 19 | 20 | # then 21 | assert isinstance(fetched_client, Client) 22 | assert fetched_client.to_snapshot() == client.to_snapshot() 23 | 24 | 25 | def test_should_raise_an_error_if_client_not_found(mongodb: Database) -> None: 26 | # given 27 | repository = MongoDBClientRepository(mongodb) 28 | 29 | # expect 30 | with pytest.raises(ClientNotFound): 31 | repository.get(ClientId.new_one()) 32 | 33 | 34 | def test_can_get_all_clients(mongodb: Database) -> None: 35 | # given 36 | repository = MongoDBClientRepository(mongodb) 37 | for i in range(5): 38 | repository.save(Client.create(f"John {i}", f"Doe {i}", f"test{i}@test.com")) 39 | 40 | # when 41 | clients = repository.get_all() 42 | 43 | # then 44 | assert len(clients) == 5 45 | 46 | 47 | def test_can_save_client(mongodb: Database) -> None: 48 | # given 49 | repository = MongoDBClientRepository(mongodb) 50 | client = Client.create("John", "Doe", "test@test.com") 51 | repository.save(client) 52 | 53 | # when 54 | client.change_email(EmailAddress("newmail@test.com")) 55 | 56 | # and 57 | repository.save(client) 58 | 59 | # then 60 | client = repository.get(client.id) 61 | assert client.email == EmailAddress("newmail@test.com") 62 | -------------------------------------------------------------------------------- /tests/integration/clients/test_s3_clients_exporter.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | import pytest 4 | 5 | from src.building_blocks.custom_types import S3SdkClient 6 | from src.clients.domain.errors import ExportError 7 | from src.clients.domain.report import Report 8 | from src.clients.infrastructure.s3_clients_exporter import S3ClientsExporter 9 | 10 | 11 | def test_can_export_clients_report(s3_mock: S3SdkClient) -> None: 12 | # given 13 | exporter = S3ClientsExporter(s3_mock, "client_reports") 14 | 15 | # when 16 | exporter.export(Report("test_report.csv", StringIO("first_name, last_name\nJohn, Doe"))) 17 | 18 | # then 19 | s3_object = s3_mock.get_object(Bucket="client_reports", Key="reports/test_report.csv") 20 | assert "Body" in s3_object 21 | 22 | 23 | def test_should_raise_an_error_if_upload_fails(s3_mock: S3SdkClient) -> None: 24 | # given 25 | exporter = S3ClientsExporter(s3_mock, "not_existing_bucket") 26 | 27 | # expect 28 | with pytest.raises(ExportError): 29 | exporter.export(Report("test_report.csv", StringIO("first_name, last_name\nJohn, Doe"))) 30 | -------------------------------------------------------------------------------- /tests/integration/clients/test_service.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from src.clients.application.client_service import ClientService 4 | from src.clients.application.dto import ( 5 | ArchiveClientDTO, 6 | ChangeClientPersonalDataDTO, 7 | ClientDTO, 8 | CreateClientDTO, 9 | ExportClientsDTO, 10 | ) 11 | from src.clients.domain.client import Client 12 | from src.clients.domain.client_repository import IClientRepository 13 | 14 | 15 | def test_can_create_client(client_service: ClientService) -> None: 16 | # given 17 | input_dto = CreateClientDTO(first_name="John", last_name="Doe", email="test@test.com") 18 | 19 | # when 20 | result = client_service.create(input_dto) 21 | 22 | # then 23 | assert isinstance(result, ClientDTO) 24 | 25 | 26 | def test_can_change_personal_data(client_service: ClientService, client_repo: IClientRepository) -> None: 27 | # given 28 | client = Client.create("John", "Doe", "test@test.com") 29 | 30 | # when 31 | client_repo.save(client) 32 | 33 | # and 34 | result = client_service.change_personal_data( 35 | ChangeClientPersonalDataDTO( 36 | client_id=str(client.id.value), first_name="Luis", last_name=client.last_name, email=client.email.value 37 | ) 38 | ) 39 | 40 | # then 41 | assert isinstance(result, ClientDTO) 42 | assert result.first_name == "Luis" 43 | 44 | 45 | def test_can_archive_client( 46 | client_service: ClientService, client_repo: IClientRepository, gym_pass_facade: Mock 47 | ) -> None: 48 | # given 49 | client = Client.create("John", "Doe", "test@test.com") 50 | 51 | # when 52 | client_repo.save(client) 53 | 54 | # and 55 | client_service.archive(ArchiveClientDTO(client_id=str(client.id))) 56 | 57 | # then 58 | gym_pass_facade.disable_for.assert_called_once_with(str(client.id)) 59 | 60 | 61 | def test_can_export_clients( 62 | client_service: ClientService, client_repo: IClientRepository, clients_exporter: Mock 63 | ) -> None: 64 | # given 65 | client = Client.create("John", "Doe", "test@test.com") 66 | 67 | # when 68 | client_repo.save(client) 69 | 70 | # and 71 | client_service.export(ExportClientsDTO(format="CSV")) 72 | 73 | # then 74 | clients_exporter.export.assert_called_once() 75 | -------------------------------------------------------------------------------- /tests/integration/gym_classes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/tests/integration/gym_classes/__init__.py -------------------------------------------------------------------------------- /tests/integration/gym_classes/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bson import ObjectId 3 | from pymongo.database import Database 4 | 5 | 6 | @pytest.fixture() 7 | def seed_db(mongodb: Database) -> list[dict[str, str]]: 8 | documents = [ 9 | { 10 | "_id": ObjectId("63500520c1f28686b7d7da2c"), 11 | "name": "Pilates 1", 12 | "day": "Monday", 13 | "time": "9:00-10:00", 14 | "coach": "Alex W.", 15 | "description": "test 1", 16 | }, 17 | { 18 | "_id": ObjectId("6350052ac1f28686b7d7da2d"), 19 | "name": "Cross fit kids", 20 | "day": "Monday", 21 | "time": "8:00-9:00", 22 | "coach": "Alex W.", 23 | "description": "test 1", 24 | }, 25 | { 26 | "_id": ObjectId("63500534c1f28686b7d7da2e"), 27 | "name": "Pilates 2", 28 | "day": "Tuesday", 29 | "time": "9:00-10:00", 30 | "coach": "Alex W.", 31 | "description": "test 2", 32 | }, 33 | { 34 | "_id": ObjectId("6350053dc1f28686b7d7da2f"), 35 | "name": "Kickboxing", 36 | "day": "Tuesday", 37 | "time": "10:00-11:00", 38 | "coach": "Alex W.", 39 | "description": "", 40 | }, 41 | { 42 | "_id": ObjectId("63500544c1f28686b7d7da30"), 43 | "name": "MMA", 44 | "day": "Wednesday", 45 | "time": "10:00-11:00", 46 | "coach": "Alex W.", 47 | "description": "", 48 | }, 49 | ] 50 | mongodb["gym_classes"].insert_many(documents) 51 | 52 | for document in documents: 53 | document["id"] = str(document["_id"]) 54 | del document["_id"] 55 | 56 | return documents # type: ignore 57 | -------------------------------------------------------------------------------- /tests/integration/gym_classes/test_controllers.py: -------------------------------------------------------------------------------- 1 | from bson.objectid import ObjectId 2 | from fastapi import status 3 | from fastapi.testclient import TestClient 4 | 5 | 6 | def test_can_create_gym_class(test_client: TestClient) -> None: 7 | # given 8 | payload = { 9 | "name": "Pilates", 10 | "day": "Monday", 11 | "time": "10:30-11:30", 12 | "coach": "Simon W.", 13 | "description": "Try pilates in our gym", 14 | } 15 | 16 | # when 17 | response = test_client.post("/classes", json=payload) 18 | 19 | # then 20 | assert response.status_code == status.HTTP_201_CREATED 21 | assert "id" in response.json() 22 | 23 | 24 | def test_can_get_gym_class(test_client: TestClient, seed_db: list[dict[str, str]]) -> None: 25 | # when 26 | gym_class_id = seed_db[0]["id"] 27 | response = test_client.get(f"/classes/{gym_class_id}") 28 | 29 | # then 30 | assert response.status_code == status.HTTP_200_OK 31 | assert response.json() == seed_db[0] 32 | 33 | 34 | def test_should_return_404_if_gym_class_does_not_exist(test_client: TestClient, seed_db: list[dict[str, str]]) -> None: 35 | # when 36 | response = test_client.get(f"/classes/{ObjectId()}") 37 | 38 | # then 39 | assert response.status_code == status.HTTP_404_NOT_FOUND 40 | 41 | 42 | def test_can_get_all_gym_classes(test_client: TestClient, seed_db: list[dict[str, str]]) -> None: 43 | # when 44 | response = test_client.get("/classes") 45 | 46 | # then 47 | assert response.status_code == status.HTTP_200_OK 48 | assert len(response.json()) == len(seed_db) 49 | 50 | 51 | def test_can_update_gym_class(test_client: TestClient, seed_db: list[dict[str, str]]) -> None: 52 | # when 53 | gym_class_id = seed_db[0]["id"] 54 | payload = {"name": "Morning start-up", "day": "Monday", "time": "9:00-10:00", "coach": "Nina G.", "description": ""} 55 | response = test_client.put(f"/classes/{gym_class_id}", json=payload) 56 | 57 | # then 58 | assert response.status_code == status.HTTP_200_OK 59 | assert response.json()["name"] == payload["name"] 60 | -------------------------------------------------------------------------------- /tests/integration/gym_classes/test_service.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bson import ObjectId 3 | from pymongo.database import Database 4 | 5 | from src.gym_classes.errors import GymClassNotFound 6 | from src.gym_classes.models import CreateOrUpdateClass, GymClass 7 | from src.gym_classes.service import GymClassService 8 | 9 | 10 | @pytest.mark.usefixtures("seed_db") 11 | def test_can_get_all_sorted_by_day(mongodb: Database) -> None: 12 | # given 13 | service = GymClassService(mongodb) 14 | 15 | # when 16 | result = service.get_all() 17 | 18 | # then 19 | assert len(result) == 5 20 | assert result[0].name == "Cross fit kids" 21 | assert result[4].name == "MMA" 22 | 23 | 24 | @pytest.mark.usefixtures("seed_db") 25 | def test_can_get_one(mongodb: Database) -> None: 26 | # given 27 | service = GymClassService(mongodb) 28 | 29 | # when 30 | gym_class = service.get(ObjectId("63500520c1f28686b7d7da2c")) 31 | 32 | # then 33 | assert isinstance(gym_class, GymClass) 34 | assert gym_class.name == "Pilates 1" 35 | assert gym_class.day == "Monday" 36 | assert gym_class.description == "test 1" 37 | 38 | 39 | @pytest.mark.usefixtures("seed_db") 40 | def test_should_raise_an_error_if_gym_class_does_not_exist(mongodb: Database) -> None: 41 | # given 42 | service = GymClassService(mongodb) 43 | 44 | # expect 45 | with pytest.raises(GymClassNotFound): 46 | service.get(ObjectId()) 47 | 48 | 49 | def test_can_create_new_gym_class(mongodb: Database) -> None: 50 | # given 51 | service = GymClassService(mongodb) 52 | collection = mongodb.get_collection("gym_classes") 53 | 54 | # when 55 | result = service.create(CreateOrUpdateClass(name="Test", day="Monday", time="10:30", coach="John Doe")) 56 | 57 | # then 58 | documents = list(collection.find({})) 59 | assert isinstance(result, GymClass) 60 | assert len(documents) == 1 61 | assert documents[0]["name"] == "Test" 62 | 63 | 64 | @pytest.mark.usefixtures("seed_db") 65 | def test_can_update_gym_class(mongodb: Database) -> None: 66 | # given 67 | service = GymClassService(mongodb) 68 | collection = mongodb.get_collection("gym_classes") 69 | 70 | # when 71 | result = service.update( 72 | "63500520c1f28686b7d7da2c", CreateOrUpdateClass(name="Test", day="Monday", time="10:30", coach="John Doe") 73 | ) 74 | 75 | # then 76 | document = collection.find_one({"_id": ObjectId("63500520c1f28686b7d7da2c")}) 77 | assert isinstance(result, GymClass) 78 | assert result.name == "Test" 79 | assert document["name"] == "Test" # type: ignore 80 | 81 | 82 | @pytest.mark.usefixtures("seed_db") 83 | def test_can_delete(mongodb: Database) -> None: 84 | # given 85 | service = GymClassService(mongodb) 86 | collection = mongodb.get_collection("gym_classes") 87 | 88 | # when 89 | service.delete("63500520c1f28686b7d7da2c") 90 | 91 | # then 92 | documents = list(collection.find({})) 93 | assert len(documents) == 4 94 | assert "63500520c1f28686b7d7da2c" not in [str(document["_id"]) for document in documents] 95 | -------------------------------------------------------------------------------- /tests/integration/gym_passes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/tests/integration/gym_passes/__init__.py -------------------------------------------------------------------------------- /tests/integration/gym_passes/conftest.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from src.building_blocks.clock import Clock 6 | from src.gym_passes.application.gym_pass_service import GymPassService 7 | from src.gym_passes.domain.gym_pass_repository import IGymPassRepository 8 | from src.gym_passes.infrastructure.in_memory_gym_pass_repository import InMemoryGymPassRepository 9 | 10 | 11 | @pytest.fixture() 12 | def gym_pass_repo() -> IGymPassRepository: 13 | return InMemoryGymPassRepository() 14 | 15 | 16 | @pytest.fixture() 17 | def fixed_clock() -> Clock: 18 | return Clock.fixed_clock(datetime.now()) 19 | 20 | 21 | @pytest.fixture() 22 | def gym_pass_service(gym_pass_repo: IGymPassRepository, fixed_clock: Clock) -> GymPassService: 23 | return GymPassService(gym_pass_repo, fixed_clock) 24 | -------------------------------------------------------------------------------- /tests/integration/gym_passes/test_gym_pass_service.py: -------------------------------------------------------------------------------- 1 | from src.building_blocks.clock import Clock 2 | from src.gym_passes.application.dto import CreateGymPassDTO, PauseGymPassDTO 3 | from src.gym_passes.application.gym_pass_service import GymPassService 4 | from src.gym_passes.domain.date_range import DateRange 5 | from src.gym_passes.domain.gym_pass import GymPass 6 | from src.gym_passes.domain.gym_pass_repository import IGymPassRepository 7 | 8 | 9 | def test_can_create_gym_pass(gym_pass_service: GymPassService) -> None: 10 | # when 11 | result = gym_pass_service.create(CreateGymPassDTO(owner="123456789", validity=CreateGymPassDTO.Validity.ONE_MONTH)) 12 | 13 | # then 14 | assert result.is_active 15 | 16 | 17 | def test_can_pause_gym_pass( 18 | gym_pass_service: GymPassService, gym_pass_repo: IGymPassRepository, fixed_clock: Clock 19 | ) -> None: 20 | # given 21 | gym_pass = GymPass.create_for("123456789", DateRange.one_month(fixed_clock), fixed_clock) 22 | gym_pass_repo.save(gym_pass) 23 | 24 | # when 25 | result = gym_pass_service.pause(PauseGymPassDTO(gym_pass_id=str(gym_pass.id), days=14)) 26 | 27 | # then 28 | assert not result.is_active 29 | 30 | 31 | def test_can_renew_gym_pass( 32 | gym_pass_service: GymPassService, gym_pass_repo: IGymPassRepository, fixed_clock: Clock 33 | ) -> None: 34 | # given 35 | gym_pass = GymPass.create_for("123456789", DateRange.one_month(fixed_clock), fixed_clock) 36 | gym_pass_repo.save(gym_pass) 37 | gym_pass_service.pause(PauseGymPassDTO(gym_pass_id=str(gym_pass.id), days=14)) 38 | gym_pass = gym_pass_repo.get(gym_pass.id) 39 | 40 | # when 41 | result = gym_pass_service.renew(str(gym_pass.id)) 42 | 43 | # then 44 | assert result.is_active 45 | -------------------------------------------------------------------------------- /tests/integration/gym_passes/test_mongo_gym_pass_repository.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | from pymongo.database import Database 5 | 6 | from src.building_blocks.clock import Clock 7 | from src.gym_passes.domain.date_range import DateRange 8 | from src.gym_passes.domain.errors import GymPassNotFound 9 | from src.gym_passes.domain.gym_pass import GymPass 10 | from src.gym_passes.domain.gym_pass_id import GymPassId 11 | from src.gym_passes.domain.pause import Pause 12 | from src.gym_passes.infrastructure.mongo_gym_pass_repository import MongoDBGymPassRepository 13 | 14 | 15 | def test_can_get_gym_pass(mongodb: Database) -> None: 16 | # given 17 | repository = MongoDBGymPassRepository(mongodb) 18 | clock = Clock.fixed_clock(datetime(year=2022, month=10, day=27)) 19 | gym_pass = GymPass.create_for("1234", DateRange.one_year(clock), clock) 20 | repository.save(gym_pass) 21 | 22 | # when 23 | fetched_gym_pass = repository.get(gym_pass.id) 24 | 25 | # then 26 | assert isinstance(fetched_gym_pass, GymPass) 27 | assert fetched_gym_pass.to_snapshot() == gym_pass.to_snapshot() 28 | 29 | 30 | def test_should_raise_an_error_if_gym_pass_not_found(mongodb: Database) -> None: 31 | # given 32 | repository = MongoDBGymPassRepository(mongodb) 33 | 34 | # expect 35 | with pytest.raises(GymPassNotFound): 36 | repository.get(GymPassId.new_one()) 37 | 38 | 39 | def test_can_save_gym_pass(mongodb: Database) -> None: 40 | # given 41 | repository = MongoDBGymPassRepository(mongodb) 42 | clock = Clock.fixed_clock(datetime(year=2022, month=10, day=27)) 43 | gym_pass = GymPass.create_for("1234", DateRange.one_year(clock), clock) 44 | repository.save(gym_pass) 45 | 46 | # when 47 | gym_pass.disable() 48 | 49 | # and 50 | repository.save(gym_pass) 51 | 52 | # then 53 | fetched_gym_pass = repository.get(gym_pass.id) 54 | assert fetched_gym_pass.to_snapshot() == gym_pass.to_snapshot() 55 | 56 | 57 | def test_can_save_paused_gym_pass(mongodb: Database) -> None: 58 | # given 59 | repository = MongoDBGymPassRepository(mongodb) 60 | clock = Clock.fixed_clock(datetime(year=2022, month=10, day=27)) 61 | gym_pass = GymPass.create_for("1234", DateRange.one_year(clock), clock) 62 | repository.save(gym_pass) 63 | 64 | # when 65 | gym_pass.pause(Pause(paused_at=clock.get_current_date(), days=7)) 66 | 67 | # and 68 | repository.save(gym_pass) 69 | 70 | # then 71 | fetched_gym_pass = repository.get(gym_pass.id) 72 | assert fetched_gym_pass.to_snapshot() == gym_pass.to_snapshot() 73 | 74 | 75 | def test_can_get_gym_pass_by_owner_id(mongodb: Database) -> None: 76 | # given 77 | repository = MongoDBGymPassRepository(mongodb) 78 | clock = Clock.fixed_clock(datetime(year=2022, month=10, day=27)) 79 | gym_pass = GymPass.create_for("1234", DateRange.one_year(clock), clock) 80 | repository.save(gym_pass) 81 | 82 | # when 83 | fetched_gym_pass = repository.get_by("1234") 84 | 85 | # then 86 | assert isinstance(fetched_gym_pass, GymPass) 87 | assert fetched_gym_pass.to_snapshot() == gym_pass.to_snapshot() 88 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/tests/unit/clients/__init__.py -------------------------------------------------------------------------------- /tests/unit/clients/test_client.py: -------------------------------------------------------------------------------- 1 | from src.clients.domain.client import Client 2 | from src.clients.domain.client_id import ClientId 3 | from src.clients.domain.email_address import EmailAddress 4 | 5 | 6 | def test_can_change_personal_data() -> None: 7 | # given 8 | client = Client.create(first_name="John", last_name="Doe", email="test@test.com") 9 | 10 | # when 11 | client.change_personal_data("Mark", "New") 12 | 13 | # then 14 | assert isinstance(client.id, ClientId) 15 | assert client.is_active() 16 | assert client.first_name == "Mark" 17 | assert client.last_name == "New" 18 | 19 | 20 | def test_can_change_email() -> None: 21 | # given 22 | client = Client.create(first_name="John", last_name="Doe", email="test@test.com") 23 | 24 | # when 25 | new_email_address = EmailAddress("newtest@test.com") 26 | client.change_email(new_email_address) 27 | 28 | # then 29 | assert client.email == new_email_address 30 | 31 | 32 | def test_can_archive() -> None: 33 | # given 34 | client = Client.create(first_name="John", last_name="Doe", email="test@test.com") 35 | 36 | # when 37 | client.archive() 38 | 39 | # then 40 | assert not client.is_active() 41 | -------------------------------------------------------------------------------- /tests/unit/clients/test_client_id.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bson.objectid import ObjectId 3 | 4 | from src.clients.domain.client_id import ClientId 5 | from src.clients.domain.errors import ClientError 6 | 7 | 8 | def test_can_create_client_id_from_valid_str() -> None: 9 | # given 10 | client_id = ClientId.of("6350053dc1f28686b7d7da2f") 11 | 12 | # expect 13 | assert client_id.value == ObjectId("6350053dc1f28686b7d7da2f") 14 | 15 | 16 | def test_should_raise_an_error_if_invalid_id() -> None: 17 | # expect 18 | with pytest.raises(ClientError): 19 | ClientId.of("12345") 20 | 21 | 22 | def test_can_create_new_client_id() -> None: 23 | # expect 24 | assert isinstance(ClientId.new_one(), ClientId) 25 | 26 | 27 | def test_equality() -> None: 28 | assert ClientId.of("636521bae447a90afcfad9af") == ClientId.of("636521bae447a90afcfad9af") 29 | -------------------------------------------------------------------------------- /tests/unit/clients/test_email_address.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from src.clients.domain.email_address import EmailAddress 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "invalid_email_address", ["abc.def@mail.c", "abc.def@mail#archive.com", "abc.def@mail", "abc.def@mail..com"] 8 | ) 9 | def test_can_validate_email_address(invalid_email_address: str) -> None: 10 | with pytest.raises(ValueError): 11 | EmailAddress(invalid_email_address) 12 | -------------------------------------------------------------------------------- /tests/unit/gym_passes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymon6927/hexagonal-architecture-python/ac564424204904485545a6d0692244dcda34a18d/tests/unit/gym_passes/__init__.py -------------------------------------------------------------------------------- /tests/unit/gym_passes/test_date_range.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from src.building_blocks.clock import Clock 4 | from src.gym_passes.domain.date_range import DateRange 5 | 6 | 7 | def test_can_change_if_date_is_with_in_range() -> None: 8 | # given 9 | clock = Clock.fixed_clock(datetime.now()) 10 | one_month = DateRange.one_month(clock) 11 | one_year = DateRange.one_year(clock) 12 | 13 | # expect 14 | assert not one_month.is_within_range(clock.get_current_date() + timedelta(days=50)) 15 | assert one_month.is_within_range(clock.get_current_date() + timedelta(days=12)) 16 | assert not one_year.is_within_range(clock.get_current_date() + timedelta(days=520)) 17 | assert one_year.is_within_range(clock.get_current_date() + timedelta(days=50)) 18 | -------------------------------------------------------------------------------- /tests/unit/gym_passes/test_gym_pass.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | 5 | from src.building_blocks.clock import Clock 6 | from src.gym_passes.domain.date_range import DateRange 7 | from src.gym_passes.domain.errors import GymPassError 8 | from src.gym_passes.domain.gym_pass import GymPass 9 | from src.gym_passes.domain.gym_pass_id import GymPassId 10 | from src.gym_passes.domain.pause import Pause 11 | from src.gym_passes.domain.status import Status 12 | 13 | 14 | def test_can_create_gym_pass() -> None: 15 | # when 16 | clock = Clock.fixed_clock(datetime.now()) 17 | gym_pass = GymPass.create_for("1234", DateRange.one_year(clock), clock) 18 | 19 | # then 20 | assert gym_pass.active is True 21 | assert gym_pass.is_owned_by("1234") 22 | 23 | 24 | def test_can_disable_gym_pass() -> None: 25 | # when 26 | clock = Clock.fixed_clock(datetime.now()) 27 | gym_pass = GymPass.create_for("1234", DateRange.one_year(clock), clock) 28 | 29 | # when 30 | gym_pass.disable() 31 | 32 | # then 33 | assert gym_pass.active is False 34 | 35 | 36 | def test_can_activate_gym_pass() -> None: 37 | # when 38 | clock = Clock.fixed_clock(datetime.now()) 39 | gym_pass = GymPass(GymPassId.new_one(), "1234", Status.disabled, DateRange.one_month(clock), clock, None) 40 | 41 | # when 42 | gym_pass.activate() 43 | 44 | # then 45 | assert gym_pass.active is True 46 | 47 | 48 | def test_can_pause_gym_pass() -> None: 49 | # given 50 | clock = Clock.fixed_clock(datetime.now()) 51 | gym_pass = GymPass.create_for("1234", DateRange.one_year(clock), clock) 52 | 53 | # when 54 | gym_pass.pause(Pause(paused_at=clock.get_current_date(), days=7)) 55 | 56 | # then 57 | assert gym_pass.active is False 58 | 59 | 60 | def test_can_renew_paused_gym_pass() -> None: 61 | # given 62 | clock = Clock.fixed_clock(datetime.now()) 63 | gym_pass = GymPass.create_for("1234", DateRange.one_month(clock), clock) 64 | 65 | # when 66 | gym_pass.pause(Pause(paused_at=clock.get_current_date() + timedelta(days=10), days=7)) 67 | 68 | # and 69 | gym_pass.renew() 70 | 71 | # then 72 | assert gym_pass.active is True 73 | 74 | 75 | def test_can_paused_gym_pass_multiple_times() -> None: 76 | # given 77 | clock = Clock.fixed_clock(datetime.now()) 78 | gym_pass = GymPass.create_for("1234", DateRange.one_month(clock), clock) 79 | 80 | # when 81 | gym_pass.pause(Pause(paused_at=clock.get_current_date() + timedelta(days=10), days=7)) 82 | gym_pass.renew() 83 | 84 | # and 85 | gym_pass.pause(Pause(paused_at=clock.get_current_date() + timedelta(days=15), days=7)) 86 | gym_pass.renew() 87 | 88 | # then 89 | assert gym_pass.active is True 90 | 91 | 92 | def test_can_not_exceed_maximum_pause_limit() -> None: 93 | # given 94 | clock = Clock.fixed_clock(datetime.now()) 95 | gym_pass = GymPass.create_for("1234", DateRange.one_month(clock), clock) 96 | 97 | # when 98 | gym_pass.pause(Pause(paused_at=clock.get_current_date() + timedelta(days=10), days=7)) 99 | gym_pass.renew() 100 | 101 | # and 102 | gym_pass.pause(Pause(paused_at=clock.get_current_date() + timedelta(days=15), days=7)) 103 | gym_pass.renew() 104 | 105 | # and 106 | gym_pass.pause(Pause(paused_at=clock.get_current_date() + timedelta(days=25), days=7)) 107 | gym_pass.renew() 108 | 109 | # then 110 | with pytest.raises(GymPassError): 111 | gym_pass.pause(Pause(paused_at=clock.get_current_date() + timedelta(days=45), days=7)) 112 | -------------------------------------------------------------------------------- /tests/unit/gym_passes/test_gym_pass_id.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bson import ObjectId 3 | 4 | from src.gym_passes.domain.errors import GymPassError 5 | from src.gym_passes.domain.gym_pass_id import GymPassId 6 | 7 | 8 | def test_can_create_new_gym_pass_id() -> None: 9 | # expect 10 | assert isinstance(GymPassId.new_one(), GymPassId) 11 | 12 | 13 | def test_can_create_gym_pass_id_from_valid_str() -> None: 14 | # given 15 | gym_pass_id = GymPassId.of("6350053dc1f28686b7d7da2f") 16 | 17 | # expect 18 | assert gym_pass_id.value == ObjectId("6350053dc1f28686b7d7da2f") 19 | 20 | 21 | def test_should_raise_an_error_if_invalid_id() -> None: 22 | # expect 23 | with pytest.raises(GymPassError): 24 | GymPassId.of("12345") 25 | -------------------------------------------------------------------------------- /tests/unit/gym_passes/test_pause.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from src.gym_passes.domain.errors import PausingError 6 | from src.gym_passes.domain.pause import Pause 7 | 8 | 9 | def test_can_not_create_pause_for_more_than_45_days() -> None: 10 | # expect 11 | with pytest.raises(PausingError): 12 | Pause(paused_at=datetime.now(), days=46) 13 | --------------------------------------------------------------------------------