├── .gitignore
├── .idea
├── .gitignore
├── fastapi-hexagonal.iml
├── inspectionProfiles
│ └── profiles_settings.xml
├── misc.xml
├── modules.xml
└── vcs.xml
├── Dockerfile
├── Makefile
├── README.md
├── app
├── __init__.py
├── application
│ ├── __init__.py
│ ├── services
│ │ ├── __init__.py
│ │ └── product.py
│ └── validators
│ │ ├── __init__.py
│ │ └── product.py
├── domain
│ ├── __init__.py
│ ├── entities
│ │ ├── __init__.py
│ │ └── product.py
│ ├── events
│ │ ├── __init__.py
│ │ └── product.py
│ ├── exceptions.py
│ ├── repositories
│ │ ├── __init__.py
│ │ └── product.py
│ └── use_cases
│ │ ├── __init__.py
│ │ └── product.py
├── infrastructure
│ ├── __init__.py
│ ├── container.py
│ ├── events
│ │ ├── __init__.py
│ │ └── product.py
│ ├── fast_api.py
│ ├── handlers
│ │ ├── __init__.py
│ │ └── products.py
│ ├── repositories
│ │ ├── __init__.py
│ │ └── product.py
│ └── schemas
│ │ ├── __init__.py
│ │ └── product.py
├── main.py
└── test
│ ├── __init__.py
│ ├── data
│ ├── __init__.py
│ └── product.py
│ ├── fastapi
│ ├── __init__.py
│ └── test_product_api.py
│ └── services
│ ├── __init__.py
│ └── test_product.py
├── architecture.png
└── requirements.txt
/.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 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
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 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | #.idea/
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 | # Datasource local storage ignored files
7 | /dataSources/
8 | /dataSources.local.xml
9 |
--------------------------------------------------------------------------------
/.idea/fastapi-hexagonal.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.11-alpine
2 |
3 | WORKDIR /code
4 |
5 | COPY requirements.txt /requirements.txt
6 |
7 | ADD ./app /code/app
8 |
9 | RUN pip install -r /requirements.txt
10 |
11 | EXPOSE 8000
12 |
13 | CMD ["uvicorn", "app.main:application", "--reload", "--host", "0.0.0.0", "--port", "8000"]
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | IMAGE = devlusaja-fastapi:latest
2 | CONTAINER = fastapi_container
3 |
4 | build:
5 | @docker build -t ${IMAGE} .
6 |
7 | up:
8 | @docker run -d --name ${CONTAINER} -p 8000:8000 -v ${PWD}/app:/code/app ${IMAGE}
9 | @make log
10 |
11 | down:
12 | @docker rm -f ${CONTAINER}
13 |
14 | log:
15 | @docker logs -f ${CONTAINER}
16 |
17 | test:
18 | @docker run --rm -it --name ${CONTAINER}-test -v ${PWD}/app:/code/app ${IMAGE} pytest -vv -s -p no:cacheprovider
19 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hexagonal architecture example with Python
2 |
3 | Example of a microservice designed following the definition of hexagonal architecture.
4 | Depending on the size of our system, there may be more sub-layers within the application, domain or infrastructure.
5 | So it is requested to take it as a base.
6 |
7 | **Let's explain architecture**
8 |
9 | * Principal layers:
10 | * Domain
11 | * Application
12 | * Infrastructure
13 | * Test
14 |
15 | > Domain:
16 | > * In this layer we place all the business logic, such as:
17 | > - Entities with business rules.
18 | > - Interfaces or ports that define the behavior of use cases, repositories or events that are then implemented by the application and infrastructure layers.
19 | > - It is important to highlight that in this layer we should not use 3rd party technologies
20 | >
21 | > Application:
22 | > * In this layer we put all the functionality that is related to our application, for example:
23 | > - Services which implement the use cases defined in the domain layer
24 | > - Data validations that allow verifying if the input data is correct, such as character lengths.
25 | >
26 | > Infrastructure:
27 | > * In this layer we place the input and output adapters, which implement the interfaces defined for the repositories or business events. For example:
28 | > - Repository that stores the data in a database.
29 | > - Events that communicate with some queuing engine.
30 | > - Handlers that serve as the entry point and that call the application services.
31 | > - In this layer we can implement any 3rd party library.
32 | >
33 | > Test:
34 | > * In this directory we will place all the tests of our application.
35 |
36 | 
37 |
38 | * Structure
39 |
40 | ```bash
41 | ├── app
42 | │ ├── application
43 | │ │ ├── validators # Validation logic for input data
44 | │ │ └── services # Implement the use cases
45 | │ ├── domain
46 | │ │ ├── entities # Entities with business logic
47 | │ │ ├── events # Interface that defines how an event behaves
48 | │ │ ├── repositories # Interface that defines how a data repository behaves
49 | │ │ ├── use cases # Interface that defines the use cases of the application
50 | │ │ └── exceptions # Domain exceptions
51 | │ ├── infrastructure
52 | │ │ ├── events # Implements the domain event interface
53 | │ │ ├── handlers # Application entry point
54 | │ │ ├── repositories # Implements the domain repository interface
55 | │ │ ├── schemas # Data structures used as input and output
56 | │ │ └── container # Dependency injector
57 | └───└── test
58 | ```
59 |
60 | **Technologies**
61 |
62 | - python
63 | - fastapi
64 | - dependency-injector
65 | - pytest
66 | - pydantic
67 | - docker
68 | - make
69 |
70 | **Commands**
71 | ```bash
72 | $ make build #Create docker images
73 | ```
74 |
75 | ```bash
76 | $ make up #Run the docker container
77 | ```
78 |
79 | ```bash
80 | $ make down #Stop the docker container
81 | ```
82 |
83 | **References**
84 |
85 | - https://alistair.cockburn.us/hexagonal-architecture/
86 | - http://wiki.c2.com/?HexagonalArchitecture
--------------------------------------------------------------------------------
/app/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/__init__.py
--------------------------------------------------------------------------------
/app/application/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/application/__init__.py
--------------------------------------------------------------------------------
/app/application/services/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/application/services/__init__.py
--------------------------------------------------------------------------------
/app/application/services/product.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 | from app.domain.use_cases.product import ProductUseCases
3 | from app.domain.entities.product import ProductEntity
4 | from app.domain.events.product import ProductCreatedEvent, ProductUpdatedEvent
5 | from app.application.validators.product import ProductValidator
6 | from app.domain.repositories.product import ProductRepository
7 |
8 |
9 | class ProductService(ProductUseCases):
10 |
11 | __media_url = 'https://devlusaja.com/{}'
12 |
13 | def __init__(self, product_repository: ProductRepository,
14 | product_created_event: ProductCreatedEvent,
15 | product_updated_event: ProductUpdatedEvent
16 | ):
17 | super().__init__(product_repository, product_created_event, product_updated_event)
18 |
19 | def products_catalog(self) -> List[ProductEntity]:
20 | products = self.product_repository.get_all()
21 | for product in products:
22 | product.image = self.__media_url.format(product.image)
23 | return products
24 |
25 | def product_detail(self, id: str) -> ProductEntity:
26 | product = self.product_repository.get_by_id(id)
27 | product.image = self.__media_url.format(product.image)
28 | return product
29 |
30 | def register_product(self, product: ProductEntity) -> ProductEntity:
31 | ProductValidator.validate_price_is_float(product.price)
32 | ProductValidator.validate_description_len(product.description)
33 |
34 | product = self.product_repository.add(product)
35 | product.image = self.__media_url.format(product.image)
36 |
37 | self.product_created_event.send(product)
38 |
39 | return product
40 |
41 | def update_product(self, product: ProductEntity) -> ProductEntity:
42 | ProductValidator.validate_price_is_float(product.price)
43 | ProductValidator.validate_description_len(product.description)
44 |
45 | product = self.product_repository.update(product)
46 | product.image = self.__media_url.format(product.image)
47 |
48 | self.product_updated_event.send(product)
49 |
50 | return product
51 |
--------------------------------------------------------------------------------
/app/application/validators/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/application/validators/__init__.py
--------------------------------------------------------------------------------
/app/application/validators/product.py:
--------------------------------------------------------------------------------
1 | from app.domain.exceptions import InvalidPrice, InvalidDescription
2 |
3 |
4 | class ProductValidator:
5 |
6 | @staticmethod
7 | def validate_price_is_float(price: float) -> float:
8 | try:
9 | float(price)
10 | except ValueError:
11 | raise InvalidPrice
12 | return float(price)
13 |
14 | @staticmethod
15 | def validate_description_len(description: str) -> None:
16 | if len(description) > 50:
17 | raise InvalidDescription
18 |
--------------------------------------------------------------------------------
/app/domain/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/domain/__init__.py
--------------------------------------------------------------------------------
/app/domain/entities/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/domain/entities/__init__.py
--------------------------------------------------------------------------------
/app/domain/entities/product.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from app.domain.exceptions import PriceIsLessThanOrEqualToZero, StockIsLessThanOrEqualToZero
3 |
4 |
5 | class ProductEntity:
6 |
7 | def __init__(self, uid: str, name: str, description: str, price: float, stock: int, image: str):
8 | self.__validate_price(price)
9 | self.__validate_stock(stock)
10 |
11 | self.id = uid
12 | self.name = name
13 | self.description = description
14 | self.price = price
15 | self.stock = stock
16 | self.image = image
17 |
18 | @staticmethod
19 | def __validate_price(price: float):
20 | if price <= 0:
21 | raise PriceIsLessThanOrEqualToZero
22 |
23 | @staticmethod
24 | def __validate_stock(stock: int):
25 | if stock <= 0:
26 | raise StockIsLessThanOrEqualToZero
27 |
28 | class ProductEntityFactory:
29 |
30 | @staticmethod
31 | def create(id: str|None, name: str, description: str, price: float, stock: int, image: str) -> ProductEntity:
32 | if id is None:
33 | id = uuid.uuid4().__str__()
34 | return ProductEntity(id, name, description, float(price), int(stock), image)
35 |
--------------------------------------------------------------------------------
/app/domain/events/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/domain/events/__init__.py
--------------------------------------------------------------------------------
/app/domain/events/product.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from app.domain.entities.product import ProductEntity
3 |
4 | class ProductCreatedEvent(ABC):
5 |
6 | @abstractmethod
7 | def send(self, product: ProductEntity) -> bool:
8 | raise NotImplemented
9 |
10 | class ProductUpdatedEvent(ABC):
11 |
12 | @abstractmethod
13 | def send(self, product: ProductEntity) -> bool:
14 | raise NotImplemented
15 |
--------------------------------------------------------------------------------
/app/domain/exceptions.py:
--------------------------------------------------------------------------------
1 | class InvalidDescription(Exception):
2 | def __init__(self):
3 | super().__init__('The description must have less than 50 characters')
4 |
5 | class InvalidPrice(Exception):
6 | def __init__(self):
7 | super().__init__('The price is not a valid number')
8 |
9 | class PriceIsLessThanOrEqualToZero(Exception):
10 | def __init__(self):
11 | super().__init__('The price is less than or equal to zero')
12 |
13 | class StockIsLessThanOrEqualToZero(Exception):
14 | def __init__(self):
15 | super().__init__('The stock is less than or equal to zero')
--------------------------------------------------------------------------------
/app/domain/repositories/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/domain/repositories/__init__.py
--------------------------------------------------------------------------------
/app/domain/repositories/product.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from typing import List
3 |
4 | from app.domain.entities.product import ProductEntity
5 |
6 |
7 | class ProductRepository(ABC):
8 |
9 | @abstractmethod
10 | def get_all(self) -> List[ProductEntity]:
11 | raise NotImplemented
12 |
13 | @abstractmethod
14 | def get_by_id(self, id: str) -> ProductEntity:
15 | raise NotImplemented
16 |
17 | @abstractmethod
18 | def add(self, product: ProductEntity) -> ProductEntity:
19 | raise NotImplemented
20 |
21 | @abstractmethod
22 | def update(self, product: ProductEntity) -> ProductEntity:
23 | raise NotImplemented
24 |
--------------------------------------------------------------------------------
/app/domain/use_cases/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/domain/use_cases/__init__.py
--------------------------------------------------------------------------------
/app/domain/use_cases/product.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from typing import List
3 | from app.domain.entities.product import ProductEntity
4 | from app.domain.events.product import ProductCreatedEvent, ProductUpdatedEvent
5 | from app.domain.repositories.product import ProductRepository
6 |
7 |
8 | class ProductUseCases(ABC):
9 |
10 | @abstractmethod
11 | def __init__(self, product_repository: ProductRepository,
12 | product_created_event: ProductCreatedEvent,
13 | product_updated_event: ProductUpdatedEvent
14 | ):
15 | self.product_repository = product_repository
16 | self.product_created_event = product_created_event
17 | self.product_updated_event = product_updated_event
18 |
19 | @abstractmethod
20 | def products_catalog(self) -> List[ProductEntity]:
21 | raise NotImplemented
22 |
23 | @abstractmethod
24 | def product_detail(self, id: str) -> ProductEntity:
25 | raise NotImplemented
26 |
27 | @abstractmethod
28 | def register_product(self, product: ProductEntity) -> ProductEntity:
29 | raise NotImplemented
30 |
31 | @abstractmethod
32 | def update_product(self, product: ProductEntity) -> ProductEntity:
33 | raise NotImplemented
34 |
--------------------------------------------------------------------------------
/app/infrastructure/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/infrastructure/__init__.py
--------------------------------------------------------------------------------
/app/infrastructure/container.py:
--------------------------------------------------------------------------------
1 | from dependency_injector import containers, providers
2 | from app.domain.entities.product import ProductEntityFactory
3 | from app.infrastructure.events.product import ProductCreatedQueueEvent, ProductUpdatedQueueEvent
4 | from app.infrastructure.handlers import Handlers
5 | from app.infrastructure.repositories.product import ProductInMemoryRepository
6 | from app.application.services.product import ProductService
7 |
8 |
9 | class Container(containers.DeclarativeContainer):
10 |
11 | #loads all handlers where @injects are set
12 | wiring_config = containers.WiringConfiguration(modules=Handlers.modules())
13 |
14 | #Factories
15 | product_factory = providers.Factory(ProductEntityFactory)
16 |
17 | #Repositories
18 | product_repository = providers.Singleton(ProductInMemoryRepository)
19 |
20 | #Events
21 | product_created_event = providers.Factory(ProductCreatedQueueEvent)
22 | product_updated_event = providers.Factory(ProductUpdatedQueueEvent)
23 |
24 | #Services
25 | product_services = providers.Factory(ProductService, product_repository, product_created_event, product_updated_event)
26 |
--------------------------------------------------------------------------------
/app/infrastructure/events/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/infrastructure/events/__init__.py
--------------------------------------------------------------------------------
/app/infrastructure/events/product.py:
--------------------------------------------------------------------------------
1 | from app.domain.entities.product import ProductEntity
2 | from app.domain.events.product import ProductCreatedEvent, ProductUpdatedEvent
3 |
4 |
5 | class ProductCreatedQueueEvent(ProductCreatedEvent):
6 |
7 | def send(self, product: ProductEntity):
8 | # TODO: Your code here
9 | return True
10 |
11 | class ProductUpdatedQueueEvent(ProductUpdatedEvent):
12 |
13 | def send(self, product: ProductEntity):
14 | # TODO: Your code here
15 | return True
16 |
--------------------------------------------------------------------------------
/app/infrastructure/fast_api.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 | from app.infrastructure.container import Container
3 | from app.infrastructure.handlers import Handlers
4 |
5 |
6 | def create_app():
7 | fast_api = FastAPI()
8 | fast_api.container = Container()
9 | for handler in Handlers.iterator():
10 | fast_api.include_router(handler.router)
11 | return fast_api
12 |
--------------------------------------------------------------------------------
/app/infrastructure/handlers/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 | from types import ModuleType
3 | from typing import Iterator
4 |
5 |
6 | class Handlers:
7 | handlers_base_path = ('app', 'infrastructure', 'handlers')
8 | ignored = ('__init__.py', '__pycache__')
9 |
10 | @classmethod
11 | def __all_module_names(cls) -> list:
12 | return list(
13 | filter(
14 | lambda module: module not in cls.ignored, os.listdir('/'.join(cls.handlers_base_path))
15 | )
16 | )
17 |
18 | @classmethod
19 | def __module_namespace(cls, handler_name: str) -> str:
20 | return '%s.%s' % ('.'.join(cls.handlers_base_path), handler_name)
21 |
22 | @classmethod
23 | def iterator(cls) -> Iterator[ModuleType]:
24 | for module in cls.__all_module_names():
25 | import importlib
26 | handler = importlib.import_module(cls.__module_namespace(module[:-3]))
27 | yield handler
28 |
29 | @classmethod
30 | def modules(cls) -> map:
31 | return map(
32 | lambda module: cls.__module_namespace(module[:-3]), cls.__all_module_names()
33 | )
34 |
--------------------------------------------------------------------------------
/app/infrastructure/handlers/products.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 | from dependency_injector.wiring import inject, Provide
3 | from fastapi import APIRouter, Depends
4 | from app.domain.entities.product import ProductEntity, ProductEntityFactory
5 | from app.infrastructure.container import Container
6 | from app.application.services.product import ProductService
7 | from app.infrastructure.schemas.product import ProductOutput, ProductInput
8 |
9 | router = APIRouter(
10 | prefix='/products',
11 | tags=['products']
12 | )
13 |
14 | @router.get('/', response_model=List[ProductOutput])
15 | @inject
16 | def get_catalog(product_services: ProductService = Depends(Provide[Container.product_services])) -> List[dict]:
17 | response: List[ProductEntity] = product_services.products_catalog()
18 | return [product_entity.__dict__ for product_entity in response]
19 |
20 | @router.get('/{id}', response_model=ProductOutput)
21 | @inject
22 | def get_description(id: str, product_services: ProductService = Depends(Provide[Container.product_services])) -> dict:
23 | response: ProductEntity = product_services.product_detail(id)
24 | return response.__dict__
25 |
26 | @router.post('/', response_model=ProductOutput)
27 | @inject
28 | def register_product(
29 | product: ProductInput,
30 | product_factory: ProductEntityFactory = Depends(Provide[Container.product_factory]),
31 | product_services: ProductService = Depends(Provide[Container.product_services])
32 | ) -> dict:
33 | name, description, price, stock, image = product.__dict__.values()
34 | product_entity: ProductEntity = product_factory.create(None, name, description, price, stock, image)
35 | response: ProductEntity = product_services.register_product(product_entity)
36 | return response.__dict__
37 |
38 | @router.put('/{id}', response_model=ProductOutput)
39 | @inject
40 | def update_product(
41 | id: str,
42 | product: ProductInput,
43 | product_factory: ProductEntityFactory = Depends(Provide[Container.product_factory]),
44 | product_services: ProductService = Depends(Provide[Container.product_services])
45 | ) -> dict:
46 | name, description, price, stock, image = product.__dict__.values()
47 | product_entity: ProductEntity = product_factory.create(id, name, description, price, stock, image)
48 | response: ProductEntity = product_services.update_product(product_entity)
49 | return response.__dict__
50 |
--------------------------------------------------------------------------------
/app/infrastructure/repositories/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/infrastructure/repositories/__init__.py
--------------------------------------------------------------------------------
/app/infrastructure/repositories/product.py:
--------------------------------------------------------------------------------
1 | from copy import copy
2 | from typing import List
3 | from app.domain.entities.product import ProductEntityFactory, ProductEntity
4 | from app.domain.repositories.product import ProductRepository
5 |
6 |
7 | class ProductInMemoryRepository(ProductRepository):
8 |
9 | products: List[dict] = [
10 | {'id': '3f996431-e90e-4d12-b2be-5614959c0202', 'name': 'milk', 'description': 'skimmed cows milk', 'price': 10.50, 'stock': 1, 'image': 'milk.jpg'},
11 | {'id': '3f996431-e90e-4d12-b2be-5614959c0201', 'name': 'meat', 'description': 'beef licence', 'price': 20.50, 'stock': 2, 'image': 'meat.jpg'}
12 | ]
13 |
14 | def get_all(self) -> List[ProductEntity]:
15 | return [ProductEntityFactory.create(**product) for product in self.products]
16 |
17 | def get_by_id(self, id: str) -> ProductEntity|None:
18 | try:
19 | product = next(filter(lambda p: p['id'] == id, self.products))
20 | return ProductEntityFactory.create(**product)
21 | except StopIteration:
22 | return None
23 |
24 | def add(self, product: ProductEntity) -> ProductEntity:
25 | self.products.append(copy(product.__dict__))
26 | return product
27 |
28 | def update(self, product: ProductEntity) -> ProductEntity:
29 | for key, value in enumerate(self.products):
30 | if value['id'] == product.id:
31 | self.products[key] = copy(product.__dict__)
32 | return product
33 |
--------------------------------------------------------------------------------
/app/infrastructure/schemas/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/infrastructure/schemas/__init__.py
--------------------------------------------------------------------------------
/app/infrastructure/schemas/product.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 |
4 | class ProductInput(BaseModel):
5 | name: str
6 | description: str
7 | price: float
8 | stock: int
9 | image: str
10 |
11 | class ProductOutput(BaseModel):
12 | id: str
13 | name: str
14 | description: str
15 | price: float
16 | stock: int
17 | image: str
18 |
--------------------------------------------------------------------------------
/app/main.py:
--------------------------------------------------------------------------------
1 | from app.infrastructure.fast_api import create_app
2 |
3 | application = create_app()
4 |
--------------------------------------------------------------------------------
/app/test/__init__.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import Mock, patch
2 | from app.domain.entities.product import ProductEntityFactory
3 | from app.domain.repositories.product import ProductRepository
4 | from app.test.data.product import expected_products_catalog, expected_product_description
5 |
6 |
7 | product_data = {'id': 'MockID', 'name': 'MockProduct', 'description': 'MockDescription', 'price': 10, 'stock': 1, 'image': 'MockImage.jpg'}
8 | product_repository_get_all = [product_data for x in range(2)]
9 | product_repository_get_by_id = product_data
10 |
11 | @patch('app.infrastructure.repositories.product.ProductInMemoryRepository', spec=True)
12 | def create_mock_product_repository(mock_repository: Mock):
13 | mock_product_repo: ProductRepository = mock_repository.return_value
14 | mock_product_repo.get_all = Mock(return_value=[ProductEntityFactory.create(**product) for product in product_repository_get_all])
15 | mock_product_repo.get_by_id = Mock(return_value=ProductEntityFactory.create(**product_repository_get_by_id))
16 | mock_product_repo.add = Mock(return_value=ProductEntityFactory.create(**product_repository_get_by_id))
17 | mock_product_repo.update = Mock(return_value=ProductEntityFactory.create(**product_repository_get_by_id))
18 | return mock_product_repo
19 |
--------------------------------------------------------------------------------
/app/test/data/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/test/data/__init__.py
--------------------------------------------------------------------------------
/app/test/data/product.py:
--------------------------------------------------------------------------------
1 | product_data = {'id': 'MockID', 'name': 'MockProduct', 'description': 'MockDescription', 'price': 10, 'stock': 1, 'image': 'https://devlusaja.com/MockImage.jpg'}
2 | expected_products_catalog = [product_data for x in range(2)]
3 | expected_product_description = product_data
4 |
--------------------------------------------------------------------------------
/app/test/fastapi/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/test/fastapi/__init__.py
--------------------------------------------------------------------------------
/app/test/fastapi/test_product_api.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from app.infrastructure.fast_api import create_app
3 | from fastapi.testclient import TestClient
4 | from app.test import create_mock_product_repository, expected_products_catalog, expected_product_description
5 |
6 |
7 | class TestUserApi:
8 |
9 | @pytest.fixture(autouse=True)
10 | def setup(self):
11 | self.app = create_app()
12 | self.client = TestClient(self.app)
13 | self.base_path = '/products'
14 |
15 | def test_get_product_catalog(self):
16 | with self.app.container.product_repository.override(create_mock_product_repository()):
17 | response = self.client.get(self.base_path + '/')
18 | assert expected_products_catalog == response.json()
19 | assert response.status_code == 200
20 |
21 | def test_get_product_detail(self):
22 | with self.app.container.product_repository.override(create_mock_product_repository()):
23 | response = self.client.get(self.base_path + '/mock_id')
24 | assert expected_product_description == response.json()
25 | assert response.status_code == 200
26 |
27 | def test_post_register_product(self):
28 | with self.app.container.product_repository.override(create_mock_product_repository()):
29 | response = self.client.post(
30 | self.base_path,
31 | json=expected_product_description
32 | )
33 | assert expected_product_description == response.json()
34 | assert response.status_code == 200
35 |
36 | def test_put_update_product(self):
37 | with self.app.container.product_repository.override(create_mock_product_repository()):
38 | response = self.client.put(
39 | self.base_path + '/mock_id',
40 | json=expected_product_description
41 | )
42 | assert expected_product_description == response.json()
43 | assert response.status_code == 200
--------------------------------------------------------------------------------
/app/test/services/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/test/services/__init__.py
--------------------------------------------------------------------------------
/app/test/services/test_product.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from app.application.services.product import ProductService
3 | from app.domain.entities.product import ProductEntity, ProductEntityFactory
4 | from app.infrastructure.container import Container
5 | from app.test import create_mock_product_repository
6 | from app.test.data.product import expected_product_description
7 |
8 |
9 | class TestProductService:
10 |
11 | """
12 | this fixture fulfills the function of dependency injection for all tests
13 | """
14 | @pytest.fixture(autouse=True)
15 | def injector(self):
16 | container = Container()
17 | with container.product_repository.override(create_mock_product_repository()):
18 | self.product_service: ProductService = container.product_services()
19 | self.product_factory: ProductEntityFactory = container.product_factory()
20 |
21 | def test_that_all_products_in_the_catalog_are_an_entity(self):
22 | catalog = self.product_service.products_catalog()
23 | for product in catalog:
24 | assert type(product) is ProductEntity
25 |
26 | def test_that_product_detail_is_an_entity(self):
27 | product = self.product_service.product_detail('mock_id')
28 | assert type(product) is ProductEntity
29 |
30 | def test_product_name_is_correct(self):
31 | product = self.product_service.product_detail('mock_id')
32 | assert product.name == expected_product_description['name']
33 |
34 | def test_register_product(self):
35 | uid, name, description, price, stock, image = expected_product_description.values()
36 | product_entity = self.product_factory.create(None, name, description, float(price), int(stock), image)
37 | product = self.product_service.register_product(product_entity)
38 | assert type(product) is ProductEntity
39 | assert product.id == uid
40 |
41 | def test_update_product(self):
42 | uid, name, description, price, stock, image = expected_product_description.values()
43 | product_entity = self.product_factory.create(uid, name, description, price, stock, image)
44 | product = self.product_service.update_product(product_entity)
45 | assert type(product) is ProductEntity
46 |
--------------------------------------------------------------------------------
/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/architecture.png
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | fastapi==0.93.0
2 | uvicorn[standard]==0.20.0
3 | dependency-injector==4.41.0
4 | pytest==7.2.2
5 | httpx==0.23.3
6 | pydantic==1.10.6
--------------------------------------------------------------------------------