├── .gitignore ├── Makefile ├── README.md ├── demo-push-message ├── Dockerfile ├── main.py └── requirements.txt ├── docker-compose.yml ├── imgs └── cover.png ├── inventory ├── adapter │ └── all_requested_orders │ │ ├── memory.py │ │ └── mongo.py ├── domain.requirements.txt ├── domain │ ├── __init__.py │ ├── base │ │ ├── __init__.py │ │ ├── aggregate.py │ │ ├── event.py │ │ ├── repository.py │ │ └── singleton.py │ ├── model │ │ ├── __init__.py │ │ └── requested_order │ │ │ ├── __init__.py │ │ │ ├── event.py │ │ │ ├── repository │ │ │ ├── __init__.py │ │ │ ├── exception.py │ │ │ └── repository.py │ │ │ └── requested_order.py │ ├── port.py │ ├── registry.py │ └── usecase.py ├── infrastructure.requirements.txt ├── infrastructure │ ├── kafka │ │ ├── __init__.py │ │ ├── config.py │ │ └── kafka.py │ └── mongo │ │ ├── __init__.py │ │ ├── config.py │ │ └── mongo.py ├── message-handler │ ├── dependency.py │ └── main.py ├── msg.Dockerfile ├── rest.Dockerfile ├── rest.requirements.txt └── rest │ ├── __init__.py │ ├── dependency.py │ ├── event_handler.py │ ├── main.py │ └── schema.py ├── kowl.yml └── ordering ├── adapter └── all_orders │ ├── memory.py │ └── mongo.py ├── domain.requirements.txt ├── domain ├── __init__.py ├── base │ ├── __init__.py │ ├── aggregate.py │ ├── event.py │ ├── repository.py │ └── singleton.py ├── model │ ├── __init__.py │ └── order │ │ ├── __init__.py │ │ ├── event.py │ │ ├── exception.py │ │ ├── order.py │ │ └── repository │ │ ├── __init__.py │ │ ├── exception.py │ │ └── repository.py ├── port.py ├── registry.py └── usecase.py ├── infrastructure.requirements.txt ├── infrastructure ├── kafka │ ├── __init__.py │ ├── config.py │ └── kafka.py └── mongo │ ├── __init__.py │ ├── config.py │ └── mongo.py ├── relay.Dockerfile ├── relay ├── change.py ├── dependency.py ├── main.py └── memory.py ├── rest.Dockerfile ├── rest.requirements.txt └── rest ├── __init__.py ├── dependency.py ├── endpoint.py ├── event_handler.py ├── main.py └── schema.py /.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 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | .idea/ 153 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | up: 2 | docker-compose up -d --build 3 | 4 | down: 5 | docker-compose down 6 | 7 | log-ordering: 8 | docker logs -f ordering-service 9 | 10 | log-ordering-relay: 11 | docker logs -f ordering-relay 12 | 13 | log-inventory: 14 | docker logs -f inventory-service 15 | 16 | log-inventory-handler: 17 | docker logs -f inventory-msg-handler 18 | 19 | push-message: 20 | docker run -it --mount type=bind,source=$(shell pwd)/demo-push-message/main.py,target=/app/main.py \ 21 | --network=MyNetwork demo-push-message 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Microservice Architecture Demo 2 | ## โดย [Copy Paste Engineer](https://www.facebook.com/CopyPasteEng) 3 | 4 | ![](imgs/cover.png) 5 | 6 | คลิปเนื้อหา: [ออกแบบ Microservices ด้วย Domain Driven Design #3 - Architecture Overview](https://youtu.be/EBjfiuJsYe4) 7 | 8 | ### Install 9 | ติดตั้ง library สำหรับ `ordering service` และ `inventory service` 10 | 11 | ```bash 12 | cd ordering/ 13 | pip install -r domain.requirements.txt 14 | pip install -r infrastructure.requirements.txt 15 | pip install -r rest.requirements.txt 16 | 17 | cd ../inventory/ 18 | pip install -r domain.requirements.txt 19 | pip install -r infrastructure.requirements.txt 20 | pip install -r rest.requirements.txt 21 | ``` 22 | 23 | ### วิธีรัน 24 | ไปที่ folder นอกสุดของ project แล้วรันด้วย `docker-compose` 25 | 26 | ```bash 27 | docker-compose up -d 28 | ``` 29 | 30 | ### วิธีปิด 31 | 32 | ไปที่ folder นอกสุดของ project แล้วปิดการทำงานด้วย `docker-compose` 33 | 34 | ```bash 35 | docker-compose down 36 | ``` 37 | -------------------------------------------------------------------------------- /demo-push-message/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | COPY requirements.txt /requirements.txt 4 | RUN pip install -r /requirements.txt 5 | 6 | COPY . /app/ 7 | 8 | ENV PYTHONPATH /app/ 9 | ENV PYTHONUNBUFFERED 1 10 | 11 | CMD python /app/main.py 12 | -------------------------------------------------------------------------------- /demo-push-message/main.py: -------------------------------------------------------------------------------- 1 | import json 2 | from kafka import KafkaProducer 3 | 4 | 5 | def get_kafka_producer() -> KafkaProducer: 6 | producer = KafkaProducer(bootstrap_servers=['kafka:9092'], 7 | value_serializer=lambda v: json.dumps(v).encode('utf-8')) 8 | return producer 9 | 10 | 11 | def main(): 12 | producer = get_kafka_producer() 13 | topic = 'Ordering.OrderSubmittedEvent' 14 | data = { 15 | "id_": "OR-619be7adda6893a6c84c5809", 16 | "items": [ 17 | { 18 | "product_id": "string", 19 | "amount": 7777 20 | } 21 | ], 22 | "customer_id": "string", 23 | "_event_type": "OrderSubmittedEvent", 24 | "version": 2 25 | } 26 | producer.send(topic, data) 27 | 28 | 29 | if __name__ == '__main__': 30 | main() 31 | -------------------------------------------------------------------------------- /demo-push-message/requirements.txt: -------------------------------------------------------------------------------- 1 | kafka-python>=2.0.2,<2.1.0 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | services: 4 | 5 | # ordering service - rest api 6 | ordering-service: 7 | container_name: ordering-service 8 | build: 9 | context: ./ordering/ 10 | dockerfile: rest.Dockerfile 11 | restart: always 12 | depends_on: 13 | - ordering-mongodb 14 | networks: 15 | - default 16 | environment: 17 | PORT: '8080' 18 | MONGO_SERVER: ordering-mongodb 19 | MONGO_PORT: '27017' 20 | MONGO_USERNAME: root 21 | MONGO_PASSWORD: admin 22 | ports: 23 | - '8080:8080' 24 | volumes: 25 | - ./ordering/domain:/app/domain 26 | - ./ordering/rest:/app/rest 27 | 28 | # ordering service - message relay 29 | ordering-relay: 30 | container_name: ordering-relay 31 | build: 32 | context: ./ordering/ 33 | dockerfile: relay.Dockerfile 34 | restart: always 35 | depends_on: 36 | - ordering-mongodb 37 | networks: 38 | - default 39 | environment: 40 | MONGO_SERVER: ordering-mongodb 41 | MONGO_PORT: '27017' 42 | MONGO_USERNAME: root 43 | MONGO_PASSWORD: admin 44 | KAFKA_SERVER: kafka 45 | KAFKA_PORT: '9092' 46 | 47 | # ordering service - mongodb ui 48 | # reference: https://hub.docker.com/_/mongo-express 49 | ordering-mongodb-admin: 50 | container_name: ordering-mongodb-admin 51 | image: mongo-express:0.54 52 | restart: always 53 | depends_on: 54 | - ordering-mongodb 55 | networks: 56 | - default 57 | environment: 58 | ME_CONFIG_MONGODB_SERVER: ordering-mongodb 59 | ME_CONFIG_MONGODB_ADMINUSERNAME: root 60 | ME_CONFIG_MONGODB_ADMINPASSWORD: admin 61 | ports: 62 | - '8081:8081' 63 | 64 | # ordering service - mongdb primary node 65 | # reference: https://github.com/bitnami/bitnami-docker-mongodb/blob/master/docker-compose-replicaset.yml 66 | ordering-mongodb: 67 | container_name: ordering-mongodb 68 | image: bitnami/mongodb:5.0 69 | restart: always 70 | networks: 71 | - default 72 | environment: 73 | MONGODB_REPLICA_SET_MODE: primary 74 | MONGODB_ADVERTISED_HOSTNAME: ordering-mongodb 75 | MONGODB_ROOT_USERNAME: root 76 | MONGODB_ROOT_PASSWORD: admin 77 | MONGODB_REPLICA_SET_KEY: replicasetkey123 78 | ports: 79 | - '27017:27017' 80 | 81 | # ordering service - mongdb replica node 82 | # reference: https://github.com/bitnami/bitnami-docker-mongodb/blob/master/docker-compose-replicaset.yml 83 | ordering-mongodb-replica0: 84 | container_name: ordering-mongodb-replica0 85 | image: bitnami/mongodb:5.0 86 | restart: always 87 | depends_on: 88 | - ordering-mongodb 89 | networks: 90 | - default 91 | environment: 92 | MONGODB_REPLICA_SET_MODE: secondary 93 | MONGODB_ADVERTISED_HOSTNAME: ordering-mongodb-replica0 94 | MONGODB_INITIAL_PRIMARY_HOST: ordering-mongodb 95 | MONGODB_INITIAL_PRIMARY_ROOT_USERNAME: root 96 | MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD: admin 97 | MONGODB_REPLICA_SET_KEY: replicasetkey123 98 | 99 | # ordering service - mongdb replica node 100 | # reference: https://github.com/bitnami/bitnami-docker-mongodb/blob/master/docker-compose-replicaset.yml 101 | ordering-mongodb-arbiter: 102 | container_name: ordering-mongodb-arbiter 103 | image: bitnami/mongodb:5.0 104 | restart: always 105 | depends_on: 106 | - ordering-mongodb 107 | networks: 108 | - default 109 | environment: 110 | MONGODB_REPLICA_SET_MODE: arbiter 111 | MONGODB_ADVERTISED_HOSTNAME: ordering-mongodb-arbiter 112 | MONGODB_INITIAL_PRIMARY_HOST: ordering-mongodb 113 | MONGODB_INITIAL_PRIMARY_ROOT_USERNAME: root 114 | MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD: admin 115 | MONGODB_REPLICA_SET_KEY: replicasetkey123 116 | 117 | # message broker - zookeeper 118 | # reference: https://github.com/bitnami/bitnami-docker-kafka/blob/master/docker-compose.yml 119 | zookeeper: 120 | image: 'bitnami/zookeeper:3.7' 121 | restart: always 122 | container_name: zookeeper 123 | environment: 124 | ALLOW_ANONYMOUS_LOGIN: 'yes' 125 | networks: 126 | - default 127 | 128 | # message broker - kafka 129 | # reference: https://github.com/bitnami/bitnami-docker-kafka/blob/master/docker-compose.yml 130 | kafka: 131 | image: 'bitnami/kafka:latest' 132 | restart: always 133 | container_name: kafka 134 | environment: 135 | KAFKA_CFG_ZOOKEEPER_CONNECT: zookeeper:2181 136 | ALLOW_PLAINTEXT_LISTENER: 'yes' 137 | ports: 138 | - '9092:9092' 139 | networks: 140 | - default 141 | depends_on: 142 | - zookeeper 143 | 144 | # message broker - kafka ui 145 | # reference: https://github.com/cloudhut/kowl/tree/master/docs/local 146 | kowl: 147 | image: quay.io/cloudhut/kowl:v1.4.0 148 | container_name: kowl 149 | restart: always 150 | hostname: kowl 151 | volumes: 152 | - ./kowl.yml:/etc/kowl/config.yaml 153 | entrypoint: ./kowl --config.filepath=/etc/kowl/config.yaml 154 | ports: 155 | - "9999:8080" 156 | networks: 157 | - default 158 | depends_on: 159 | - kafka 160 | 161 | # inventory service - rest api 162 | inventory-service: 163 | container_name: inventory-service 164 | build: 165 | context: ./inventory/ 166 | dockerfile: rest.Dockerfile 167 | restart: always 168 | depends_on: 169 | - inventory-mongodb 170 | networks: 171 | - default 172 | environment: 173 | PORT: '8080' 174 | MONGO_SERVER: inventory-mongodb 175 | MONGO_PORT: '27017' 176 | MONGO_USERNAME: root 177 | MONGO_PASSWORD: admin 178 | ports: 179 | - '8090:8080' 180 | volumes: 181 | - ./inventory/domain:/app/domain 182 | - ./inventory/rest:/app/rest 183 | 184 | # inventory service - message handler 185 | inventory-msg-handler: 186 | container_name: inventory-msg-handler 187 | build: 188 | context: ./inventory/ 189 | dockerfile: msg.Dockerfile 190 | restart: always 191 | depends_on: 192 | - inventory-mongodb 193 | networks: 194 | - default 195 | environment: 196 | MONGO_SERVER: inventory-mongodb 197 | MONGO_PORT: '27017' 198 | MONGO_USERNAME: root 199 | MONGO_PASSWORD: admin 200 | volumes: 201 | - ./inventory/domain:/app/domain 202 | - ./inventory/message-handler:/app/message-handler 203 | 204 | # inventory service - mongdb single container 205 | # reference: https://github.com/bitnami/bitnami-docker-mongodb/blob/master/docker-compose.yml 206 | inventory-mongodb: 207 | container_name: inventory-mongodb 208 | image: bitnami/mongodb:5.0 209 | restart: always 210 | networks: 211 | - default 212 | environment: 213 | MONGODB_ROOT_USERNAME: root 214 | MONGODB_ROOT_PASSWORD: admin 215 | ports: 216 | - '27027:27017' 217 | 218 | # inventory service - mongodb ui 219 | # reference: https://hub.docker.com/_/mongo-express 220 | inventory-mongodb-admin: 221 | container_name: inventory-mongodb-admin 222 | image: mongo-express:0.54 223 | restart: always 224 | depends_on: 225 | - inventory-mongodb 226 | networks: 227 | - default 228 | environment: 229 | ME_CONFIG_MONGODB_SERVER: inventory-mongodb 230 | ME_CONFIG_MONGODB_ADMINUSERNAME: root 231 | ME_CONFIG_MONGODB_ADMINPASSWORD: admin 232 | ports: 233 | - '8091:8081' 234 | 235 | networks: 236 | default: 237 | name: MyNetwork 238 | -------------------------------------------------------------------------------- /imgs/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CopyPasteEngineer/microservice-architecture-demo/fdf892afbe753810d49b22c769cb16ca867fe297/imgs/cover.png -------------------------------------------------------------------------------- /inventory/adapter/all_requested_orders/memory.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict 2 | from bson.objectid import ObjectId 3 | 4 | from domain.port import AllRequestedOrders 5 | 6 | from domain.model.requested_order import RequestedOrder 7 | from domain.model.requested_order.repository.exception import ( 8 | OrderNotFoundException, InvalidOrderIdException, DuplicateKeyError 9 | ) 10 | 11 | 12 | class AllRequestedOrdersInMemory(AllRequestedOrders): 13 | def __init__(self, data: Dict[str, RequestedOrder] = None): 14 | self.data: Dict[str, RequestedOrder] = data or {} 15 | 16 | async def next_identity(self) -> str: 17 | return 'IN-'+str(ObjectId()) 18 | 19 | async def identity_from_order_id(self, source_id: str) -> str: 20 | try: 21 | source_id_random = source_id.split('-', maxsplit=1)[1] 22 | except IndexError: 23 | raise InvalidOrderIdException('Invalid Order id') 24 | id_ = f'IN-{source_id_random}' 25 | return id_ 26 | 27 | async def are_pending(self) -> List[RequestedOrder]: 28 | return [order for order in self.data.values() if order.is_pending()] 29 | 30 | async def from_id(self, id_: str) -> RequestedOrder: 31 | order = self.data.get(id_, None) 32 | if order is None: 33 | raise OrderNotFoundException(f'Order id {id_} not found') 34 | return order 35 | 36 | async def save(self, entity: RequestedOrder): 37 | self.data[entity.id_] = entity 38 | 39 | async def add(self, entity: RequestedOrder): 40 | if entity.id_ in self.data: 41 | raise DuplicateKeyError(f'Order with ID {str(entity.id_)} already exists') 42 | self.data[entity.id_] = entity 43 | -------------------------------------------------------------------------------- /inventory/adapter/all_requested_orders/mongo.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from bson.objectid import ObjectId 3 | import bson.errors 4 | from pymongo.database import Database 5 | 6 | from domain.port import AllRequestedOrders 7 | 8 | from domain.model.requested_order import RequestedOrder 9 | from domain.model.requested_order.repository.exception import ( 10 | OrderNotFoundException, InvalidOrderIdException, DuplicateKeyError, EntityOutdated 11 | ) 12 | 13 | 14 | class AllRequestedOrdersInMongo(AllRequestedOrders): 15 | def __init__(self, mongo_db: Database, collection_name: str = 'order'): 16 | self.mongo_db = mongo_db 17 | self.collection_name = collection_name 18 | 19 | async def next_identity(self) -> str: 20 | return 'IN-'+str(ObjectId()) 21 | 22 | async def identity_from_order_id(self, source_id: str) -> str: 23 | try: 24 | source_id_random = source_id.split('-', maxsplit=1)[1] 25 | except IndexError: 26 | raise InvalidOrderIdException('Invalid Order id') 27 | id_ = f'IN-{source_id_random}' 28 | return id_ 29 | 30 | async def are_pending(self) -> List[RequestedOrder]: 31 | filter_ = {'state.status': 'pending'} 32 | projection = {'_id': False, 'events': False} 33 | 34 | raw_orders = self.mongo_db[self.collection_name].find(filter_, projection) 35 | pending_orders = [(RequestedOrder.deserialize(raw_order['state']), raw_order['version']) 36 | async for raw_order in raw_orders] 37 | for pending_order, version in pending_orders: 38 | pending_order._version = version 39 | return [pending_order for pending_order, _ in pending_orders] 40 | 41 | async def from_id(self, id_: str) -> RequestedOrder: 42 | mongo_id = self._entity_id_to_mongo_id(id_) 43 | filter_ = {'_id': ObjectId(mongo_id)} 44 | projection = {'_id': False, 'events': False} 45 | 46 | raw = await self.mongo_db[self.collection_name].find_one(filter_, projection) 47 | if raw is None: 48 | raise OrderNotFoundException(f'Order id {id_} not found') 49 | order = RequestedOrder.deserialize(raw['state']) 50 | order._version = raw['version'] 51 | return order 52 | 53 | async def save(self, entity: RequestedOrder): 54 | data = entity.serialize() 55 | id_ = self._entity_id_to_mongo_id(entity.id_) 56 | 57 | current_version = entity._version 58 | spec = {'_id': id_, 'version': current_version} 59 | pending_events = [dict(**event.serialize(), version=current_version+1+i) 60 | for i, event in enumerate(entity.get_pending_events())] 61 | update = { 62 | '$set': {'state': data}, 63 | '$push': {'events': {'$each': pending_events}}, 64 | '$inc': {'version': len(pending_events)}, 65 | } 66 | 67 | try: 68 | await self.mongo_db[self.collection_name].update_one(spec, update, upsert=True) 69 | except DuplicateKeyError: 70 | raise EntityOutdated() 71 | 72 | async def add(self, entity: RequestedOrder): 73 | data = entity.serialize() 74 | id_ = self._entity_id_to_mongo_id(entity.id_) 75 | 76 | current_version = entity._version 77 | pending_events = [dict(**event.serialize(), version=current_version+1+i) 78 | for i, event in enumerate(entity.get_pending_events())] 79 | document = { 80 | '_id': id_, 81 | 'state': data, 82 | 'events': pending_events, 83 | 'version': current_version + len(pending_events), 84 | } 85 | 86 | try: 87 | await self.mongo_db[self.collection_name].insert_one(document) 88 | except DuplicateKeyError as e: 89 | raise e 90 | 91 | @staticmethod 92 | def _entity_id_to_mongo_id(id_: str) -> ObjectId: 93 | try: 94 | return ObjectId(id_.split('-', maxsplit=1)[1]) 95 | except (IndexError, bson.errors.InvalidId): 96 | raise InvalidOrderIdException('Invalid Order id') 97 | -------------------------------------------------------------------------------- /inventory/domain.requirements.txt: -------------------------------------------------------------------------------- 1 | pydantic>=1.8.2,<1.9.0 2 | -------------------------------------------------------------------------------- /inventory/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CopyPasteEngineer/microservice-architecture-demo/fdf892afbe753810d49b22c769cb16ca867fe297/inventory/domain/__init__.py -------------------------------------------------------------------------------- /inventory/domain/base/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /inventory/domain/base/aggregate.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, TypeVar, Type 2 | from pydantic import BaseModel, PrivateAttr 3 | 4 | from .event import EventBase 5 | 6 | 7 | AggregateType = TypeVar('AggregateType') 8 | 9 | 10 | class AggregateBase(BaseModel): 11 | _pending_events: List = PrivateAttr(default_factory=list) 12 | 13 | def _append_event(self, event: EventBase): 14 | self._pending_events.append(event) 15 | 16 | def get_pending_events(self) -> List[EventBase]: 17 | return self._pending_events 18 | 19 | def serialize(self) -> Dict: 20 | return self.dict(by_alias=True) 21 | 22 | @classmethod 23 | def deserialize(cls: Type[AggregateType], data: Dict) -> AggregateType: 24 | return cls(**data) 25 | -------------------------------------------------------------------------------- /inventory/domain/base/event.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, TypeVar, Type 2 | from pydantic import BaseModel 3 | 4 | 5 | EventType = TypeVar('EventType') 6 | 7 | 8 | class EventBase(BaseModel): 9 | def serialize(self) -> Dict: 10 | d = self.dict() 11 | d['_event_type'] = self.__class__.__name__ 12 | return d 13 | 14 | @classmethod 15 | def deserialize(cls: Type[EventType], data: Dict) -> EventType: 16 | del data['_event_type'] 17 | return cls(**data) 18 | -------------------------------------------------------------------------------- /inventory/domain/base/repository.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, Generic 2 | from abc import abstractmethod 3 | 4 | from functools import wraps 5 | 6 | IdType = TypeVar('IdType') 7 | EntityType = TypeVar('EntityType') 8 | 9 | 10 | class EntityNotFound(Exception): 11 | pass 12 | 13 | 14 | class EntityOutdated(Exception): 15 | pass 16 | 17 | 18 | class RepositoryAbstract(Generic[IdType, EntityType]): 19 | @abstractmethod 20 | async def next_identity(self) -> IdType: 21 | pass 22 | 23 | @abstractmethod 24 | async def from_id(self, id_: IdType) -> EntityType: 25 | pass 26 | 27 | @abstractmethod 28 | async def save(self, entity: EntityType): 29 | pass 30 | 31 | 32 | def transaction(func): 33 | @wraps(func) 34 | async def wrapper(*args, **kwargs): 35 | while True: 36 | try: 37 | return await func(*args, **kwargs) 38 | except EntityOutdated: 39 | continue 40 | 41 | return wrapper 42 | -------------------------------------------------------------------------------- /inventory/domain/base/singleton.py: -------------------------------------------------------------------------------- 1 | class Singleton(type): 2 | _instances = {} 3 | 4 | def __call__(cls, *args, **kwargs): 5 | if cls not in cls._instances: 6 | cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 7 | else: 8 | assert not args and not kwargs 9 | return cls._instances[cls] 10 | -------------------------------------------------------------------------------- /inventory/domain/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CopyPasteEngineer/microservice-architecture-demo/fdf892afbe753810d49b22c769cb16ca867fe297/inventory/domain/model/__init__.py -------------------------------------------------------------------------------- /inventory/domain/model/requested_order/__init__.py: -------------------------------------------------------------------------------- 1 | from .requested_order import RequestedOrder 2 | -------------------------------------------------------------------------------- /inventory/domain/model/requested_order/event.py: -------------------------------------------------------------------------------- 1 | from typing import List, TYPE_CHECKING 2 | from pydantic import BaseModel, Field 3 | 4 | from domain.base.event import EventBase 5 | 6 | 7 | class OrderItemRepresentation(BaseModel): 8 | product_id: str 9 | amount: int 10 | 11 | if TYPE_CHECKING: 12 | def __init__(self, *, product_id: str, amount: int): 13 | super().__init__() 14 | 15 | 16 | class OrderCreatedEvent(EventBase): 17 | id_: str = Field(..., alias='id') 18 | items: List[OrderItemRepresentation] 19 | customer_id: str 20 | 21 | if TYPE_CHECKING: 22 | def __init__(self, *, id: str, items: List[OrderItemRepresentation], customer_id: str): 23 | super().__init__() 24 | 25 | 26 | class OrderItemAmountUpdatedEvent(EventBase): 27 | order_id: str 28 | product_id: str 29 | amount: int 30 | 31 | if TYPE_CHECKING: 32 | def __init__(self, *, order_id: str, product_id: str, amount: int): 33 | super().__init__() 34 | 35 | 36 | class OrderSubmittedEvent(EventBase): 37 | id_: str = Field(..., alias='id') 38 | items: List[OrderItemRepresentation] 39 | customer_id: str 40 | 41 | if TYPE_CHECKING: 42 | def __init__(self, *, id: str, items: List[OrderItemRepresentation], customer_id: str): 43 | super().__init__() 44 | -------------------------------------------------------------------------------- /inventory/domain/model/requested_order/repository/__init__.py: -------------------------------------------------------------------------------- 1 | from .repository import AllRequestedOrders 2 | -------------------------------------------------------------------------------- /inventory/domain/model/requested_order/repository/exception.py: -------------------------------------------------------------------------------- 1 | from pymongo.errors import DuplicateKeyError 2 | 3 | from ....base.repository import EntityOutdated 4 | 5 | 6 | class InvalidOrderIdException(Exception): 7 | pass 8 | 9 | 10 | class OrderNotFoundException(Exception): 11 | pass 12 | 13 | 14 | class OrderSubmittedException(Exception): 15 | pass 16 | 17 | 18 | class InvalidOrderItemAmountException(Exception): 19 | pass 20 | -------------------------------------------------------------------------------- /inventory/domain/model/requested_order/repository/repository.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import List 3 | 4 | from domain.base.repository import RepositoryAbstract 5 | 6 | from ..requested_order import RequestedOrder 7 | 8 | 9 | class AllRequestedOrders(RepositoryAbstract[str, RequestedOrder]): 10 | @abstractmethod 11 | async def next_identity(self) -> str: 12 | pass 13 | 14 | @abstractmethod 15 | async def identity_from_order_id(self, source_id: str) -> str: 16 | pass 17 | 18 | @abstractmethod 19 | async def are_pending(self) -> List[RequestedOrder]: 20 | pass 21 | 22 | @abstractmethod 23 | async def from_id(self, id_: str) -> RequestedOrder: 24 | pass 25 | 26 | @abstractmethod 27 | async def save(self, entity: RequestedOrder): 28 | pass 29 | 30 | @abstractmethod 31 | async def add(self, entity: RequestedOrder): 32 | pass 33 | -------------------------------------------------------------------------------- /inventory/domain/model/requested_order/requested_order.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple, TYPE_CHECKING 2 | from pydantic import Field, PrivateAttr 3 | 4 | from domain.base.aggregate import AggregateBase 5 | 6 | 7 | _ERR_MSG_EDIT_AFTER_SUBMIT = 'Submitted Order cannot be edited' 8 | _ERR_MSG_DOUBLE_SUBMIT = 'Submitted Order cannot be submitted again' 9 | _ERR_MSG_ITEM_AMOUNT_NOT_INTEGER = 'Order Item amount must be an integer' 10 | _ERR_MSG_ITEM_AMOUNT_LESS_THAN_ZERO = 'Order Item amount cannot be less than 0' 11 | 12 | 13 | class RequestedOrder(AggregateBase): 14 | id_: str = Field(..., alias='id') 15 | source_id: str = Field(..., alias='sourceId') 16 | requested_items: List[Tuple[str, int]] = Field(..., alias='requestedItems') 17 | customer_id: str = Field(..., alias='customerId') 18 | status: str = 'pending' 19 | _version: int = PrivateAttr(default=0) 20 | 21 | def is_pending(self) -> bool: 22 | return self.status == 'pending' 23 | 24 | if TYPE_CHECKING: 25 | def __init__(self, *, id: str, sourceId: str, requestedItems: List[Tuple[str, int]], customerId: str, 26 | status: str = 'pending'): 27 | super().__init__() 28 | -------------------------------------------------------------------------------- /inventory/domain/port.py: -------------------------------------------------------------------------------- 1 | from domain.model.requested_order.repository import AllRequestedOrders 2 | -------------------------------------------------------------------------------- /inventory/domain/registry.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from domain.base.singleton import Singleton 3 | 4 | 5 | class Registry(metaclass=Singleton): 6 | def __init__(self): 7 | from domain.model.requested_order.repository import AllRequestedOrders 8 | 9 | self.all_requested_orders: Optional[AllRequestedOrders] = None 10 | -------------------------------------------------------------------------------- /inventory/domain/usecase.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List, Tuple 2 | from pydantic import BaseModel, Field 3 | from domain.base.repository import transaction 4 | from .registry import Registry 5 | from domain.model.requested_order.requested_order import RequestedOrder 6 | from domain.model.requested_order.repository.exception import DuplicateKeyError 7 | 8 | 9 | class OrderItem(BaseModel): 10 | product_id: str = Field(..., alias='productId') 11 | amount: int 12 | 13 | 14 | class OrderState(BaseModel): 15 | id_: str = Field(..., alias='id') 16 | items: List[OrderItem] 17 | customer_id: str = Field(..., alias='customerId') 18 | status: str = 'pending' 19 | 20 | @classmethod 21 | def from_order(cls, order: RequestedOrder) -> 'OrderState': 22 | items = [OrderItem(productId=product_id, amount=amount) for product_id, amount in order.requested_items] 23 | return cls(id=order.id_, items=items, customerId=order.customer_id, status=order.status) 24 | 25 | 26 | @transaction 27 | async def get_pending_orders() -> List[OrderState]: 28 | all_requested_orders = Registry().all_requested_orders 29 | pending_orders = await all_requested_orders.are_pending() 30 | return [OrderState.from_order(pending_order) for pending_order in pending_orders] 31 | 32 | 33 | @transaction 34 | async def receive_requested_order(source_id: str, customer_id: str, items: List[Tuple[str, int]]) -> str: 35 | all_requested_orders = Registry().all_requested_orders 36 | 37 | id_: str = await all_requested_orders.identity_from_order_id(source_id) 38 | 39 | requested_order = RequestedOrder(id=id_, sourceId=source_id, requestedItems=items, customerId=customer_id) 40 | try: 41 | await all_requested_orders.add(requested_order) 42 | except DuplicateKeyError: # duplicate message, do nothing 43 | print('key duplicated; just ignore') 44 | 45 | return id_ 46 | 47 | 48 | if TYPE_CHECKING: 49 | async def get_pending_orders() -> List[OrderState]: ... 50 | 51 | async def receive_requested_order(source_id: str, customer_id: str, items: List[Tuple[str, int]]) -> str: ... 52 | -------------------------------------------------------------------------------- /inventory/infrastructure.requirements.txt: -------------------------------------------------------------------------------- 1 | pymongo>=3.12.0,<3.13.0 2 | motor>=2.5.1,<2.6.0 3 | kafka-python>=2.0.2,<2.1.0 4 | -------------------------------------------------------------------------------- /inventory/infrastructure/kafka/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import KafkaConfig 2 | from .kafka import create_kafka_consumer 3 | -------------------------------------------------------------------------------- /inventory/infrastructure/kafka/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings 2 | 3 | 4 | class KafkaConfig(BaseSettings): 5 | KAFKA_SERVER: str = 'kafka' 6 | KAFKA_PORT: str = '9092' 7 | -------------------------------------------------------------------------------- /inventory/infrastructure/kafka/kafka.py: -------------------------------------------------------------------------------- 1 | import json 2 | from kafka import KafkaConsumer 3 | 4 | from .config import KafkaConfig 5 | 6 | 7 | def create_kafka_consumer(consumer_group: str, config: KafkaConfig) -> KafkaConsumer: 8 | producer = KafkaConsumer(bootstrap_servers=[f'{config.KAFKA_SERVER}:{config.KAFKA_PORT}'], 9 | value_deserializer=lambda m: json.loads(m.decode('utf-8')), 10 | group_id=consumer_group) 11 | return producer 12 | -------------------------------------------------------------------------------- /inventory/infrastructure/mongo/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import MongoDBConfig 2 | from .mongo import create_mongo_db 3 | -------------------------------------------------------------------------------- /inventory/infrastructure/mongo/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings 2 | 3 | 4 | class MongoDBConfig(BaseSettings): 5 | MONGO_SERVER: str = 'inventory-mongodb' 6 | MONGO_PORT: str = '27017' 7 | MONGO_USERNAME: str = 'root' 8 | MONGO_PASSWORD: str = 'admin' 9 | -------------------------------------------------------------------------------- /inventory/infrastructure/mongo/mongo.py: -------------------------------------------------------------------------------- 1 | import motor.motor_asyncio 2 | 3 | from .config import MongoDBConfig 4 | 5 | 6 | def create_mongo_db(config: MongoDBConfig, loop=None): 7 | uri = f'mongodb://{config.MONGO_USERNAME}:{config.MONGO_PASSWORD}@{config.MONGO_SERVER}:{config.MONGO_PORT}' 8 | if loop: 9 | client = motor.motor_asyncio.AsyncIOMotorClient(uri, io_loop=loop) 10 | else: 11 | client = motor.motor_asyncio.AsyncIOMotorClient(uri) 12 | db = client.InventoryService 13 | return db 14 | -------------------------------------------------------------------------------- /inventory/message-handler/dependency.py: -------------------------------------------------------------------------------- 1 | from domain.registry import Registry 2 | from adapter.all_requested_orders.mongo import AllRequestedOrdersInMongo 3 | from infrastructure.mongo import MongoDBConfig, create_mongo_db 4 | from infrastructure.kafka import KafkaConfig, create_kafka_consumer 5 | 6 | 7 | def inject(loop): 8 | mongo_db = create_mongo_db(MongoDBConfig(), loop=loop) 9 | Registry().all_requested_orders = AllRequestedOrdersInMongo(mongo_db) 10 | 11 | 12 | def get_kafka_consumer(consumer_group: str): 13 | consumer = create_kafka_consumer(consumer_group, KafkaConfig()) 14 | return consumer 15 | -------------------------------------------------------------------------------- /inventory/message-handler/main.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import asyncio 3 | 4 | from domain import usecase 5 | 6 | import dependency 7 | 8 | 9 | async def handle_order_submitted(event: Dict): 10 | source_id = event['id_'] 11 | customer_id = event['customer_id'] 12 | items = [(item['product_id'], item['amount']) for item in event['items']] 13 | 14 | await usecase.receive_requested_order(source_id=source_id, customer_id=customer_id, items=items) 15 | 16 | 17 | event_handlers = { 18 | 'Ordering.OrderSubmittedEvent': handle_order_submitted, 19 | } 20 | topics = list(event_handlers.keys()) 21 | 22 | 23 | def main(): 24 | loop = asyncio.new_event_loop() 25 | asyncio.set_event_loop(loop) 26 | dependency.inject(loop) 27 | 28 | consumer = dependency.get_kafka_consumer(consumer_group='inventory-service') 29 | consumer.subscribe(topics) 30 | 31 | print('READY') 32 | while True: 33 | msg_pack = consumer.poll(timeout_ms=500, max_records=1) 34 | 35 | for tp, messages in msg_pack.items(): 36 | for message in messages: 37 | print(message.value) 38 | loop.run_until_complete(event_handlers[tp.topic](message.value)) 39 | 40 | consumer.commit() 41 | 42 | 43 | if __name__ == '__main__': 44 | main() 45 | -------------------------------------------------------------------------------- /inventory/msg.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | COPY ./domain.requirements.txt /domain.requirements.txt 4 | RUN pip install -r /domain.requirements.txt 5 | COPY ./infrastructure.requirements.txt /infrastructure.requirements.txt 6 | RUN pip install -r /infrastructure.requirements.txt 7 | 8 | COPY ./domain /app/domain 9 | COPY ./adapter /app/adapter 10 | COPY ./infrastructure /app/infrastructure 11 | COPY ./message-handler /app/message-handler 12 | 13 | ENV PYTHONPATH /app/ 14 | ENV PYTHONUNBUFFERED 1 15 | 16 | CMD sleep 10 && python /app/message-handler/main.py 17 | -------------------------------------------------------------------------------- /inventory/rest.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uvicorn-gunicorn-fastapi:python3.8 2 | 3 | COPY ./domain.requirements.txt /domain.requirements.txt 4 | RUN pip install -r /domain.requirements.txt 5 | COPY ./infrastructure.requirements.txt /infrastructure.requirements.txt 6 | RUN pip install -r /infrastructure.requirements.txt 7 | COPY ./rest.requirements.txt /rest.requirements.txt 8 | RUN pip install -r /rest.requirements.txt 9 | 10 | COPY ./domain /app/domain 11 | COPY ./adapter /app/adapter 12 | COPY ./infrastructure /app/infrastructure 13 | COPY ./rest /app/rest 14 | 15 | # change this 16 | ENV PYTHONPATH /app/rest 17 | 18 | ENV APP_MODULE rest:app 19 | ENV PORT 8080 20 | 21 | CMD sleep 10 && uvicorn $APP_MODULE --host 0.0.0.0 --port $PORT --reload 22 | -------------------------------------------------------------------------------- /inventory/rest.requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi>=0.68.1,<0.69.0 2 | -------------------------------------------------------------------------------- /inventory/rest/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import app 2 | -------------------------------------------------------------------------------- /inventory/rest/dependency.py: -------------------------------------------------------------------------------- 1 | from domain.registry import Registry 2 | from adapter.all_requested_orders.mongo import AllRequestedOrdersInMongo 3 | from infrastructure.mongo import MongoDBConfig, create_mongo_db 4 | 5 | 6 | def inject(): 7 | mongo_db = create_mongo_db(MongoDBConfig()) 8 | 9 | Registry().all_requested_orders = AllRequestedOrdersInMongo(mongo_db) 10 | -------------------------------------------------------------------------------- /inventory/rest/event_handler.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from . import dependency 3 | 4 | 5 | async def startup(app: FastAPI): 6 | dependency.inject() 7 | 8 | 9 | async def shutdown(app: FastAPI): 10 | pass 11 | -------------------------------------------------------------------------------- /inventory/rest/main.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from functools import partial 3 | from fastapi import FastAPI 4 | 5 | from domain import usecase 6 | from .event_handler import startup, shutdown 7 | from .schema import OrderDetail 8 | 9 | 10 | def create_app(): 11 | fast_app = FastAPI(title='Inventory Service') 12 | fast_app.add_event_handler('startup', func=partial(startup, app=fast_app)) 13 | fast_app.add_event_handler('shutdown', func=partial(shutdown, app=fast_app)) 14 | return fast_app 15 | 16 | 17 | app = create_app() 18 | 19 | 20 | @app.get('/pending-orders', response_model=List[OrderDetail]) 21 | async def get_pending_order(): 22 | orders = await usecase.get_pending_orders() 23 | return [OrderDetail.from_order(order) for order in orders] 24 | -------------------------------------------------------------------------------- /inventory/rest/schema.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pydantic import BaseModel, Field 3 | 4 | from domain.usecase import OrderState 5 | 6 | 7 | class OrderItem(BaseModel): 8 | product_id: str = Field(..., alias='productId') 9 | amount: int 10 | 11 | 12 | class OrderDetail(BaseModel): 13 | id_: str = Field(..., alias='id') 14 | items: List[OrderItem] 15 | customer_id: str = Field(..., alias='customerId') 16 | status: str = 'pending' 17 | 18 | @classmethod 19 | def from_order(cls, order: OrderState) -> 'OrderDetail': 20 | items = [OrderItem(productId=t.product_id, amount=t.amount) for t in order.items] 21 | return OrderDetail(id=order.id_, items=items, customerId=order.customer_id, 22 | status=order.status) 23 | -------------------------------------------------------------------------------- /kowl.yml: -------------------------------------------------------------------------------- 1 | kafka: 2 | brokers: 3 | - kafka:9092 -------------------------------------------------------------------------------- /ordering/adapter/all_orders/memory.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from bson.objectid import ObjectId 3 | 4 | from domain.port import AllOrders 5 | 6 | from domain.model.order import Order 7 | from domain.model.order.repository.exception import OrderNotFoundException 8 | 9 | 10 | class AllOrdersInMemory(AllOrders): 11 | def __init__(self, data: Dict[str, Order] = None): 12 | self.data: Dict[str, Order] = data or {} 13 | 14 | async def next_identity(self) -> str: 15 | return 'OR-'+str(ObjectId()) 16 | 17 | async def from_id(self, id_: str) -> Order: 18 | order = self.data.get(id_, None) 19 | if order is None: 20 | raise OrderNotFoundException(f'Order id {id_} not found') 21 | return order 22 | 23 | async def save(self, entity: Order): 24 | self.data[entity.id_] = entity 25 | -------------------------------------------------------------------------------- /ordering/adapter/all_orders/mongo.py: -------------------------------------------------------------------------------- 1 | from bson.objectid import ObjectId 2 | import bson.errors 3 | from pymongo.database import Database 4 | from pymongo.errors import DuplicateKeyError 5 | 6 | from domain.port import AllOrders 7 | 8 | from domain.model.order import Order 9 | from domain.model.order.repository.exception import ( 10 | OrderNotFoundException, InvalidOrderIdException, EntityOutdated, 11 | ) 12 | 13 | 14 | class AllOrdersInMongo(AllOrders): 15 | def __init__(self, mongo_db: Database, collection_name: str = 'order'): 16 | self.mongo_db = mongo_db 17 | self.collection_name = collection_name 18 | 19 | async def next_identity(self) -> str: 20 | return 'OR-'+str(ObjectId()) 21 | 22 | @staticmethod 23 | def _entity_id_to_mongo_id(id_: str) -> ObjectId: 24 | try: 25 | return ObjectId(id_.split('-', maxsplit=1)[1]) 26 | except (IndexError, bson.errors.InvalidId): 27 | raise InvalidOrderIdException('Invalid Order id') 28 | 29 | async def from_id(self, id_: str) -> Order: 30 | mongo_id = self._entity_id_to_mongo_id(id_) 31 | filter_ = {'_id': ObjectId(mongo_id)} 32 | projection = {'_id': False, 'events': False} 33 | 34 | raw = await self.mongo_db[self.collection_name].find_one(filter_, projection) 35 | if raw is None: 36 | raise OrderNotFoundException(f'Order id {id_} not found') 37 | order = Order.deserialize(raw['state']) 38 | order._version = raw['version'] 39 | return order 40 | 41 | async def save(self, entity: Order): 42 | data = entity.serialize() 43 | id_ = self._entity_id_to_mongo_id(entity.id_) 44 | 45 | current_version = entity._version 46 | spec = {'_id': id_, 'version': current_version} 47 | pending_events = [dict(**event.serialize(), version=current_version+1+i) 48 | for i, event in enumerate(entity.get_pending_events())] 49 | update = { 50 | '$set': {'state': data}, 51 | '$push': {'events': {'$each': pending_events}}, 52 | '$inc': {'version': len(pending_events)}, 53 | } 54 | 55 | try: 56 | await self.mongo_db[self.collection_name].update_one(spec, update, upsert=True) 57 | except DuplicateKeyError: 58 | raise EntityOutdated() 59 | -------------------------------------------------------------------------------- /ordering/domain.requirements.txt: -------------------------------------------------------------------------------- 1 | pydantic>=1.8.2,<1.9.0 2 | -------------------------------------------------------------------------------- /ordering/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CopyPasteEngineer/microservice-architecture-demo/fdf892afbe753810d49b22c769cb16ca867fe297/ordering/domain/__init__.py -------------------------------------------------------------------------------- /ordering/domain/base/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ordering/domain/base/aggregate.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, TypeVar, Type 2 | from pydantic import BaseModel, PrivateAttr 3 | 4 | from .event import EventBase 5 | 6 | 7 | AggregateType = TypeVar('AggregateType') 8 | 9 | 10 | class AggregateBase(BaseModel): 11 | _pending_events: List = PrivateAttr(default_factory=list) 12 | 13 | def _append_event(self, event: EventBase): 14 | self._pending_events.append(event) 15 | 16 | def get_pending_events(self) -> List[EventBase]: 17 | return self._pending_events 18 | 19 | def serialize(self) -> Dict: 20 | return self.dict(by_alias=True) 21 | 22 | @classmethod 23 | def deserialize(cls: Type[AggregateType], data: Dict) -> AggregateType: 24 | return cls(**data) 25 | -------------------------------------------------------------------------------- /ordering/domain/base/event.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, TypeVar, Type 2 | from pydantic import BaseModel 3 | 4 | 5 | EventType = TypeVar('EventType') 6 | 7 | 8 | class EventBase(BaseModel): 9 | def serialize(self) -> Dict: 10 | d = self.dict() 11 | d['_event_type'] = self.__class__.__name__ 12 | return d 13 | 14 | @classmethod 15 | def deserialize(cls: Type[EventType], data: Dict) -> EventType: 16 | del data['_event_type'] 17 | return cls(**data) 18 | -------------------------------------------------------------------------------- /ordering/domain/base/repository.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, Generic 2 | from abc import abstractmethod 3 | 4 | from functools import wraps 5 | 6 | IdType = TypeVar('IdType') 7 | EntityType = TypeVar('EntityType') 8 | 9 | 10 | class EntityNotFound(Exception): 11 | pass 12 | 13 | 14 | class EntityOutdated(Exception): 15 | pass 16 | 17 | 18 | class RepositoryAbstract(Generic[IdType, EntityType]): 19 | @abstractmethod 20 | async def next_identity(self) -> IdType: 21 | pass 22 | 23 | @abstractmethod 24 | async def from_id(self, id_: IdType) -> EntityType: 25 | pass 26 | 27 | @abstractmethod 28 | async def save(self, entity: EntityType): 29 | pass 30 | 31 | 32 | def transaction(func): 33 | @wraps(func) 34 | async def wrapper(*args, **kwargs): 35 | while True: 36 | try: 37 | return await func(*args, **kwargs) 38 | except EntityOutdated: 39 | continue 40 | 41 | return wrapper 42 | -------------------------------------------------------------------------------- /ordering/domain/base/singleton.py: -------------------------------------------------------------------------------- 1 | class Singleton(type): 2 | _instances = {} 3 | 4 | def __call__(cls, *args, **kwargs): 5 | if cls not in cls._instances: 6 | cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 7 | else: 8 | assert not args and not kwargs 9 | return cls._instances[cls] 10 | -------------------------------------------------------------------------------- /ordering/domain/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CopyPasteEngineer/microservice-architecture-demo/fdf892afbe753810d49b22c769cb16ca867fe297/ordering/domain/model/__init__.py -------------------------------------------------------------------------------- /ordering/domain/model/order/__init__.py: -------------------------------------------------------------------------------- 1 | from .order import Order 2 | -------------------------------------------------------------------------------- /ordering/domain/model/order/event.py: -------------------------------------------------------------------------------- 1 | from typing import List, TYPE_CHECKING 2 | from pydantic import BaseModel, Field 3 | 4 | from domain.base.event import EventBase 5 | 6 | 7 | class OrderItemRepresentation(BaseModel): 8 | product_id: str 9 | amount: int 10 | 11 | if TYPE_CHECKING: 12 | def __init__(self, *, product_id: str, amount: int): 13 | super().__init__() 14 | 15 | 16 | class OrderCreatedEvent(EventBase): 17 | id_: str = Field(..., alias='id') 18 | items: List[OrderItemRepresentation] 19 | customer_id: str 20 | 21 | if TYPE_CHECKING: 22 | def __init__(self, *, id: str, items: List[OrderItemRepresentation], customer_id: str): 23 | super().__init__() 24 | 25 | 26 | class OrderItemAmountUpdatedEvent(EventBase): 27 | order_id: str 28 | product_id: str 29 | amount: int 30 | 31 | if TYPE_CHECKING: 32 | def __init__(self, *, order_id: str, product_id: str, amount: int): 33 | super().__init__() 34 | 35 | 36 | class OrderSubmittedEvent(EventBase): 37 | id_: str = Field(..., alias='id') 38 | items: List[OrderItemRepresentation] 39 | customer_id: str 40 | 41 | if TYPE_CHECKING: 42 | def __init__(self, *, id: str, items: List[OrderItemRepresentation], customer_id: str): 43 | super().__init__() 44 | -------------------------------------------------------------------------------- /ordering/domain/model/order/exception.py: -------------------------------------------------------------------------------- 1 | class OrderSubmittedException(Exception): 2 | pass 3 | 4 | 5 | class InvalidOrderItemAmountException(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /ordering/domain/model/order/order.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Tuple, TYPE_CHECKING 2 | from pydantic import Field, PrivateAttr 3 | 4 | from domain.base.aggregate import AggregateBase 5 | from .event import ( 6 | OrderItemRepresentation, OrderCreatedEvent, OrderItemAmountUpdatedEvent, OrderSubmittedEvent, 7 | ) 8 | from .exception import OrderSubmittedException, InvalidOrderItemAmountException 9 | 10 | 11 | _ERR_MSG_EDIT_AFTER_SUBMIT = 'Submitted Order cannot be edited' 12 | _ERR_MSG_DOUBLE_SUBMIT = 'Submitted Order cannot be submitted again' 13 | _ERR_MSG_ITEM_AMOUNT_NOT_INTEGER = 'Order Item amount must be an integer' 14 | _ERR_MSG_ITEM_AMOUNT_LESS_THAN_ZERO = 'Order Item amount cannot be less than 0' 15 | 16 | 17 | class Order(AggregateBase): 18 | id_: str = Field(..., alias='id') 19 | items: Dict[str, int] 20 | customer_id: str = Field(..., alias='customerId') 21 | submitted: bool = False 22 | _version: int = PrivateAttr(default=0) 23 | 24 | @classmethod 25 | def create_new_order_with_items(cls, *, id: str, items: List[Tuple[str, int]], customer_id: str) -> 'Order': 26 | items_dict: Dict[str, int] = {product_id: amount for product_id, amount in items} 27 | order = cls(id=id, items=items_dict, customerId=customer_id) 28 | 29 | event = OrderCreatedEvent(id=id, items=order._get_items_represent(), customer_id=customer_id) 30 | order._append_event(event) 31 | return order 32 | 33 | def update_item_amount(self, product_id: str, amount: int): 34 | if self.submitted: 35 | raise OrderSubmittedException(_ERR_MSG_EDIT_AFTER_SUBMIT) 36 | 37 | if int(amount) != amount: 38 | raise InvalidOrderItemAmountException(_ERR_MSG_ITEM_AMOUNT_NOT_INTEGER) 39 | 40 | if amount < 0: 41 | raise InvalidOrderItemAmountException(_ERR_MSG_ITEM_AMOUNT_LESS_THAN_ZERO) 42 | 43 | if amount == 0: 44 | if product_id in self.items: 45 | del self.items[product_id] 46 | else: 47 | self.items[product_id] = amount 48 | 49 | event = OrderItemAmountUpdatedEvent(order_id=self.id_, product_id=product_id, amount=amount) 50 | self._append_event(event) 51 | 52 | def submit(self): 53 | if self.submitted: 54 | raise OrderSubmittedException(_ERR_MSG_DOUBLE_SUBMIT) 55 | 56 | self.submitted = True 57 | 58 | event = OrderSubmittedEvent(id=self.id_, items=self._get_items_represent(), customer_id=self.customer_id) 59 | self._append_event(event) 60 | 61 | def _get_items_represent(self) -> List[OrderItemRepresentation]: 62 | return [OrderItemRepresentation(product_id=pid, amount=amount) 63 | for pid, amount in sorted(self.items.items())] 64 | 65 | if TYPE_CHECKING: 66 | def __init__(self, *, id: str, items: Dict[str, int], customerId: str, submitted: bool = False): 67 | super().__init__() 68 | -------------------------------------------------------------------------------- /ordering/domain/model/order/repository/__init__.py: -------------------------------------------------------------------------------- 1 | from .repository import AllOrders 2 | -------------------------------------------------------------------------------- /ordering/domain/model/order/repository/exception.py: -------------------------------------------------------------------------------- 1 | from ....base.repository import EntityOutdated 2 | 3 | 4 | class InvalidOrderIdException(Exception): 5 | pass 6 | 7 | 8 | class OrderNotFoundException(Exception): 9 | pass 10 | -------------------------------------------------------------------------------- /ordering/domain/model/order/repository/repository.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | 3 | from domain.base.repository import RepositoryAbstract 4 | 5 | from ..order import Order 6 | 7 | 8 | class AllOrders(RepositoryAbstract[str, Order]): 9 | @abstractmethod 10 | async def next_identity(self) -> str: 11 | pass 12 | 13 | @abstractmethod 14 | async def from_id(self, id_: str) -> Order: 15 | pass 16 | 17 | @abstractmethod 18 | async def save(self, entity: Order): 19 | pass 20 | -------------------------------------------------------------------------------- /ordering/domain/port.py: -------------------------------------------------------------------------------- 1 | from domain.model.order.repository import AllOrders 2 | -------------------------------------------------------------------------------- /ordering/domain/registry.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from domain.base.singleton import Singleton 3 | 4 | 5 | class Registry(metaclass=Singleton): 6 | def __init__(self): 7 | from domain.model.order.repository import AllOrders 8 | 9 | self.all_orders: Optional[AllOrders] = None 10 | -------------------------------------------------------------------------------- /ordering/domain/usecase.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List, Tuple 2 | from pydantic import BaseModel, Field 3 | from domain.base.repository import transaction 4 | from .registry import Registry 5 | from domain.model.order.order import Order 6 | 7 | 8 | class OrderItem(BaseModel): 9 | product_id: str = Field(..., alias='productId') 10 | amount: int 11 | 12 | 13 | class OrderState(BaseModel): 14 | id_: str = Field(..., alias='id') 15 | items: List[OrderItem] 16 | customer_id: str = Field(..., alias='customerId') 17 | submitted: bool 18 | 19 | @classmethod 20 | def from_order(cls, order: Order) -> 'OrderState': 21 | items = [OrderItem(productId=product_id, amount=amount) for product_id, amount in order.items.items()] 22 | return cls(id=order.id_, items=items, customerId=order.customer_id, submitted=order.submitted) 23 | 24 | 25 | class OrderItemUpdate(BaseModel): 26 | order_id: str = Field(..., alias='orderId') 27 | product_id: str = Field(..., alias='productId') 28 | amount: int 29 | 30 | 31 | @transaction 32 | async def get_order(order_id: str) -> OrderState: 33 | repo = Registry().all_orders 34 | order: Order = await repo.from_id(order_id) 35 | return OrderState.from_order(order) 36 | 37 | 38 | @transaction 39 | async def create_new_order(customer_id: str, items: List[Tuple[str, int]]) -> str: 40 | repo = Registry().all_orders 41 | id_: str = await repo.next_identity() 42 | order = Order.create_new_order_with_items(id=id_, items=items, customer_id=customer_id) 43 | await repo.save(order) 44 | return id_ 45 | 46 | 47 | @transaction 48 | async def update_order_item_amount(order_id: str, product_id: str, amount: int) -> OrderItemUpdate: 49 | repo = Registry().all_orders 50 | order: Order = await repo.from_id(order_id) 51 | order.update_item_amount(product_id=product_id, amount=amount) 52 | await repo.save(order) 53 | return OrderItemUpdate(orderId=order.id_, productId=product_id, amount=order.items.get(product_id, 0)) 54 | 55 | 56 | @transaction 57 | async def submit_order(order_id: str): 58 | repo = Registry().all_orders 59 | order: Order = await repo.from_id(order_id) 60 | order.submit() 61 | await repo.save(order) 62 | 63 | 64 | if TYPE_CHECKING: 65 | async def get_order(order_id: str) -> OrderState: ... 66 | 67 | async def create_new_order(customer_id: str, items: List[str, int]) -> str: ... 68 | 69 | async def update_order_item_amount(order_id: str, product_id: str, amount: int) -> OrderItemUpdate: ... 70 | 71 | async def submit_order(order_id: str): ... 72 | -------------------------------------------------------------------------------- /ordering/infrastructure.requirements.txt: -------------------------------------------------------------------------------- 1 | pymongo>=3.12.0,<3.13.0 2 | motor>=2.5.1,<2.6.0 3 | kafka-python>=2.0.2,<2.1.0 4 | -------------------------------------------------------------------------------- /ordering/infrastructure/kafka/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import KafkaConfig 2 | from .kafka import create_kafka_producer 3 | -------------------------------------------------------------------------------- /ordering/infrastructure/kafka/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings 2 | 3 | 4 | class KafkaConfig(BaseSettings): 5 | KAFKA_SERVER: str = 'kafka' 6 | KAFKA_PORT: str = '9092' 7 | -------------------------------------------------------------------------------- /ordering/infrastructure/kafka/kafka.py: -------------------------------------------------------------------------------- 1 | import json 2 | from kafka import KafkaProducer 3 | 4 | from .config import KafkaConfig 5 | 6 | 7 | def create_kafka_producer(config: KafkaConfig) -> KafkaProducer: 8 | producer = KafkaProducer(bootstrap_servers=[f'{config.KAFKA_SERVER}:{config.KAFKA_PORT}'], 9 | value_serializer=lambda v: json.dumps(v).encode('utf-8')) 10 | return producer 11 | -------------------------------------------------------------------------------- /ordering/infrastructure/mongo/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import MongoDBConfig 2 | from .mongo import create_mongo_db 3 | -------------------------------------------------------------------------------- /ordering/infrastructure/mongo/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings 2 | 3 | 4 | class MongoDBConfig(BaseSettings): 5 | MONGO_SERVER: str = 'ordering-mongodb' 6 | MONGO_PORT: str = '27017' 7 | MONGO_USERNAME: str = 'root' 8 | MONGO_PASSWORD: str = 'admin' 9 | -------------------------------------------------------------------------------- /ordering/infrastructure/mongo/mongo.py: -------------------------------------------------------------------------------- 1 | import motor.motor_asyncio 2 | 3 | from .config import MongoDBConfig 4 | 5 | 6 | def create_mongo_db(config: MongoDBConfig, loop=None): 7 | uri = f'mongodb://{config.MONGO_USERNAME}:{config.MONGO_PASSWORD}@{config.MONGO_SERVER}:{config.MONGO_PORT}' 8 | if loop: 9 | client = motor.motor_asyncio.AsyncIOMotorClient(uri, io_loop=loop) 10 | else: 11 | client = motor.motor_asyncio.AsyncIOMotorClient(uri) 12 | db = client.OrderingService 13 | return db 14 | -------------------------------------------------------------------------------- /ordering/relay.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | COPY ./domain.requirements.txt /domain.requirements.txt 4 | RUN pip install -r /domain.requirements.txt 5 | COPY ./infrastructure.requirements.txt /infrastructure.requirements.txt 6 | RUN pip install -r /infrastructure.requirements.txt 7 | 8 | COPY ./domain /app/domain 9 | COPY ./infrastructure /app/infrastructure 10 | COPY ./adapter /app/adapter 11 | COPY ./relay/ /app/relay 12 | 13 | ENV PYTHONPATH /app/ 14 | ENV PYTHONUNBUFFERED 1 15 | 16 | CMD sleep 10 && python /app/relay/main.py 17 | -------------------------------------------------------------------------------- /ordering/relay/change.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Dict, AsyncIterator 2 | from pymongo.database import Collection 3 | from pymongo.change_stream import CollectionChangeStream 4 | 5 | 6 | class MongoChangeWatcher: 7 | def __init__(self, db: Collection): 8 | self.collection: Collection = db 9 | self.current_stream: Optional[CollectionChangeStream] = None 10 | 11 | @property 12 | def resume_token(self) -> str: 13 | return self.current_stream.resume_token 14 | 15 | async def iter_changes(self, latest_token=None) -> AsyncIterator[Dict]: 16 | async with self.collection.watch(resume_after=latest_token) as stream: # change stream 17 | self.current_stream = stream 18 | async for change in self.current_stream: 19 | yield change 20 | -------------------------------------------------------------------------------- /ordering/relay/dependency.py: -------------------------------------------------------------------------------- 1 | from infrastructure.mongo import MongoDBConfig, create_mongo_db 2 | from infrastructure.kafka import KafkaConfig, create_kafka_producer 3 | 4 | 5 | def get_mongo_db(loop=None): 6 | mongo_db = create_mongo_db(MongoDBConfig(), loop=loop) 7 | return mongo_db 8 | 9 | 10 | def get_kafka_producer(): 11 | producer = create_kafka_producer(KafkaConfig()) 12 | return producer 13 | -------------------------------------------------------------------------------- /ordering/relay/main.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | import asyncio 3 | from pymongo.database import Database 4 | from kafka import KafkaProducer 5 | 6 | from change import MongoChangeWatcher 7 | from memory import MongoRelayMemory 8 | import dependency 9 | 10 | 11 | def extract_events(change: Dict) -> List[Dict]: 12 | operation_type = change.get('operationType', '') 13 | if operation_type == 'update': 14 | fields: Dict = change['updateDescription']['updatedFields'] 15 | pairs = [(key, event) for key, event in fields.items() if key.startswith('events.')] 16 | return [event for _, event in pairs] 17 | 18 | elif operation_type == 'insert': 19 | return change['fullDocument']['events'] 20 | 21 | return [] 22 | 23 | 24 | async def relay(watcher: MongoChangeWatcher, memory: MongoRelayMemory, producer: KafkaProducer): 25 | latest_token = await memory.get_latest_token() 26 | print('READY') 27 | async for change in watcher.iter_changes(latest_token=latest_token): 28 | events = extract_events(change) 29 | for event in events: 30 | print(event) 31 | try: 32 | event_type = event['_event_type'] 33 | except KeyError as e: 34 | raise e # or do something 35 | 36 | producer.send(f'Ordering.{event_type}', event) 37 | 38 | resume_token = watcher.resume_token 39 | await memory.save_token(resume_token) 40 | 41 | 42 | def main(): 43 | loop = asyncio.new_event_loop() 44 | asyncio.set_event_loop(loop) 45 | db: Database = dependency.get_mongo_db(loop) 46 | producer = dependency.get_kafka_producer() 47 | 48 | watcher = MongoChangeWatcher(db['order']) 49 | memory = MongoRelayMemory(db['relayProgress']) 50 | 51 | loop.run_until_complete(relay(watcher, memory, producer)) 52 | 53 | 54 | if __name__ == '__main__': 55 | main() 56 | -------------------------------------------------------------------------------- /ordering/relay/memory.py: -------------------------------------------------------------------------------- 1 | from pymongo.database import Collection 2 | 3 | 4 | class MongoRelayMemory: 5 | def __init__(self, collection: Collection): 6 | self.collection: Collection = collection 7 | 8 | async def get_latest_token(self): 9 | memory = await self.collection.find_one({'name': 'main'}) 10 | if not memory: 11 | return None 12 | return memory['latest_token'] 13 | 14 | async def save_token(self, token): 15 | filter_ = {'name': 'main'} 16 | update = {'$set': {'latest_token': token}} 17 | await self.collection.update_one(filter_, update, upsert=True) 18 | -------------------------------------------------------------------------------- /ordering/rest.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uvicorn-gunicorn-fastapi:python3.8 2 | 3 | COPY ./domain.requirements.txt /domain.requirements.txt 4 | RUN pip install -r /domain.requirements.txt 5 | COPY ./infrastructure.requirements.txt /infrastructure.requirements.txt 6 | RUN pip install -r /infrastructure.requirements.txt 7 | COPY ./rest.requirements.txt /rest.requirements.txt 8 | RUN pip install -r /rest.requirements.txt 9 | 10 | COPY ./domain /app/domain 11 | COPY ./infrastructure /app/infrastructure 12 | COPY ./adapter /app/adapter 13 | COPY ./rest /app/rest 14 | 15 | ENV PYTHONPATH /app/rest 16 | 17 | ENV APP_MODULE rest:app 18 | ENV PORT 8080 19 | 20 | CMD sleep 10 && uvicorn $APP_MODULE --host 0.0.0.0 --port $PORT --reload 21 | -------------------------------------------------------------------------------- /ordering/rest.requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi>=0.68.1,<0.69.0 2 | -------------------------------------------------------------------------------- /ordering/rest/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import app 2 | -------------------------------------------------------------------------------- /ordering/rest/dependency.py: -------------------------------------------------------------------------------- 1 | from domain.registry import Registry 2 | from adapter.all_orders.mongo import AllOrdersInMongo 3 | from infrastructure.mongo import MongoDBConfig, create_mongo_db 4 | 5 | 6 | def inject(): 7 | mongo_db = create_mongo_db(MongoDBConfig()) 8 | Registry().all_orders = AllOrdersInMongo(mongo_db) 9 | -------------------------------------------------------------------------------- /ordering/rest/endpoint.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException 2 | 3 | from domain import usecase 4 | from domain.model.order.exception import OrderSubmittedException, InvalidOrderItemAmountException 5 | from domain.model.order.repository.exception import InvalidOrderIdException, OrderNotFoundException 6 | 7 | from .schema import ( 8 | CreateOrder, UpdateOrderItem, CreationSuccess, OrderItemUpdateSuccess, 9 | OrderDetail, OrderSubmissionSuccess, 10 | ) 11 | 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.post('', response_model=CreationSuccess) 17 | async def create_order(order: CreateOrder): 18 | items = [(t.product_id, t.amount) for t in order.items] 19 | order_id = await usecase.create_new_order(customer_id=order.customer_id, items=items) 20 | return CreationSuccess(id=order_id, message='order created') 21 | 22 | 23 | @router.get('/{orderId}', response_model=OrderDetail) 24 | async def get_order(order_id: str): 25 | try: 26 | order = await usecase.get_order(order_id=order_id) 27 | except (InvalidOrderIdException, OrderNotFoundException): 28 | raise HTTPException(404, 'Order not found') 29 | 30 | return OrderDetail.from_order(order) 31 | 32 | 33 | @router.put('/{orderId}/items/{productId}', response_model=OrderItemUpdateSuccess) 34 | async def update_item_amount_to_order(order_id: str, product_id: str, order_item: UpdateOrderItem): 35 | try: 36 | result = await usecase.update_order_item_amount(order_id=order_id, product_id=product_id, 37 | amount=order_item.amount) 38 | except OrderSubmittedException: 39 | raise HTTPException(405, 'Submitted Order cannot be updated') 40 | except InvalidOrderItemAmountException as e: 41 | raise HTTPException(403, str(e)) 42 | 43 | return OrderItemUpdateSuccess(orderId=result.order_id, productId=result.product_id, amount=result.amount, 44 | message='order item updated') 45 | 46 | 47 | @router.put('/{orderId}/submission', response_model=OrderSubmissionSuccess) 48 | async def submit_order(order_id: str): 49 | try: 50 | await usecase.submit_order(order_id=order_id) 51 | except OrderSubmittedException: 52 | raise HTTPException(405, 'Submitted Order cannot be updated') 53 | 54 | return OrderSubmissionSuccess(id=order_id, message='order submitted') 55 | -------------------------------------------------------------------------------- /ordering/rest/event_handler.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from . import dependency 3 | 4 | 5 | async def startup(app: FastAPI): 6 | dependency.inject() 7 | 8 | 9 | async def shutdown(app: FastAPI): 10 | pass 11 | -------------------------------------------------------------------------------- /ordering/rest/main.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from fastapi import FastAPI 3 | 4 | from .event_handler import startup, shutdown 5 | 6 | from . import endpoint 7 | 8 | 9 | def create_app(): 10 | fast_app = FastAPI(title='Ordering Service') 11 | fast_app.add_event_handler('startup', func=partial(startup, app=fast_app)) 12 | fast_app.add_event_handler('shutdown', func=partial(shutdown, app=fast_app)) 13 | fast_app.include_router(endpoint.router, prefix='/orders', tags=['order']) 14 | return fast_app 15 | 16 | 17 | app = create_app() 18 | -------------------------------------------------------------------------------- /ordering/rest/schema.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pydantic import BaseModel, Field 3 | 4 | from domain.usecase import OrderState 5 | 6 | 7 | class OrderItem(BaseModel): 8 | product_id: str = Field(..., alias='productId') 9 | amount: int 10 | 11 | 12 | class OrderDetail(BaseModel): 13 | id_: str = Field(..., alias='id') 14 | items: List[OrderItem] 15 | customer_id: str = Field(..., alias='customerId') 16 | submitted: bool = False 17 | 18 | @classmethod 19 | def from_order(cls, order: OrderState) -> 'OrderDetail': 20 | items = [OrderItem(productId=t.product_id, amount=t.amount) for t in order.items] 21 | return OrderDetail(id=order.id_, items=items, customerId=order.customer_id, 22 | submitted=order.submitted) 23 | 24 | 25 | class CreateOrder(BaseModel): 26 | items: List[OrderItem] 27 | customer_id: str = Field(..., alias='customerId') 28 | 29 | 30 | class UpdateOrderItem(BaseModel): 31 | amount: int 32 | 33 | 34 | class CreationSuccess(BaseModel): 35 | id_: str = Field(..., alias='id') 36 | message: str 37 | 38 | 39 | class OrderItemUpdateSuccess(BaseModel): 40 | order_id: str = Field(..., alias='orderId') 41 | product_id: str = Field(..., alias='productId') 42 | amount: int 43 | message: str 44 | 45 | 46 | class OrderSubmissionSuccess(BaseModel): 47 | id_: str = Field(..., alias='id') 48 | message: str 49 | --------------------------------------------------------------------------------