├── README.md ├── appendix_c ├── orders │ ├── Pipfile │ ├── Pipfile.lock │ ├── alembic.ini │ ├── jwt_generator.py │ ├── kitchen.yaml │ ├── machine_to_machine_test.py │ ├── migrations │ │ ├── README │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ ├── bd1046019404_initial_migration.py │ │ │ └── cf6a8fb1fd44_add_user_id_to_order_table.py │ ├── oas.yaml │ ├── orders │ │ ├── exceptions.py │ │ ├── orders_service │ │ │ ├── exceptions.py │ │ │ ├── orders.py │ │ │ └── orders_service.py │ │ ├── repository │ │ │ ├── models.py │ │ │ ├── orders_repository.py │ │ │ └── unit_of_work.py │ │ └── web │ │ │ ├── api │ │ │ ├── api.py │ │ │ ├── auth.py │ │ │ └── schemas.py │ │ │ └── app.py │ ├── package.json │ ├── payments.yaml │ ├── private_key.pem │ ├── pubkey.pem │ ├── public_key.pem │ └── yarn.lock └── ui │ ├── .gitignore │ ├── README.md │ ├── babel.config.js │ ├── package.json │ ├── public │ ├── copy.png │ ├── favicon.ico │ ├── github.png │ ├── index.html │ ├── microapis.png │ ├── reddit.png │ ├── shopping-cart.png │ ├── twitter.png │ └── youtube.png │ ├── src │ ├── App.vue │ ├── assets │ │ └── logo.png │ ├── auth │ │ └── index.js │ ├── main.ts │ ├── products.ts │ ├── shims-vue.d.ts │ └── store │ │ └── index.ts │ ├── tsconfig.json │ └── yarn.lock ├── ch02 ├── Pipfile ├── Pipfile.lock ├── oas.yaml └── orders │ ├── api │ ├── api.py │ └── schemas.py │ └── app.py ├── ch05 └── oas.yaml ├── ch06 ├── kitchen │ ├── Pipfile │ ├── Pipfile.lock │ ├── api │ │ ├── api.py │ │ └── schemas.py │ ├── app.py │ ├── config.py │ └── oas.yaml └── orders │ ├── Pipfile │ ├── Pipfile.lock │ ├── oas.yaml │ └── orders │ ├── api │ ├── api.py │ └── schemas.py │ ├── app.py │ └── exceptions.py ├── ch07 ├── Pipfile ├── Pipfile.lock ├── alembic.ini ├── kitchen.yaml ├── migrations │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ └── bd1046019404_initial_migration.py ├── oas.yaml ├── orders │ ├── orders_service │ │ ├── exceptions.py │ │ ├── orders.py │ │ └── orders_service.py │ ├── repository │ │ ├── models.py │ │ ├── orders_repository.py │ │ └── unit_of_work.py │ └── web │ │ ├── api │ │ ├── api.py │ │ └── schemas.py │ │ └── app.py ├── package.json ├── payments.yaml └── yarn.lock ├── ch08 └── schema.graphql ├── ch09 ├── Pipfile ├── Pipfile.lock ├── client.py ├── package.json ├── schema.graphql └── yarn.lock ├── ch10 ├── Pipfile ├── Pipfile.lock ├── exceptions.py ├── server.py └── web │ ├── data.py │ ├── mutations.py │ ├── products.graphql │ ├── queries.py │ ├── schema.py │ └── types.py ├── ch11 ├── Pipfile ├── Pipfile.lock ├── alembic.ini ├── exceptions.py ├── jwt_generator.py ├── kitchen.yaml ├── machine_to_machine_test.py ├── migrations │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── bd1046019404_initial_migration.py │ │ └── cf6a8fb1fd44_add_user_id_to_order_table.py ├── oas.yaml ├── orders │ ├── exceptions.py │ ├── orders_service │ │ ├── exceptions.py │ │ ├── orders.py │ │ └── orders_service.py │ ├── repository │ │ ├── models.py │ │ ├── orders_repository.py │ │ └── unit_of_work.py │ └── web │ │ ├── api │ │ ├── api.py │ │ ├── auth.py │ │ └── schemas.py │ │ └── app.py ├── package.json ├── payments.yaml ├── private_key.pem ├── pubkey.pem ├── public_key.pem └── yarn.lock ├── ch12 ├── README.md ├── orders │ ├── Pipfile │ ├── Pipfile.lock │ ├── hooks.py │ ├── oas.yaml │ ├── oas_with_links.yaml │ ├── orders │ │ ├── api │ │ │ ├── api.py │ │ │ └── schemas.py │ │ ├── app.py │ │ └── exceptions.py │ ├── package.json │ ├── test.py │ └── yarn.lock └── products │ ├── Pipfile │ ├── Pipfile.lock │ ├── exceptions.py │ ├── package.json │ ├── server.py │ ├── test.py │ ├── web │ ├── data.py │ ├── mutations.py │ ├── products.graphql │ ├── queries.py │ ├── schema.py │ └── types.py │ └── yarn.lock ├── ch13 ├── Dockerfile ├── Pipfile ├── Pipfile.lock ├── alembic.ini ├── docker-compose.yaml ├── kitchen.yaml ├── machine_to_machine_test.py ├── migrations │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── bd1046019404_initial_migration.py │ │ └── cf6a8fb1fd44_add_user_id_to_order_table.py ├── oas.yaml ├── orders │ ├── exceptions.py │ ├── orders_service │ │ ├── exceptions.py │ │ ├── orders.py │ │ └── orders_service.py │ ├── repository │ │ ├── models.py │ │ ├── orders_repository.py │ │ └── unit_of_work.py │ └── web │ │ ├── api │ │ ├── api.py │ │ ├── auth.py │ │ └── schemas.py │ │ └── app.py ├── package.json ├── payments.yaml ├── private.pem ├── pubkey.pem ├── public_key.pem └── yarn.lock └── ch14 ├── Dockerfile ├── Pipfile ├── Pipfile.lock ├── alb_controller_policy.json ├── alembic.ini ├── docker-compose.yaml ├── kitchen.yaml ├── machine_to_machine_test.py ├── migrations.dockerfile ├── migrations ├── README ├── env.py ├── script.py.mako └── versions │ ├── bd1046019404_initial_migration.py │ └── cf6a8fb1fd44_add_user_id_to_order_table.py ├── oas.yaml ├── orders-migrations-job.yaml ├── orders-service-deployment.yaml ├── orders-service-ingress.yaml ├── orders-service.yaml ├── orders ├── exceptions.py ├── orders_service │ ├── exceptions.py │ ├── orders.py │ └── orders_service.py ├── repository │ ├── models.py │ ├── orders_repository.py │ └── unit_of_work.py └── web │ ├── api │ ├── api.py │ ├── auth.py │ └── schemas.py │ └── app.py ├── package.json ├── payments.yaml ├── private.pem ├── pubkey.pem ├── public_key.pem └── yarn.lock /appendix_c/orders/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | alembic = "*" 8 | black = "*" 9 | 10 | [packages] 11 | cryptography = "*" 12 | fastapi = "*" 13 | pyjwt = "*" 14 | requests = "*" 15 | sqlalchemy = "*" 16 | uvicorn = "*" 17 | pyyaml = "*" 18 | 19 | [requires] 20 | python_version = "3.10" 21 | 22 | [pipenv] 23 | allow_prereleases = true 24 | -------------------------------------------------------------------------------- /appendix_c/orders/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | sqlalchemy.url = sqlite:///orders.db 39 | 40 | 41 | [post_write_hooks] 42 | # post_write_hooks defines scripts or Python functions that are run 43 | # on newly generated revision scripts. See the documentation for further 44 | # detail and examples 45 | 46 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 47 | # hooks=black 48 | # black.type=console_scripts 49 | # black.entrypoint=black 50 | # black.options=-l 79 51 | 52 | # Logging configuration 53 | [loggers] 54 | keys = root,sqlalchemy,alembic 55 | 56 | [handlers] 57 | keys = console 58 | 59 | [formatters] 60 | keys = generic 61 | 62 | [logger_root] 63 | level = WARN 64 | handlers = console 65 | qualname = 66 | 67 | [logger_sqlalchemy] 68 | level = WARN 69 | handlers = 70 | qualname = sqlalchemy.engine 71 | 72 | [logger_alembic] 73 | level = INFO 74 | handlers = 75 | qualname = alembic 76 | 77 | [handler_console] 78 | class = StreamHandler 79 | args = (sys.stderr,) 80 | level = NOTSET 81 | formatter = generic 82 | 83 | [formatter_generic] 84 | format = %(levelname)-5.5s [%(name)s] %(message)s 85 | datefmt = %H:%M:%S 86 | -------------------------------------------------------------------------------- /appendix_c/orders/jwt_generator.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from pathlib import Path 3 | 4 | import jwt 5 | from cryptography.hazmat.primitives import serialization 6 | 7 | 8 | def generate_jwt(): 9 | now = datetime.utcnow() 10 | payload = { 11 | "iss": "https://auth.coffeemesh.io/", 12 | "sub": "ec7bbccf-ca89-4af3-82ac-b41e4831a962", 13 | "aud": "http://127.0.0.1:8000/orders", 14 | "iat": now.timestamp(), 15 | "exp": (now + timedelta(hours=24)).timestamp(), 16 | "scope": "openid", 17 | } 18 | 19 | private_key_text = Path("private_key.pem").read_text() 20 | private_key = serialization.load_pem_private_key( 21 | private_key_text.encode(), 22 | password=None, 23 | ) 24 | return jwt.encode(payload=payload, key=private_key, algorithm="RS256") 25 | 26 | 27 | print(generate_jwt()) 28 | -------------------------------------------------------------------------------- /appendix_c/orders/machine_to_machine_test.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def get_access_token(): 5 | payload = { 6 | "client_id": "", 7 | "client_secret": "", 8 | "audience": "http://127.0.0.1:8000/orders", 9 | "grant_type": "client_credentials" 10 | } 11 | 12 | response = requests.post( 13 | "https://coffeemesh-dev.eu.auth0.com/oauth/token", 14 | json=payload, 15 | headers={'content-type': "application/json"} 16 | ) 17 | 18 | return response.json()['access_token'] 19 | 20 | 21 | def create_order(token): 22 | order_payload = { 23 | 'order': [{ 24 | 'product': 'asdf', 25 | 'size': 'small', 26 | 'quantity': 1 27 | }] 28 | } 29 | 30 | order = requests.post( 31 | 'http://127.0.0.1:8000/orders', 32 | json=order_payload, 33 | headers={'content-type': "application/json", "Authorization": f"Bearer {token}"} 34 | ) 35 | 36 | return order.json() 37 | 38 | 39 | access_token = get_access_token() 40 | print(access_token) 41 | order = create_order(access_token) 42 | print(order) 43 | -------------------------------------------------------------------------------- /appendix_c/orders/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /appendix_c/orders/migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | 6 | from alembic import context 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | 12 | # Interpret the config file for Python logging. 13 | # This line sets up loggers basically. 14 | fileConfig(config.config_file_name) 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | from orders.repository.models import Base 19 | target_metadata = Base.metadata 20 | # target_metadata = None 21 | 22 | # other values from the config, defined by the needs of env.py, 23 | # can be acquired: 24 | # my_important_option = config.get_main_option("my_important_option") 25 | # ... etc. 26 | 27 | 28 | def run_migrations_offline(): 29 | """Run migrations in 'offline' mode. 30 | 31 | This configures the context with just a URL 32 | and not an Engine, though an Engine is acceptable 33 | here as well. By skipping the Engine creation 34 | we don't even need a DBAPI to be available. 35 | 36 | Calls to context.execute() here emit the given string to the 37 | script output. 38 | 39 | """ 40 | url = config.get_main_option("sqlalchemy.url") 41 | context.configure( 42 | url=url, 43 | target_metadata=target_metadata, 44 | literal_binds=True, 45 | dialect_opts={"paramstyle": "named"}, 46 | ) 47 | 48 | with context.begin_transaction(): 49 | context.run_migrations() 50 | 51 | 52 | def run_migrations_online(): 53 | """Run migrations in 'online' mode. 54 | 55 | In this scenario we need to create an Engine 56 | and associate a connection with the context. 57 | 58 | """ 59 | connectable = engine_from_config( 60 | config.get_section(config.config_ini_section), 61 | prefix="sqlalchemy.", 62 | poolclass=pool.NullPool, 63 | ) 64 | 65 | with connectable.connect() as connection: 66 | context.configure( 67 | connection=connection, 68 | target_metadata=target_metadata, 69 | render_as_batch=True, 70 | ) 71 | 72 | with context.begin_transaction(): 73 | context.run_migrations() 74 | 75 | 76 | if context.is_offline_mode(): 77 | run_migrations_offline() 78 | else: 79 | run_migrations_online() 80 | -------------------------------------------------------------------------------- /appendix_c/orders/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /appendix_c/orders/migrations/versions/bd1046019404_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: bd1046019404 4 | Revises: 5 | Create Date: 2020-12-06 21:44:18.071169 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'bd1046019404' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('order', 22 | sa.Column('id', sa.String(), nullable=False), 23 | sa.Column('status', sa.String(), nullable=False), 24 | sa.Column('created', sa.DateTime(), nullable=True), 25 | sa.Column('schedule_id', sa.String(), nullable=True), 26 | sa.Column('delivery_id', sa.String(), nullable=True), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | op.create_table('order_item', 30 | sa.Column('id', sa.String(), nullable=False), 31 | sa.Column('order_id', sa.String(), nullable=True), 32 | sa.Column('product', sa.String(), nullable=False), 33 | sa.Column('size', sa.String(), nullable=False), 34 | sa.Column('quantity', sa.Integer(), nullable=False), 35 | sa.ForeignKeyConstraint(['order_id'], ['order.id'], ), 36 | sa.PrimaryKeyConstraint('id') 37 | ) 38 | # ### end Alembic commands ### 39 | 40 | 41 | def downgrade(): 42 | # ### commands auto generated by Alembic - please adjust! ### 43 | op.drop_table('order_item') 44 | op.drop_table('order') 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /appendix_c/orders/migrations/versions/cf6a8fb1fd44_add_user_id_to_order_table.py: -------------------------------------------------------------------------------- 1 | """Add user id to order table 2 | 3 | Revision ID: cf6a8fb1fd44 4 | Revises: bd1046019404 5 | Create Date: 2021-11-07 12:28:17.852145 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'cf6a8fb1fd44' 14 | down_revision = 'bd1046019404' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('order', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('user_id', sa.String(), nullable=False)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('order', schema=None) as batch_op: 30 | batch_op.drop_column('user_id') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /appendix_c/orders/orders/exceptions.py: -------------------------------------------------------------------------------- 1 | class OutOfStockError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /appendix_c/orders/orders/orders_service/exceptions.py: -------------------------------------------------------------------------------- 1 | class OrderNotFoundError(Exception): 2 | pass 3 | 4 | 5 | class APIIntegrationError(Exception): 6 | pass 7 | 8 | 9 | class InvalidActionError(Exception): 10 | pass 11 | -------------------------------------------------------------------------------- /appendix_c/orders/orders/orders_service/orders.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from orders.orders_service.exceptions import APIIntegrationError, InvalidActionError 4 | 5 | 6 | class OrderItem: 7 | def __init__(self, id, product, quantity, size): 8 | self.id = id 9 | self.product = product 10 | self.quantity = quantity 11 | self.size = size 12 | 13 | def dict(self): 14 | return {"product": self.product, "size": self.size, "quantity": self.quantity} 15 | 16 | 17 | class Order: 18 | def __init__( 19 | self, 20 | id, 21 | created, 22 | items, 23 | status, 24 | schedule_id=None, 25 | delivery_id=None, 26 | order_=None, 27 | ): 28 | self._order = order_ 29 | self._id = id 30 | self._created = created 31 | self.items = [OrderItem(**item) for item in items] 32 | self._status = status 33 | self.schedule_id = schedule_id 34 | self.delivery_id = delivery_id 35 | 36 | @property 37 | def id(self): 38 | return self._id or self._order.id 39 | 40 | @property 41 | def created(self): 42 | return self._created or self._order.created 43 | 44 | @property 45 | def status(self): 46 | return self._status or self._order.status 47 | 48 | def cancel(self): 49 | if self.status == "progress": 50 | response = requests.post( 51 | f"http://localhost:3000/kitchen/schedules/{self.schedule_id}/cancel", 52 | json={"order": [item.dict() for item in self.items]}, 53 | ) 54 | if response.status_code == 200: 55 | return 56 | raise APIIntegrationError(f"Could not cancel order with id {self.id}") 57 | if self.status == "delivery": 58 | raise InvalidActionError(f"Cannot cancel order with id {self.id}") 59 | 60 | def pay(self): 61 | response = requests.post( 62 | "http://localhost:3001/payments", json={"order_id": self.id} 63 | ) 64 | if response.status_code == 201: 65 | return 66 | raise APIIntegrationError( 67 | f"Could not process payment for order with id {self.id}" 68 | ) 69 | 70 | def schedule(self): 71 | response = requests.post( 72 | "http://localhost:3000/kitchen/schedules", 73 | json={"order": [item.dict() for item in self.items]}, 74 | ) 75 | if response.status_code == 201: 76 | return response.json()["id"] 77 | raise APIIntegrationError(f"Could not schedule order with id {self.id}") 78 | 79 | def dict(self): 80 | return { 81 | "id": self.id, 82 | "order": [item.dict() for item in self.items], 83 | "status": self.status, 84 | "created": self.created, 85 | } 86 | -------------------------------------------------------------------------------- /appendix_c/orders/orders/orders_service/orders_service.py: -------------------------------------------------------------------------------- 1 | from orders.orders_service.exceptions import OrderNotFoundError 2 | from orders.repository.orders_repository import OrdersRepository 3 | 4 | 5 | class OrdersService: 6 | def __init__(self, orders_repository: OrdersRepository): 7 | self.orders_repository = orders_repository 8 | 9 | def place_order(self, items, user_id): 10 | return self.orders_repository.add(items, user_id) 11 | 12 | def get_order(self, order_id, **filters): 13 | order = self.orders_repository.get(order_id, **filters) 14 | if order is not None: 15 | return order 16 | raise OrderNotFoundError(f"Order with id {order_id} not found") 17 | 18 | def update_order(self, order_id, user_id, **payload): 19 | order = self.orders_repository.get(order_id, user_id=user_id) 20 | if order is None: 21 | raise OrderNotFoundError(f"Order with id {order_id} not found") 22 | return self.orders_repository.update(order_id, **payload) 23 | 24 | def list_orders(self, **filters): 25 | limit = filters.pop("limit", None) 26 | return self.orders_repository.list(limit=limit, **filters) 27 | 28 | def pay_order(self, order_id, user_id): 29 | order = self.orders_repository.get(order_id, user_id=user_id) 30 | if order is None: 31 | raise OrderNotFoundError(f"Order with id {order_id} not found") 32 | order.pay() 33 | schedule_id = order.schedule() 34 | return self.orders_repository.update( 35 | order_id, status="progress", schedule_id=schedule_id 36 | ) 37 | 38 | def cancel_order(self, order_id, user_id): 39 | order = self.orders_repository.get(order_id, user_id=user_id) 40 | if order is None: 41 | raise OrderNotFoundError(f"Order with id {order_id} not found") 42 | order.cancel() 43 | return self.orders_repository.update(order_id, status="cancelled") 44 | 45 | def delete_order(self, order_id, user_id): 46 | order = self.orders_repository.get(order_id, user_id=user_id) 47 | if order is None: 48 | raise OrderNotFoundError(f"Order with id {order_id} not found") 49 | return self.orders_repository.delete(order_id) 50 | -------------------------------------------------------------------------------- /appendix_c/orders/orders/repository/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from sqlalchemy import Column, Integer, String, ForeignKey, DateTime 5 | from sqlalchemy.ext.declarative import declarative_base 6 | from sqlalchemy.orm import relationship 7 | 8 | Base = declarative_base() 9 | 10 | 11 | def generate_uuid(): 12 | return str(uuid.uuid4()) 13 | 14 | 15 | class OrderModel(Base): 16 | __tablename__ = "order" 17 | 18 | id = Column(String, primary_key=True, default=generate_uuid) 19 | user_id = Column(String, nullable=False) 20 | items = relationship("OrderItemModel", backref="order") 21 | status = Column(String, nullable=False, default="created") 22 | created = Column(DateTime, default=datetime.utcnow) 23 | schedule_id = Column(String) 24 | delivery_id = Column(String) 25 | 26 | def dict(self): 27 | return { 28 | "id": self.id, 29 | "items": [item.dict() for item in self.items], 30 | "status": self.status, 31 | "created": self.created, 32 | "schedule_id": self.schedule_id, 33 | "delivery_id": self.delivery_id, 34 | } 35 | 36 | 37 | class OrderItemModel(Base): 38 | __tablename__ = "order_item" 39 | 40 | id = Column(String, primary_key=True, default=generate_uuid) 41 | order_id = Column(String, ForeignKey("order.id")) 42 | product = Column(String, nullable=False) 43 | size = Column(String, nullable=False) 44 | quantity = Column(Integer, nullable=False) 45 | 46 | def dict(self): 47 | return { 48 | "id": self.id, 49 | "product": self.product, 50 | "size": self.size, 51 | "quantity": self.quantity, 52 | } 53 | -------------------------------------------------------------------------------- /appendix_c/orders/orders/repository/orders_repository.py: -------------------------------------------------------------------------------- 1 | from orders.orders_service.orders import Order 2 | from orders.repository.models import OrderModel, OrderItemModel 3 | 4 | 5 | class OrdersRepository: 6 | def __init__(self, session): 7 | self.session = session 8 | 9 | def add(self, items, user_id): 10 | record = OrderModel( 11 | items=[OrderItemModel(**item) for item in items], user_id=user_id 12 | ) 13 | self.session.add(record) 14 | return Order(**record.dict(), order_=record) 15 | 16 | def _get(self, id_, **filters): 17 | return ( 18 | self.session.query(OrderModel) 19 | .filter(OrderModel.id == str(id_)) 20 | .filter_by(**filters) 21 | .first() 22 | ) 23 | 24 | def get(self, id_, **filters): 25 | order = self._get(id_, **filters) 26 | if order is not None: 27 | return Order(**order.dict()) 28 | 29 | def list(self, limit=None, **filters): 30 | query = self.session.query(OrderModel) 31 | if "cancelled" in filters: 32 | cancelled = filters.pop("cancelled") 33 | if cancelled: 34 | query = query.filter(OrderModel.status == "cancelled") 35 | else: 36 | query = query.filter(OrderModel.status != "cancelled") 37 | records = query.filter_by(**filters).limit(limit).all() 38 | return [Order(**record.dict()) for record in records] 39 | 40 | def update(self, id_, **payload): 41 | record = self._get(id_) 42 | if "items" in payload: 43 | for item in record.items: 44 | self.session.delete(item) 45 | record.items = [OrderItemModel(**item) for item in payload.pop("items")] 46 | for key, value in payload.items(): 47 | setattr(record, key, value) 48 | return Order(**record.dict()) 49 | 50 | def delete(self, id_): 51 | self.session.delete(self._get(id_)) 52 | -------------------------------------------------------------------------------- /appendix_c/orders/orders/repository/unit_of_work.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.orm import sessionmaker 5 | 6 | 7 | DB_URL = os.getenv('DB_URL') 8 | 9 | assert DB_URL is not None, 'DB_URL environment variable needed.' 10 | 11 | 12 | class UnitOfWork: 13 | def __init__(self): 14 | self.session_maker = sessionmaker(bind=create_engine(DB_URL)) 15 | 16 | def __enter__(self): 17 | self.session = self.session_maker() 18 | return self 19 | 20 | def __exit__(self, exc_type, exc_val, traceback): 21 | if exc_type is not None: 22 | self.rollback() 23 | self.session.close() 24 | self.session.close() 25 | 26 | def commit(self): 27 | self.session.commit() 28 | 29 | def rollback(self): 30 | self.session.rollback() 31 | -------------------------------------------------------------------------------- /appendix_c/orders/orders/web/api/auth.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import requests 3 | from cryptography.x509 import load_pem_x509_certificate 4 | 5 | 6 | X509_CERT_TEMPLATE = "-----BEGIN CERTIFICATE-----\n{key}\n-----END CERTIFICATE-----" 7 | 8 | 9 | public_keys = requests.get( 10 | "https://coffeemesh-dev.eu.auth0.com/.well-known/jwks.json" 11 | ).json()["keys"] 12 | 13 | 14 | def _get_certificate_for_kid(kid): 15 | """ 16 | Return the public key whose ID matches the provided kid. 17 | If no match is found, an exception is raised. 18 | """ 19 | for key in public_keys: 20 | if key["kid"] == kid: 21 | return key["x5c"][0] 22 | raise Exception(f"Not matching key found for kid {kid}") 23 | 24 | 25 | def load_public_key_from_x509_cert(certificate): 26 | """ 27 | Loads the public signing key into a RSAPublicKey object. To do that, 28 | we first need to format the key into a PEM certificate and make sure 29 | it's utf-8 encoded. We can then load the key using cryptography's 30 | convenient `load_pem_x509_certificate` function. 31 | """ 32 | return load_pem_x509_certificate(certificate).public_key() 33 | 34 | 35 | def decode_and_validate_token(access_token): 36 | """ 37 | Validates an access token. If the token is valid, it returns the token payload. 38 | """ 39 | unverified_headers = jwt.get_unverified_header(access_token) 40 | x509_certificate = _get_certificate_for_kid(unverified_headers["kid"]) 41 | public_key = load_public_key_from_x509_cert( 42 | X509_CERT_TEMPLATE.format(key=x509_certificate).encode("utf-8") 43 | ) 44 | return jwt.decode( 45 | access_token, 46 | key=public_key, 47 | algorithms=unverified_headers["alg"], 48 | audience=[ 49 | "http://127.0.0.1:8000/orders", 50 | "https://coffeemesh-dev.eu.auth0.com/userinfo", 51 | ], 52 | ) 53 | -------------------------------------------------------------------------------- /appendix_c/orders/orders/web/api/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import List, Optional 4 | from uuid import UUID 5 | 6 | from pydantic import BaseModel, Extra, conint, conlist, validator 7 | 8 | 9 | class Size(Enum): 10 | small = "small" 11 | medium = "medium" 12 | big = "big" 13 | 14 | 15 | class Status(Enum): 16 | created = "created" 17 | paid = "paid" 18 | progress = "progress" 19 | cancelled = "cancelled" 20 | dispatched = "dispatched" 21 | delivered = "delivered" 22 | 23 | 24 | class OrderItemSchema(BaseModel): 25 | product: str 26 | size: Size 27 | quantity: Optional[conint(ge=1, strict=True)] = 1 28 | 29 | class Config: 30 | extra = Extra.forbid 31 | 32 | @validator("quantity") 33 | def quantity_non_nullable(cls, value): 34 | assert value is not None, "quantity may not be None" 35 | return value 36 | 37 | 38 | class CreateOrderSchema(BaseModel): 39 | order: conlist(OrderItemSchema, min_items=1) 40 | 41 | class Config: 42 | extra = Extra.forbid 43 | 44 | 45 | class GetOrderSchema(CreateOrderSchema): 46 | id: UUID 47 | created: datetime 48 | status: Status 49 | 50 | 51 | class GetOrdersSchema(BaseModel): 52 | orders: List[GetOrderSchema] 53 | 54 | class Config: 55 | extra = Extra.forbid 56 | -------------------------------------------------------------------------------- /appendix_c/orders/orders/web/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import yaml 5 | from fastapi import FastAPI 6 | from jwt import ( 7 | ExpiredSignatureError, 8 | ImmatureSignatureError, 9 | InvalidAlgorithmError, 10 | InvalidAudienceError, 11 | InvalidKeyError, 12 | InvalidSignatureError, 13 | InvalidTokenError, 14 | MissingRequiredClaimError, 15 | ) 16 | from starlette import status 17 | from starlette.middleware.base import RequestResponseEndpoint, BaseHTTPMiddleware 18 | from starlette.middleware.cors import CORSMiddleware 19 | from starlette.requests import Request 20 | from starlette.responses import Response, JSONResponse 21 | 22 | from orders.web.api.auth import decode_and_validate_token 23 | 24 | app = FastAPI(debug=True, openapi_url="/openapi/orders.json", docs_url="/docs/orders") 25 | 26 | oas_doc = yaml.safe_load((Path(__file__).parent / "../../oas.yaml").read_text()) 27 | 28 | app.openapi = lambda: oas_doc 29 | 30 | 31 | class AuthorizeRequestMiddleware(BaseHTTPMiddleware): 32 | async def dispatch( 33 | self, request: Request, call_next: RequestResponseEndpoint 34 | ) -> Response: 35 | if os.getenv("AUTH_ON", "False") != "True": 36 | request.state.user_id = "test" 37 | return await call_next(request) 38 | 39 | if request.url.path in ["/docs/orders", "/openapi/orders.json"]: 40 | return await call_next(request) 41 | if request.method == "OPTIONS": 42 | return await call_next(request) 43 | 44 | bearer_token = request.headers.get("Authorization") 45 | if not bearer_token: 46 | return JSONResponse( 47 | status_code=status.HTTP_401_UNAUTHORIZED, 48 | content={ 49 | "detail": "Missing access token", 50 | "body": "Missing access token", 51 | }, 52 | ) 53 | try: 54 | auth_token = bearer_token.split(" ")[1].strip() 55 | token_payload = decode_and_validate_token(auth_token) 56 | except ( 57 | ExpiredSignatureError, 58 | ImmatureSignatureError, 59 | InvalidAlgorithmError, 60 | InvalidAudienceError, 61 | InvalidKeyError, 62 | InvalidSignatureError, 63 | InvalidTokenError, 64 | MissingRequiredClaimError, 65 | ) as error: 66 | return JSONResponse( 67 | status_code=status.HTTP_401_UNAUTHORIZED, 68 | content={"detail": str(error), "body": str(error)}, 69 | ) 70 | else: 71 | request.state.user_id = token_payload["sub"] 72 | return await call_next(request) 73 | 74 | 75 | app.add_middleware(AuthorizeRequestMiddleware) 76 | 77 | 78 | app.add_middleware( 79 | CORSMiddleware, 80 | allow_origins=["*"], 81 | allow_credentials=True, 82 | allow_methods=["*"], 83 | allow_headers=["*"], 84 | ) 85 | 86 | 87 | from orders.web.api import api 88 | -------------------------------------------------------------------------------- /appendix_c/orders/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@stoplight/prism-cli": "^4.1.1" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /appendix_c/orders/payments.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | 3 | info: 4 | title: Payments API 5 | description: API to process payments for orders 6 | version: 1.0.0 7 | 8 | servers: 9 | - url: https://coffeemesh.com 10 | description: main production server 11 | - url: https://coffeemesh-staging.com 12 | description: staging server for testing purposes only 13 | 14 | paths: 15 | /payments: 16 | post: 17 | summary: Schedules an order for production 18 | requestBody: 19 | required: true 20 | content: 21 | application/json: 22 | schema: 23 | type: object 24 | required: 25 | - order_id 26 | - status 27 | properties: 28 | order_id: 29 | type: string 30 | format: uuid 31 | 32 | responses: 33 | '200': 34 | description: A JSON representation of the scheduled order 35 | content: 36 | application/json: 37 | schema: 38 | type: object 39 | required: 40 | - payment_id 41 | properties: 42 | payment_id: 43 | type: string 44 | format: uuid 45 | status: 46 | type: string 47 | enum: 48 | - invalid 49 | - paid 50 | - pending 51 | -------------------------------------------------------------------------------- /appendix_c/orders/private_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDSoqxmFnnH1RfV 3 | lgePZH5Q7ekYe6fyjKu+IsTbRLcQAmTs+H0EiwUZ6UELwZ2wOOE6j4XHMPf1yiO/ 4 | ojAlN8u3Sg98sDJTnF68vRyLmBEdqonR50pC9FIkNVC9m8XFlvRwtojdvLSl1dwM 5 | e32n789XOjRCZ6RNvEhEnvsBBjSYnXmX25R7owk5qUMetJeJQQK5uFU2I13NONU0 6 | TirzFN2rhdamzpKHRzHjjvfsZfXlaLhzjLfzpk3VTq+fOfitVSHx2lOEHQzXALl2 7 | CtsOEk2Lu2ffsohZwp3nFPjE5mtpUTuRSaEW6OpeprSkWvG4WGYc7BVOOnWNms8e 8 | At5Nt//LAgMBAAECggEAUt3Kw1L+UB7GhLHEgaZAh6hBdu9XEHZFLsVQ+w6akoLO 9 | n+fWj03+EMaSX4Spe+W0viwurkHWm20OCVtOY6YC0DYjx6Mt+XTgVJJ1w3ls6mXo 10 | WJsMvTCPjE0pWZ8J/IU534oAaHPQAhoTuxluQv52bNOqMaHCow56w/xjtXByisNa 11 | AfYh4GVBWyYn+Zbma2Ogp+7ulpYFjNjhLQUKRojDGTAoTTfG+s8hjTKwODp9pzUQ 12 | tCr1GHOo3F3bFacvxQCH0WxvwFP3fmvYK29bxWUl/Z7cM5UwKxqGp6wU6efjqYvI 13 | Fw6Yak+KwFtLjVPF68tN9ntkU9b9/KY+qSsREVsmgQKBgQD7h8QKu48EITz0Zlwd 14 | C2ETK3m0UZRon2jEeXW+PzK6EFTwTnO/wghBLa+RkMvv4NjdenI8rknwYcLrP8Rt 15 | zd4k/mq2VzzsDA0UuDUjXgzbuYMNXpwAlJcVmSfaZF0KDVIZ2uIiWezFiENI0RqT 16 | j7XuXAubiJt8IOInA3CEGoQ6iwKBgQDWYN+CnbPepW4S2cINvwTDFoE1FA2Fic+R 17 | lG2JJ07Q3j/Ewu6f8quHyYUSRQ5DhWxwfe1msaYvKXflBj6C+jr/rAOVnyQ5HXLZ 18 | KfyHLURKn6m8HLEZEZEmAU58nRNq+exZftCWzp6K2WgE7K+1m6FeOl/yRK31fIap 19 | M6o/yNw3wQKBgQCXCk3EjCAzQKpTsGu73Str0X2BtENEGAVXhgAYP+b8J/Z5XwLO 20 | sXs3eHGnHaX447IWPQMAQUCRIoNjtKUFssukt0npOLWSoSHxwTPXixB5mQqDKr7O 21 | 8mtPQurVj9L2yEz2zaNhMVKmw050GWy2E2QSQB+QRBXqEez7tGsKSMoCRQKBgQCI 22 | OM5OBT/CfoRPXie87GBuRuKbg76D2GoZK6PevyeJ+W+z69oNsPnmMttoHJFPvnyF 23 | jr9HviLHXSZeVXVrbO4IgJlWfeValafg7pkUnGMEuCf27JRsRYliCPqCnJ02INFa 24 | nQaWjXyY5kT+vBd64wXLBnTpUVLo5tP6uGW6Wjv1AQKBgHc3OH8IZQSlfMoq5V2J 25 | oOEAv1gNnI4iYwpEqpq6X66wzsTr8JbUYpEsis0DJ7bV3EqPB4g69LJFcEq+/Wi7 26 | LQfORwIw4okEznejZ6C+tGTgvCV2qrDwm7c+F+QkjwmUDJiZHkoldUOTnQJwU78q 27 | vdVhGbUr3cR1U1btKvQBpnBj 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /appendix_c/orders/pubkey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0qKsZhZ5x9UX1ZYHj2R+ 3 | UO3pGHun8oyrviLE20S3EAJk7Ph9BIsFGelBC8GdsDjhOo+FxzD39cojv6IwJTfL 4 | t0oPfLAyU5xevL0ci5gRHaqJ0edKQvRSJDVQvZvFxZb0cLaI3by0pdXcDHt9p+/P 5 | Vzo0QmekTbxIRJ77AQY0mJ15l9uUe6MJOalDHrSXiUECubhVNiNdzTjVNE4q8xTd 6 | q4XWps6Sh0cx44737GX15Wi4c4y386ZN1U6vnzn4rVUh8dpThB0M1wC5dgrbDhJN 7 | i7tn37KIWcKd5xT4xOZraVE7kUmhFujqXqa0pFrxuFhmHOwVTjp1jZrPHgLeTbf/ 8 | ywIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /appendix_c/orders/public_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICpjCCAY4CCQDHM4WGoMj+8zANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDApj 3 | b2ZmZWVtZXNoMB4XDTIxMTEyODE1MzYzN1oXDTIxMTIyODE1MzYzN1owFTETMBEG 4 | A1UEAwwKY29mZmVlbWVzaDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB 5 | ANKirGYWecfVF9WWB49kflDt6Rh7p/KMq74ixNtEtxACZOz4fQSLBRnpQQvBnbA4 6 | 4TqPhccw9/XKI7+iMCU3y7dKD3ywMlOcXry9HIuYER2qidHnSkL0UiQ1UL2bxcWW 7 | 9HC2iN28tKXV3Ax7fafvz1c6NEJnpE28SESe+wEGNJideZfblHujCTmpQx60l4lB 8 | Arm4VTYjXc041TROKvMU3auF1qbOkodHMeOO9+xl9eVouHOMt/OmTdVOr585+K1V 9 | IfHaU4QdDNcAuXYK2w4STYu7Z9+yiFnCnecU+MTma2lRO5FJoRbo6l6mtKRa8bhY 10 | ZhzsFU46dY2azx4C3k23/8sCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAqAL+rxru 11 | kne3GaiwfPqmO1nKa1rkCj4mKCjzH+fbeOcYquDO1T1zd+8VWynHZUznWn15fmKF 12 | BP5foCP4xjJlNtCztDNmtDH6RVdZ3tOw2c8cJiP+jvBtCSgKgRG3srpx+xeE1S75 13 | l9vfhg40qNgvVDPrTmJv0gL//YKvXC/an/dYZWGbYkm/aCot0pDLWuGLdvWBsF9c 14 | yXIk/gxQii7uyp+j0jXfJCb3wSFHhog5fl4gIsHPp5kK8l+1xxF+XoM7YvAFso81 15 | VYbO/e2YrAlXSnLsPmeHlXJNfxwLs4c5LfFrPvyMlGKTb3LGHXDfkV+q9YZ1CD7P 16 | iOjXwWFX1Q2h7Q== 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /appendix_c/ui/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /appendix_c/ui/README.md: -------------------------------------------------------------------------------- 1 | # ui 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | yarn serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | yarn build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | yarn lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /appendix_c/ui/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /appendix_c/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coffeemesh-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "@auth0/auth0-spa-js": "^1.19.2", 12 | "@popperjs/core": "^2.10.2", 13 | "axios": "^0.24.0", 14 | "bootstrap": "^5.1.3", 15 | "core-js": "^3.6.5", 16 | "vue": "^3.0.0", 17 | "vue-class-component": "^8.0.0-0", 18 | "vuex": "^4.0.0" 19 | }, 20 | "devDependencies": { 21 | "@typescript-eslint/eslint-plugin": "^4.18.0", 22 | "@typescript-eslint/parser": "^4.18.0", 23 | "@vue/cli-plugin-babel": "~4.5.0", 24 | "@vue/cli-plugin-eslint": "~4.5.0", 25 | "@vue/cli-plugin-typescript": "~4.5.0", 26 | "@vue/cli-service": "~4.5.0", 27 | "@vue/compiler-sfc": "^3.0.0", 28 | "@vue/eslint-config-typescript": "^7.0.0", 29 | "babel-eslint": "^10.1.0", 30 | "eslint": "^6.7.2", 31 | "eslint-plugin-vue": "^7.0.0", 32 | "typescript": "~4.1.5" 33 | }, 34 | "eslintConfig": { 35 | "root": true, 36 | "env": { 37 | "node": true 38 | }, 39 | "extends": [ 40 | "plugin:vue/vue3-essential", 41 | "eslint:recommended", 42 | "@vue/typescript" 43 | ], 44 | "parserOptions": { 45 | "parser": "@typescript-eslint/parser" 46 | }, 47 | "rules": {} 48 | }, 49 | "browserslist": [ 50 | "> 1%", 51 | "last 2 versions", 52 | "not dead" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /appendix_c/ui/public/copy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abunuwas/microservice-apis/9f83ff700e7918f5275238a36ae1d69503812706/appendix_c/ui/public/copy.png -------------------------------------------------------------------------------- /appendix_c/ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abunuwas/microservice-apis/9f83ff700e7918f5275238a36ae1d69503812706/appendix_c/ui/public/favicon.ico -------------------------------------------------------------------------------- /appendix_c/ui/public/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abunuwas/microservice-apis/9f83ff700e7918f5275238a36ae1d69503812706/appendix_c/ui/public/github.png -------------------------------------------------------------------------------- /appendix_c/ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /appendix_c/ui/public/microapis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abunuwas/microservice-apis/9f83ff700e7918f5275238a36ae1d69503812706/appendix_c/ui/public/microapis.png -------------------------------------------------------------------------------- /appendix_c/ui/public/reddit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abunuwas/microservice-apis/9f83ff700e7918f5275238a36ae1d69503812706/appendix_c/ui/public/reddit.png -------------------------------------------------------------------------------- /appendix_c/ui/public/shopping-cart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abunuwas/microservice-apis/9f83ff700e7918f5275238a36ae1d69503812706/appendix_c/ui/public/shopping-cart.png -------------------------------------------------------------------------------- /appendix_c/ui/public/twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abunuwas/microservice-apis/9f83ff700e7918f5275238a36ae1d69503812706/appendix_c/ui/public/twitter.png -------------------------------------------------------------------------------- /appendix_c/ui/public/youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abunuwas/microservice-apis/9f83ff700e7918f5275238a36ae1d69503812706/appendix_c/ui/public/youtube.png -------------------------------------------------------------------------------- /appendix_c/ui/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abunuwas/microservice-apis/9f83ff700e7918f5275238a36ae1d69503812706/appendix_c/ui/src/assets/logo.png -------------------------------------------------------------------------------- /appendix_c/ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import store from './store' 4 | 5 | import 'bootstrap/dist/js/bootstrap.min' 6 | import 'bootstrap/dist/css/bootstrap.min.css' 7 | 8 | import { setupAuth } from '@/auth' 9 | 10 | const app = createApp(App) 11 | 12 | app.use(store) 13 | 14 | function callbackRedirect(appState: any) { 15 | if (appState) { 16 | console.log(appState.targetUrl) 17 | } 18 | window.location.href = '' 19 | } 20 | 21 | const authConfig = { 22 | domain: process.env.VUE_APP_AUTH_DOMAIN, 23 | client_id: process.env.VUE_APP_AUTH_CLIENT_ID, 24 | redirect_uri: process.env.VUE_APP_AUTH_REDIRECT_URI, 25 | audience: process.env.VUE_APP_AUTH_AUDIENCE, 26 | scope: process.env.VUE_APP_AUTH_SCOPE, 27 | }; 28 | 29 | setupAuth(authConfig, callbackRedirect).then((auth) => { 30 | app.use(auth).mount('#app') 31 | }); 32 | -------------------------------------------------------------------------------- /appendix_c/ui/src/products.ts: -------------------------------------------------------------------------------- 1 | enum size { 2 | Small = 'small', 3 | Medium = 'medium', 4 | Big = 'big' 5 | } 6 | 7 | interface product { 8 | id: string, 9 | name: string, 10 | sizes: any, 11 | selectedSize?: string 12 | } 13 | 14 | export const products = [ 15 | { 16 | id: 'fa52dd1d-7d19-4342-92f7-0801b568760f', 17 | name: 'Cappuccino', 18 | sizes: [ 19 | { 20 | size: size.Small, 21 | price: 10 22 | }, 23 | { 24 | size: size.Medium, 25 | price: 12 26 | }, 27 | { 28 | size: size.Big, 29 | price: 14 30 | }, 31 | ] 32 | }, 33 | { 34 | id: 'f14c1a98-6c55-4866-a36b-0fdab61bfcb4', 35 | name: 'Mocha', 36 | sizes: [ 37 | { 38 | size: size.Small, 39 | price: 12 40 | }, 41 | { 42 | size: size.Medium, 43 | price: 16 44 | }, 45 | { 46 | size: size.Big, 47 | price: 20 48 | }, 49 | ] 50 | }, 51 | { 52 | id: '7dafe3c2-0991-42a6-b1bf-789ace4b1fe0', 53 | name: 'Espresso', 54 | sizes: [ 55 | { 56 | size: size.Small, 57 | price: 8 58 | }, 59 | { 60 | size: size.Medium, 61 | price: 24 62 | }, 63 | { 64 | size: size.Big, 65 | price: 30 66 | }, 67 | ] 68 | }, 69 | { 70 | id: 'd64efb9b-a731-401e-a52a-49a4f5754dfc', 71 | name: 'Taro Bubble Tea', 72 | sizes: [ 73 | { 74 | size: size.Small, 75 | price: 18 76 | }, 77 | { 78 | size: size.Medium, 79 | price: 29 80 | }, 81 | { 82 | size: size.Big, 83 | price: 35 84 | }, 85 | ] 86 | } 87 | ] as Array 88 | -------------------------------------------------------------------------------- /appendix_c/ui/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module '*.vue' { 3 | import type { DefineComponent } from 'vue' 4 | const component: DefineComponent<{}, {}, any> 5 | export default component 6 | } 7 | -------------------------------------------------------------------------------- /appendix_c/ui/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'vuex' 2 | 3 | export default createStore({ 4 | state: { 5 | }, 6 | mutations: { 7 | }, 8 | actions: { 9 | }, 10 | modules: { 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /appendix_c/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "types": [ 17 | "webpack-env" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /ch02/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [packages] 7 | fastapi = "*" 8 | uvicorn = "*" 9 | 10 | [requires] 11 | python_version = "3.10" 12 | 13 | [dev-packages] 14 | black = "*" 15 | -------------------------------------------------------------------------------- /ch02/orders/api/api.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | from uuid import UUID 4 | 5 | from fastapi import HTTPException 6 | from starlette import status 7 | from starlette.responses import Response 8 | 9 | from orders.app import app 10 | from orders.api.schemas import ( 11 | GetOrderSchema, 12 | CreateOrderSchema, 13 | GetOrdersSchema, 14 | ) 15 | 16 | orders = [] 17 | 18 | 19 | @app.get("/orders", response_model=GetOrdersSchema) 20 | def get_orders(): 21 | return {"orders": orders} 22 | 23 | 24 | @app.post( 25 | "/orders", 26 | status_code=status.HTTP_201_CREATED, 27 | response_model=GetOrderSchema, 28 | ) 29 | def create_order(order_details: CreateOrderSchema): 30 | order = order_details.dict() 31 | order["id"] = uuid.uuid4() 32 | order["created"] = datetime.utcnow() 33 | order["status"] = "created" 34 | orders.append(order) 35 | return order 36 | 37 | 38 | @app.get("/orders/{order_id}", response_model=GetOrderSchema) 39 | def get_order(order_id: UUID): 40 | for order in orders: 41 | if order["id"] == order_id: 42 | return order 43 | raise HTTPException(status_code=404, detail=f"Order with ID {order_id} not found") 44 | 45 | 46 | @app.put("/orders/{order_id}", response_model=GetOrderSchema) 47 | def update_order(order_id: UUID, order_details: CreateOrderSchema): 48 | for order in orders: 49 | if order["id"] == order_id: 50 | order.update(order_details.dict()) 51 | return order 52 | raise HTTPException(status_code=404, detail=f"Order with ID {order_id} not found") 53 | 54 | 55 | @app.delete( 56 | "/orders/{order_id}", 57 | status_code=status.HTTP_204_NO_CONTENT, 58 | response_class=Response, 59 | ) 60 | def delete_order(order_id: UUID): 61 | for index, order in enumerate(orders): 62 | if order["id"] == order_id: 63 | orders.pop(index) 64 | return 65 | raise HTTPException(status_code=404, detail=f"Order with ID {order_id} not found") 66 | 67 | 68 | @app.post("/orders/{order_id}/cancel", response_model=GetOrderSchema) 69 | def cancel_order(order_id: UUID): 70 | for order in orders: 71 | if order["id"] == order_id: 72 | order["status"] = "cancelled" 73 | return order 74 | raise HTTPException(status_code=404, detail=f"Order with ID {order_id} not found") 75 | 76 | 77 | @app.post("/orders/{order_id}/pay", response_model=GetOrderSchema) 78 | def pay_order(order_id: UUID): 79 | for order in orders: 80 | if order["id"] == order_id: 81 | order["status"] = "progress" 82 | return order 83 | raise HTTPException(status_code=404, detail=f"Order with ID {order_id} not found") 84 | -------------------------------------------------------------------------------- /ch02/orders/api/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import List, Optional 4 | from uuid import UUID 5 | 6 | from pydantic import BaseModel, conint, validator, conlist 7 | 8 | 9 | class Size(Enum): 10 | small = "small" 11 | medium = "medium" 12 | big = "big" 13 | 14 | 15 | class StatusEnum(Enum): 16 | created = "created" 17 | paid = "paid" 18 | progress = "progress" 19 | cancelled = "cancelled" 20 | dispatched = "dispatched" 21 | delivered = "delivered" 22 | 23 | 24 | class OrderItemSchema(BaseModel): 25 | product: str 26 | size: Size 27 | quantity: Optional[conint(ge=1, strict=True)] = 1 28 | 29 | @validator("quantity") 30 | def quantity_non_nullable(cls, value): 31 | assert value is not None, "quantity may not be None" 32 | return value 33 | 34 | 35 | class CreateOrderSchema(BaseModel): 36 | order: conlist(OrderItemSchema, min_items=1) 37 | 38 | 39 | class GetOrderSchema(CreateOrderSchema): 40 | id: UUID 41 | created: datetime 42 | status: StatusEnum 43 | 44 | 45 | class GetOrdersSchema(BaseModel): 46 | orders: List[GetOrderSchema] 47 | -------------------------------------------------------------------------------- /ch02/orders/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | app = FastAPI(debug=True) 4 | 5 | 6 | from orders.api import api 7 | -------------------------------------------------------------------------------- /ch06/kitchen/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [packages] 7 | flask-smorest = "*" 8 | pyyaml = "*" 9 | 10 | [requires] 11 | python_version = "3.10" 12 | 13 | [dev-packages] 14 | black = "*" 15 | -------------------------------------------------------------------------------- /ch06/kitchen/api/schemas.py: -------------------------------------------------------------------------------- 1 | from marshmallow import Schema, fields, validate, EXCLUDE 2 | 3 | 4 | class OrderItemSchema(Schema): 5 | class Meta: 6 | unknown = EXCLUDE 7 | 8 | product = fields.String(required=True) 9 | size = fields.String( 10 | required=True, validate=validate.OneOf(["small", "medium", "big"]) 11 | ) 12 | quantity = fields.Integer( 13 | validate=validate.Range(1, min_inclusive=True), 14 | required=True, 15 | ) 16 | 17 | 18 | class ScheduleOrderSchema(Schema): 19 | class Meta: 20 | unknown = EXCLUDE 21 | 22 | order = fields.List(fields.Nested(OrderItemSchema), required=True) 23 | 24 | 25 | class GetScheduledOrderSchema(ScheduleOrderSchema): 26 | id = fields.UUID(required=True) 27 | scheduled = fields.DateTime(required=True) 28 | status = fields.String( 29 | required=True, 30 | validate=validate.OneOf(["pending", "progress", "cancelled", "finished"]), 31 | ) 32 | 33 | 34 | class GetScheduledOrdersSchema(Schema): 35 | class Meta: 36 | unknown = EXCLUDE 37 | 38 | schedules = fields.List(fields.Nested(GetScheduledOrderSchema), required=True) 39 | 40 | 41 | class ScheduleStatusSchema(Schema): 42 | class Meta: 43 | unknown = EXCLUDE 44 | 45 | status = fields.String( 46 | required=True, 47 | validate=validate.OneOf( 48 | ["pending", "progress", "cancelled", "finished"] 49 | ), 50 | ) 51 | 52 | 53 | class GetKitchenScheduleParameters(Schema): 54 | class Meta: 55 | unknown = EXCLUDE 56 | 57 | progress = fields.Boolean() 58 | limit = fields.Integer() 59 | since = fields.DateTime() 60 | -------------------------------------------------------------------------------- /ch06/kitchen/app.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import yaml 4 | from apispec import APISpec 5 | from flask import Flask 6 | from flask_smorest import Api 7 | 8 | from api.api import blueprint 9 | from config import BaseConfig 10 | 11 | 12 | app = Flask(__name__) 13 | 14 | app.config.from_object(BaseConfig) 15 | 16 | kitchen_api = Api(app) 17 | 18 | kitchen_api.register_blueprint(blueprint) 19 | 20 | api_spec = yaml.safe_load((Path(__file__).parent / "oas.yaml").read_text()) 21 | spec = APISpec( 22 | title=api_spec["info"]["title"], 23 | version=api_spec["info"]["version"], 24 | openapi_version=api_spec["openapi"], 25 | ) 26 | spec.to_dict = lambda: api_spec 27 | kitchen_api.spec = spec 28 | -------------------------------------------------------------------------------- /ch06/kitchen/config.py: -------------------------------------------------------------------------------- 1 | class BaseConfig: 2 | API_TITLE = 'Kitchen API' 3 | API_VERSION = 'v1' 4 | OPENAPI_VERSION = '3.0.3' 5 | OPENAPI_JSON_PATH = 'openapi/kitchen.json' 6 | OPENAPI_URL_PREFIX = '/' 7 | OPENAPI_REDOC_PATH = '/redoc' 8 | OPENAPI_REDOC_URL = 'https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js' # noqa: E501 9 | OPENAPI_SWAGGER_UI_PATH = '/docs/kitchen' 10 | OPENAPI_SWAGGER_UI_URL = 'https://cdn.jsdelivr.net/npm/swagger-ui-dist/' 11 | 12 | 13 | class Production(BaseConfig): 14 | debug = False 15 | 16 | 17 | class Development(BaseConfig): 18 | debug = True 19 | -------------------------------------------------------------------------------- /ch06/orders/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [packages] 7 | fastapi = "*" 8 | uvicorn = "*" 9 | pyyaml = "*" 10 | 11 | [requires] 12 | python_version = "3.10" 13 | 14 | [pipenv] 15 | allow_prereleases = true 16 | 17 | [dev-packages] 18 | black = "*" 19 | -------------------------------------------------------------------------------- /ch06/orders/orders/api/api.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | from typing import Optional 4 | from uuid import UUID 5 | 6 | from fastapi import HTTPException 7 | from starlette import status 8 | from starlette.responses import Response 9 | 10 | from orders.app import app 11 | from orders.api.schemas import ( 12 | GetOrderSchema, 13 | CreateOrderSchema, 14 | GetOrdersSchema, 15 | ) 16 | 17 | orders = [] 18 | 19 | 20 | @app.get("/orders", response_model=GetOrdersSchema) 21 | def get_orders(cancelled: Optional[bool] = None, limit: Optional[int] = None): 22 | if cancelled is None and limit is None: 23 | return {"orders": orders} 24 | 25 | query_set = [order for order in orders] 26 | 27 | if cancelled is not None: 28 | if cancelled: 29 | query_set = [order for order in query_set if order["status"] == "cancelled"] 30 | else: 31 | query_set = [order for order in query_set if order["status"] != "cancelled"] 32 | 33 | if limit is not None and len(query_set) > limit: 34 | return {"orders": query_set[:limit]} 35 | 36 | return {"orders": query_set} 37 | 38 | 39 | @app.post( 40 | "/orders", 41 | status_code=status.HTTP_201_CREATED, 42 | response_model=GetOrderSchema, 43 | ) 44 | def create_order(order: CreateOrderSchema): 45 | order = order.dict() 46 | order["id"] = uuid.uuid4() 47 | order["created"] = datetime.utcnow() 48 | order["status"] = "created" 49 | orders.append(order) 50 | return order 51 | 52 | 53 | @app.get("/orders/{order_id}", response_model=GetOrderSchema) 54 | def get_order(order_id: UUID): 55 | for order in orders: 56 | if order["id"] == order_id: 57 | return order 58 | raise HTTPException(status_code=404, detail=f"Order with ID {order_id} not found") 59 | 60 | 61 | @app.put("/orders/{order_id}", response_model=GetOrderSchema) 62 | def update_order(order_id: UUID, order_details: CreateOrderSchema): 63 | for order in orders: 64 | if order["id"] == order_id: 65 | order.update(order_details) 66 | return order 67 | raise HTTPException(status_code=404, detail=f"Order with ID {order_id} not found") 68 | 69 | 70 | @app.delete( 71 | "/orders/{order_id}", 72 | status_code=status.HTTP_204_NO_CONTENT, 73 | response_class=Response, 74 | ) 75 | def delete_order(order_id: UUID): 76 | for index, order in enumerate(orders): 77 | if order["id"] == order_id: 78 | orders.pop(index) 79 | return 80 | raise HTTPException(status_code=404, detail=f"Order with ID {order_id} not found") 81 | 82 | 83 | @app.post("/orders/{order_id}/cancel", response_model=GetOrderSchema) 84 | def cancel_order(order_id: UUID): 85 | for order in orders: 86 | if order["id"] == order_id: 87 | order["status"] = "cancelled" 88 | return order 89 | raise HTTPException(status_code=404, detail=f"Order with ID {order_id} not found") 90 | 91 | 92 | @app.post("/orders/{order_id}/pay", response_model=GetOrderSchema) 93 | def pay_order(order_id: UUID): 94 | for order in orders: 95 | if order["id"] == order_id: 96 | order["status"] = "paid" 97 | return order 98 | raise HTTPException(status_code=404, detail=f"Order with ID {order_id} not found") 99 | -------------------------------------------------------------------------------- /ch06/orders/orders/api/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import List, Optional 4 | from uuid import UUID 5 | 6 | from pydantic import BaseModel, Extra, conint, conlist, validator 7 | 8 | 9 | class Size(Enum): 10 | small = 'small' 11 | medium = 'medium' 12 | big = 'big' 13 | 14 | 15 | class Status(Enum): 16 | created = 'created' 17 | paid = 'paid' 18 | progress = 'progress' 19 | cancelled = 'cancelled' 20 | dispatched = 'dispatched' 21 | delivered = 'delivered' 22 | 23 | 24 | class OrderItemSchema(BaseModel): 25 | product: str 26 | size: Size 27 | quantity: Optional[conint(ge=1, strict=True)] = 1 28 | 29 | class Config: 30 | extra = Extra.forbid 31 | 32 | @validator('quantity') 33 | def quantity_non_nullable(cls, value): 34 | assert value is not None, 'quantity may not be None' 35 | return value 36 | 37 | 38 | class CreateOrderSchema(BaseModel): 39 | order: conlist(OrderItemSchema, min_items=1) 40 | 41 | class Config: 42 | extra = Extra.forbid 43 | 44 | 45 | class GetOrderSchema(CreateOrderSchema): 46 | id: UUID 47 | created: datetime 48 | status: Status 49 | 50 | 51 | class GetOrdersSchema(BaseModel): 52 | orders: List[GetOrderSchema] 53 | 54 | class Config: 55 | extra = Extra.forbid 56 | -------------------------------------------------------------------------------- /ch06/orders/orders/app.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import yaml 4 | from fastapi import FastAPI 5 | 6 | app = FastAPI(debug=True, openapi_url="/openapi/orders.json", docs_url="/docs/orders") 7 | 8 | oas_doc = yaml.safe_load((Path(__file__).parent / "../oas.yaml").read_text()) 9 | 10 | app.openapi = lambda: oas_doc 11 | 12 | from orders.api import api 13 | -------------------------------------------------------------------------------- /ch06/orders/orders/exceptions.py: -------------------------------------------------------------------------------- 1 | class OutOfStockError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /ch07/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | alembic = "*" 8 | black = "*" 9 | 10 | [packages] 11 | sqlalchemy = "*" 12 | fastapi = "*" 13 | uvicorn = "*" 14 | requests = "*" 15 | pyyaml = "*" 16 | 17 | [requires] 18 | python_version = "3.10" 19 | -------------------------------------------------------------------------------- /ch07/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | sqlalchemy.url = sqlite:///orders.db 39 | 40 | 41 | [post_write_hooks] 42 | # post_write_hooks defines scripts or Python functions that are run 43 | # on newly generated revision scripts. See the documentation for further 44 | # detail and examples 45 | 46 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 47 | # hooks=black 48 | # black.type=console_scripts 49 | # black.entrypoint=black 50 | # black.options=-l 79 51 | 52 | # Logging configuration 53 | [loggers] 54 | keys = root,sqlalchemy,alembic 55 | 56 | [handlers] 57 | keys = console 58 | 59 | [formatters] 60 | keys = generic 61 | 62 | [logger_root] 63 | level = WARN 64 | handlers = console 65 | qualname = 66 | 67 | [logger_sqlalchemy] 68 | level = WARN 69 | handlers = 70 | qualname = sqlalchemy.engine 71 | 72 | [logger_alembic] 73 | level = INFO 74 | handlers = 75 | qualname = alembic 76 | 77 | [handler_console] 78 | class = StreamHandler 79 | args = (sys.stderr,) 80 | level = NOTSET 81 | formatter = generic 82 | 83 | [formatter_generic] 84 | format = %(levelname)-5.5s [%(name)s] %(message)s 85 | datefmt = %H:%M:%S 86 | -------------------------------------------------------------------------------- /ch07/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /ch07/migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | 6 | from alembic import context 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | 12 | # Interpret the config file for Python logging. 13 | # This line sets up loggers basically. 14 | fileConfig(config.config_file_name) 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | from orders.repository.models import Base 19 | target_metadata = Base.metadata 20 | # target_metadata = None 21 | 22 | # other values from the config, defined by the needs of env.py, 23 | # can be acquired: 24 | # my_important_option = config.get_main_option("my_important_option") 25 | # ... etc. 26 | 27 | 28 | def run_migrations_offline(): 29 | """Run migrations in 'offline' mode. 30 | 31 | This configures the context with just a URL 32 | and not an Engine, though an Engine is acceptable 33 | here as well. By skipping the Engine creation 34 | we don't even need a DBAPI to be available. 35 | 36 | Calls to context.execute() here emit the given string to the 37 | script output. 38 | 39 | """ 40 | url = config.get_main_option("sqlalchemy.url") 41 | context.configure( 42 | url=url, 43 | target_metadata=target_metadata, 44 | literal_binds=True, 45 | dialect_opts={"paramstyle": "named"}, 46 | ) 47 | 48 | with context.begin_transaction(): 49 | context.run_migrations() 50 | 51 | 52 | def run_migrations_online(): 53 | """Run migrations in 'online' mode. 54 | 55 | In this scenario we need to create an Engine 56 | and associate a connection with the context. 57 | 58 | """ 59 | connectable = engine_from_config( 60 | config.get_section(config.config_ini_section), 61 | prefix="sqlalchemy.", 62 | poolclass=pool.NullPool, 63 | ) 64 | 65 | with connectable.connect() as connection: 66 | context.configure( 67 | connection=connection, target_metadata=target_metadata 68 | ) 69 | 70 | with context.begin_transaction(): 71 | context.run_migrations() 72 | 73 | 74 | if context.is_offline_mode(): 75 | run_migrations_offline() 76 | else: 77 | run_migrations_online() 78 | -------------------------------------------------------------------------------- /ch07/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /ch07/migrations/versions/bd1046019404_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: bd1046019404 4 | Revises: 5 | Create Date: 2020-12-06 21:44:18.071169 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'bd1046019404' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('order', 22 | sa.Column('id', sa.String(), nullable=False), 23 | sa.Column('status', sa.String(), nullable=False), 24 | sa.Column('created', sa.DateTime(), nullable=True), 25 | sa.Column('updated', sa.DateTime(), nullable=True), 26 | sa.Column('schedule_id', sa.String(), nullable=True), 27 | sa.Column('delivery_id', sa.String(), nullable=True), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | op.create_table('order_item', 31 | sa.Column('id', sa.String(), nullable=False), 32 | sa.Column('order_id', sa.String(), nullable=True), 33 | sa.Column('product', sa.String(), nullable=False), 34 | sa.Column('size', sa.String(), nullable=False), 35 | sa.Column('quantity', sa.Integer(), nullable=False), 36 | sa.ForeignKeyConstraint(['order_id'], ['order.id'], ), 37 | sa.PrimaryKeyConstraint('id') 38 | ) 39 | # ### end Alembic commands ### 40 | 41 | 42 | def downgrade(): 43 | # ### commands auto generated by Alembic - please adjust! ### 44 | op.drop_table('order_item') 45 | op.drop_table('order') 46 | # ### end Alembic commands ### 47 | -------------------------------------------------------------------------------- /ch07/orders/orders_service/exceptions.py: -------------------------------------------------------------------------------- 1 | class OrderNotFoundError(Exception): 2 | pass 3 | 4 | 5 | class APIIntegrationError(Exception): 6 | pass 7 | 8 | 9 | class InvalidActionError(Exception): 10 | pass 11 | -------------------------------------------------------------------------------- /ch07/orders/orders_service/orders.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from orders.orders_service.exceptions import ( 4 | APIIntegrationError, 5 | InvalidActionError 6 | ) 7 | 8 | 9 | class OrderItem: 10 | def __init__(self, id, product, quantity, size): 11 | self.id = id 12 | self.product = product 13 | self.quantity = quantity 14 | self.size = size 15 | 16 | def dict(self): 17 | return { 18 | 'product': self.product, 19 | 'size': self.size, 20 | 'quantity': self.quantity 21 | } 22 | 23 | 24 | class Order: 25 | def __init__(self, id, created, items, status, schedule_id=None, 26 | delivery_id=None, order_=None): 27 | self._order = order_ 28 | self._id = id 29 | self._created = created 30 | self.items = [OrderItem(**item) for item in items] 31 | self._status = status 32 | self.schedule_id = schedule_id 33 | self.delivery_id = delivery_id 34 | 35 | @property 36 | def id(self): 37 | return self._id or self._order.id 38 | 39 | @property 40 | def created(self): 41 | return self._created or self._order.created 42 | 43 | @property 44 | def status(self): 45 | return self._status or self._order.status 46 | 47 | def cancel(self): 48 | if self.status == 'progress': 49 | response = requests.post( 50 | f'http://localhost:3000/kitchen/schedules/{self.schedule_id}/cancel', 51 | json={'order': [item.dict() for item in self.items]} 52 | ) 53 | if response.status_code == 200: 54 | return 55 | raise APIIntegrationError( 56 | f'Could not cancel order with id {self.id}' 57 | ) 58 | if self.status == 'delivery': 59 | raise InvalidActionError(f'Cannot cancel order with id {self.id}') 60 | 61 | def pay(self): 62 | response = requests.post( 63 | 'http://localhost:3001/payments', json={'order_id': self.id} 64 | ) 65 | if response.status_code == 201: 66 | return 67 | raise APIIntegrationError( 68 | f'Could not process payment for order with id {self.id}' 69 | ) 70 | 71 | def schedule(self): 72 | response = requests.post( 73 | 'http://localhost:3000/kitchen/schedules', 74 | json={'order': [item.dict() for item in self.items]} 75 | ) 76 | if response.status_code == 201: 77 | return response.json()['id'] 78 | raise APIIntegrationError( 79 | f'Could not schedule order with id {self.id}' 80 | ) 81 | 82 | def dict(self): 83 | return { 84 | 'id': self.id, 85 | 'order': [item.dict() for item in self.items], 86 | 'status': self.status, 87 | 'created': self.created, 88 | } 89 | -------------------------------------------------------------------------------- /ch07/orders/orders_service/orders_service.py: -------------------------------------------------------------------------------- 1 | from orders.orders_service.exceptions import OrderNotFoundError 2 | from orders.repository.orders_repository import OrdersRepository 3 | 4 | 5 | class OrdersService: 6 | def __init__(self, orders_repository: OrdersRepository): 7 | self.orders_repository = orders_repository 8 | 9 | def place_order(self, items): 10 | return self.orders_repository.add(items) 11 | 12 | def get_order(self, order_id): 13 | order = self.orders_repository.get(order_id) 14 | if order is not None: 15 | return order 16 | raise OrderNotFoundError(f'Order with id {order_id} not found') 17 | 18 | def update_order(self, order_id, **payload): 19 | order = self.orders_repository.get(order_id) 20 | if order is None: 21 | raise OrderNotFoundError(f'Order with id {order_id} not found') 22 | return self.orders_repository.update(order_id, **payload) 23 | 24 | def list_orders(self, **filters): 25 | limit = filters.pop('limit', None) 26 | return self.orders_repository.list(limit, **filters) 27 | 28 | def pay_order(self, order_id): 29 | order = self.orders_repository.get(order_id) 30 | if order is None: 31 | raise OrderNotFoundError(f'Order with id {order_id} not found') 32 | order.pay() 33 | schedule_id = order.schedule() 34 | return self.orders_repository.update( 35 | order_id, status='progress', schedule_id=schedule_id 36 | ) 37 | 38 | def cancel_order(self, order_id): 39 | order = self.orders_repository.get(order_id) 40 | if order is None: 41 | raise OrderNotFoundError(f'Order with id {order_id} not found') 42 | order.cancel() 43 | return self.orders_repository.update(order_id, status='cancelled') 44 | 45 | def delete_order(self, order_id): 46 | order = self.orders_repository.get(order_id) 47 | if order is None: 48 | raise OrderNotFoundError(f'Order with id {order_id} not found') 49 | return self.orders_repository.delete(order_id) 50 | -------------------------------------------------------------------------------- /ch07/orders/repository/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from sqlalchemy import Column, Integer, String, ForeignKey, DateTime 5 | from sqlalchemy.ext.declarative import declarative_base 6 | from sqlalchemy.orm import relationship 7 | 8 | Base = declarative_base() 9 | 10 | 11 | def generate_uuid(): 12 | return str(uuid.uuid4()) 13 | 14 | 15 | class OrderModel(Base): 16 | __tablename__ = 'order' 17 | 18 | id = Column(String, primary_key=True, default=generate_uuid) 19 | items = relationship('OrderItemModel', backref='order') 20 | status = Column(String, nullable=False, default='created') 21 | created = Column(DateTime, default=datetime.utcnow) 22 | schedule_id = Column(String) 23 | delivery_id = Column(String) 24 | 25 | def dict(self): 26 | return { 27 | 'id': self.id, 28 | 'items': [item.dict() for item in self.items], 29 | 'status': self.status, 30 | 'created': self.created, 31 | 'schedule_id': self.schedule_id, 32 | 'delivery_id': self.delivery_id, 33 | } 34 | 35 | 36 | class OrderItemModel(Base): 37 | __tablename__ = 'order_item' 38 | 39 | id = Column(String, primary_key=True, default=generate_uuid) 40 | order_id = Column(Integer, ForeignKey('order.id')) 41 | product = Column(String, nullable=False) 42 | size = Column(String, nullable=False) 43 | quantity = Column(Integer, nullable=False) 44 | 45 | def dict(self): 46 | return { 47 | 'id': self.id, 48 | 'product': self.product, 49 | 'size': self.size, 50 | 'quantity': self.quantity 51 | } 52 | -------------------------------------------------------------------------------- /ch07/orders/repository/orders_repository.py: -------------------------------------------------------------------------------- 1 | from orders.orders_service.orders import Order 2 | from orders.repository.models import OrderModel, OrderItemModel 3 | 4 | 5 | class OrdersRepository: 6 | def __init__(self, session): 7 | self.session = session 8 | 9 | def add(self, items): 10 | record = OrderModel(items=[OrderItemModel(**item) for item in items]) 11 | self.session.add(record) 12 | return Order(**record.dict(), order_=record) 13 | 14 | def _get(self, id_): 15 | return self.session.query(OrderModel).filter(OrderModel.id == str(id_)).first() # noqa: E501 16 | 17 | def get(self, id_): 18 | order = self._get(id_) 19 | if order is not None: 20 | return Order(**order.dict()) 21 | 22 | def list(self, limit=None, **filters): 23 | query = self.session.query(OrderModel) 24 | if 'cancelled' in filters: 25 | cancelled = filters.pop('cancelled') 26 | if cancelled: 27 | query = query.filter(OrderModel.status == 'cancelled') 28 | else: 29 | query = query.filter(OrderModel.status != 'cancelled') 30 | records = query.filter_by(**filters).limit(limit).all() 31 | return [Order(**record.dict()) for record in records] 32 | 33 | def update(self, id_, **payload): 34 | record = self._get(id_) 35 | if 'items' in payload: 36 | for item in record.items: 37 | self.session.delete(item) 38 | record.items = [ 39 | OrderItemModel(**item) for item in payload.pop('items') 40 | ] 41 | for key, value in payload.items(): 42 | setattr(record, key, value) 43 | return Order(**record.dict()) 44 | 45 | def delete(self, id_): 46 | self.session.delete(self._get(id_)) 47 | -------------------------------------------------------------------------------- /ch07/orders/repository/unit_of_work.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import sessionmaker 3 | 4 | 5 | class UnitOfWork: 6 | 7 | def __init__(self): 8 | self.session_maker = sessionmaker( 9 | bind=create_engine('sqlite:///orders.db') 10 | ) 11 | 12 | def __enter__(self): 13 | self.session = self.session_maker() 14 | return self 15 | 16 | def __exit__(self, exc_type, exc_val, traceback): 17 | if exc_type is not None: 18 | self.rollback() 19 | self.session.close() 20 | self.session.close() 21 | 22 | def commit(self): 23 | self.session.commit() 24 | 25 | def rollback(self): 26 | self.session.rollback() 27 | -------------------------------------------------------------------------------- /ch07/orders/web/api/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import List, Optional 4 | from uuid import UUID 5 | 6 | from pydantic import BaseModel, Extra, conint, conlist, validator 7 | 8 | 9 | class Size(Enum): 10 | small = 'small' 11 | medium = 'medium' 12 | big = 'big' 13 | 14 | 15 | class Status(Enum): 16 | created = 'created' 17 | paid = 'paid' 18 | progress = 'progress' 19 | cancelled = 'cancelled' 20 | dispatched = 'dispatched' 21 | delivered = 'delivered' 22 | 23 | 24 | class OrderItemSchema(BaseModel): 25 | product: str 26 | size: Size 27 | quantity: Optional[conint(ge=1, strict=True)] = 1 28 | 29 | class Config: 30 | extra = Extra.forbid 31 | 32 | @validator('quantity') 33 | def quantity_non_nullable(cls, value): 34 | assert value is not None, 'quantity may not be None' 35 | return value 36 | 37 | 38 | class CreateOrderSchema(BaseModel): 39 | order: conlist(OrderItemSchema, min_items=1) 40 | 41 | class Config: 42 | extra = Extra.forbid 43 | 44 | 45 | class GetOrderSchema(CreateOrderSchema): 46 | id: UUID 47 | created: datetime 48 | status: Status 49 | 50 | 51 | class GetOrdersSchema(BaseModel): 52 | orders: List[GetOrderSchema] 53 | 54 | class Config: 55 | extra = Extra.forbid 56 | -------------------------------------------------------------------------------- /ch07/orders/web/app.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import yaml 4 | from fastapi import FastAPI 5 | 6 | app = FastAPI(debug=True, openapi_url="/openapi/orders.json", docs_url="/docs/orders") 7 | 8 | oas_doc = yaml.safe_load((Path(__file__).parent / "../../oas.yaml").read_text()) 9 | 10 | app.openapi = lambda: oas_doc 11 | 12 | from orders.web.api import api 13 | -------------------------------------------------------------------------------- /ch07/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@stoplight/prism-cli": "^4.1.1" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ch07/payments.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | 3 | info: 4 | title: Payments API 5 | description: API to process payments for orders 6 | version: 1.0.0 7 | 8 | servers: 9 | - url: https://coffeemesh.com 10 | description: main production server 11 | - url: https://coffeemesh-staging.com 12 | description: staging server for testing purposes only 13 | 14 | paths: 15 | /payments: 16 | post: 17 | summary: Schedules an order for production 18 | requestBody: 19 | required: true 20 | content: 21 | application/json: 22 | schema: 23 | type: object 24 | required: 25 | - order_id 26 | - status 27 | properties: 28 | order_id: 29 | type: string 30 | format: uuid 31 | 32 | responses: 33 | '201': 34 | description: A JSON representation of the scheduled order 35 | content: 36 | application/json: 37 | schema: 38 | type: object 39 | required: 40 | - payment_id 41 | properties: 42 | payment_id: 43 | type: string 44 | format: uuid 45 | status: 46 | type: string 47 | enum: 48 | - invalid 49 | - paid 50 | - pending 51 | -------------------------------------------------------------------------------- /ch08/schema.graphql: -------------------------------------------------------------------------------- 1 | scalar Datetime 2 | 3 | type Supplier { 4 | id: ID! 5 | name: String! 6 | address: String! 7 | contactNumber: String! 8 | email: String! 9 | ingredients: [Ingredient!]! 10 | } 11 | 12 | enum MeasureUnit { 13 | LITERS 14 | KILOGRAMS 15 | UNITS 16 | } 17 | 18 | type Stock { 19 | quantity: Float! 20 | unit: MeasureUnit! 21 | } 22 | 23 | type Ingredient { 24 | id: ID! 25 | name: String! 26 | stock: Stock! 27 | products: [Product!]! 28 | supplier: Supplier 29 | description: [String!] 30 | lastUpdated: Datetime! 31 | } 32 | 33 | type IngredientRecipe { 34 | ingredient: Ingredient! 35 | quantity: Float! 36 | unit: MeasureUnit! 37 | } 38 | 39 | enum Sizes { 40 | SMALL 41 | MEDIUM 42 | BIG 43 | } 44 | 45 | interface ProductInterface { 46 | id: ID! 47 | name: String! 48 | price: Float 49 | size: Sizes 50 | ingredients: [IngredientRecipe!] 51 | available: Boolean! 52 | lastUpdated: Datetime! 53 | } 54 | 55 | type Beverage implements ProductInterface { 56 | id: ID! 57 | name: String! 58 | price: Float 59 | size: Sizes 60 | ingredients: [IngredientRecipe!]! 61 | available: Boolean! 62 | lastUpdated: Datetime! 63 | hasCreamOnTopOption: Boolean! 64 | hasServeOnIceOption: Boolean! 65 | } 66 | 67 | type Cake implements ProductInterface { 68 | id: ID! 69 | name: String! 70 | price: Float 71 | size: Sizes 72 | ingredients: [IngredientRecipe!] 73 | available: Boolean! 74 | lastUpdated: Datetime! 75 | hasFilling: Boolean! 76 | hasNutsToppingOption: Boolean! 77 | } 78 | 79 | union Product = Beverage | Cake 80 | 81 | enum SortingOrder { 82 | ASCENDING 83 | DESCENDING 84 | } 85 | 86 | enum SortBy { 87 | price 88 | name 89 | } 90 | 91 | input ProductsFilter { 92 | maxPrice: Float 93 | minPrice: Float 94 | available: Boolean=true 95 | sortBy: SortBy=price 96 | sort: SortingOrder=DESCENDING 97 | resultsPerPage: Int = 10 98 | page: Int = 1 99 | } 100 | 101 | type Query { 102 | allProducts: [Product!]! 103 | allIngredients: [Ingredient!]! 104 | products(input: ProductsFilter!): [Product!]! 105 | product(id: ID!): Product 106 | ingredient(id: ID!): Ingredient 107 | } 108 | 109 | input IngredientRecipeInput { 110 | ingredient: ID! 111 | quantity: Float! 112 | unit: MeasureUnit! 113 | } 114 | 115 | input AddProductInput { 116 | price: Float 117 | size: Sizes 118 | ingredients: [IngredientRecipeInput!]! 119 | hasFilling: Boolean = false 120 | hasNutsToppingOption: Boolean = false 121 | hasCreamOnTopOption: Boolean = false 122 | hasServeOnIceOption: Boolean = false 123 | } 124 | 125 | input AddIngredientInput { 126 | supplier: AddSupplier 127 | stock: AddStock 128 | description: [String!]! 129 | } 130 | 131 | input AddStock { 132 | quantity: Float! 133 | unit: MeasureUnit! 134 | } 135 | 136 | input AddSupplier { 137 | address: String! 138 | contactNumber: String! 139 | email: String! 140 | } 141 | 142 | enum ProductType { 143 | cake 144 | beverage 145 | } 146 | 147 | type Mutation { 148 | addSupplier(name: String!, input: AddSupplier!): Supplier! 149 | addIngredient(name: String!, input: AddIngredientInput!): Ingredient! 150 | addProduct(name: String!, type: ProductType!, input: AddProductInput!): Product! 151 | updateProduct(id: ID!, input: AddProductInput!): Product! 152 | deleteProduct(id: ID!): Boolean! 153 | updateStock(id: ID!, changeAmount: AddStock): Ingredient! 154 | } 155 | 156 | schema { 157 | query: Query, 158 | mutation: Mutation 159 | } 160 | -------------------------------------------------------------------------------- /ch09/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | gql = "==3.0.0a5" 10 | requests = "*" 11 | 12 | [requires] 13 | python_version = "3.10" 14 | 15 | [pipenv] 16 | allow_prereleases = true 17 | -------------------------------------------------------------------------------- /ch09/client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | URL = 'http://localhost:9002/graphql' 4 | 5 | query_document = ''' 6 | { 7 | allIngredients { 8 | name 9 | } 10 | } 11 | ''' 12 | 13 | result = requests.get(URL, params={'query': query_document}) 14 | 15 | print(result.json()) 16 | -------------------------------------------------------------------------------- /ch09/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "graphql-faker": "^2.0.0-rc.25" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ch09/schema.graphql: -------------------------------------------------------------------------------- 1 | scalar Datetime 2 | 3 | type Supplier { 4 | id: ID! 5 | name: String! 6 | address: String! 7 | contactNumber: String! 8 | email: String! 9 | ingredients: [Ingredient!]! 10 | } 11 | 12 | enum MeasureUnit { 13 | LITERS 14 | KILOGRAMS 15 | UNITS 16 | } 17 | 18 | type Stock { 19 | quantity: Float! 20 | unit: MeasureUnit! 21 | } 22 | 23 | type Ingredient { 24 | id: ID! 25 | name: String! 26 | stock: Stock! 27 | products: [Product!]! 28 | supplier: Supplier 29 | description: [String!] 30 | lastUpdated: Datetime! 31 | } 32 | 33 | type IngredientRecipe { 34 | ingredient: Ingredient! 35 | quantity: Float! 36 | unit: MeasureUnit! 37 | } 38 | 39 | enum Sizes { 40 | SMALL 41 | MEDIUM 42 | BIG 43 | } 44 | 45 | interface ProductInterface { 46 | id: ID! 47 | name: String! 48 | price: Float 49 | size: Sizes 50 | ingredients: [IngredientRecipe!] 51 | available: Boolean! 52 | lastUpdated: Datetime! 53 | } 54 | 55 | type Beverage implements ProductInterface { 56 | id: ID! 57 | name: String! 58 | price: Float 59 | size: Sizes 60 | ingredients: [IngredientRecipe!]! 61 | available: Boolean! 62 | lastUpdated: Datetime! 63 | hasCreamOnTopOption: Boolean! 64 | hasServeOnIceOption: Boolean! 65 | } 66 | 67 | type Cake implements ProductInterface { 68 | id: ID! 69 | name: String! 70 | price: Float 71 | size: Sizes 72 | ingredients: [IngredientRecipe!] 73 | available: Boolean! 74 | lastUpdated: Datetime! 75 | hasFilling: Boolean! 76 | hasNutsToppingOption: Boolean! 77 | } 78 | 79 | union Product = Beverage | Cake 80 | 81 | enum SortingOrder { 82 | ASCENDING 83 | DESCENDING 84 | } 85 | 86 | enum SortBy { 87 | price 88 | name 89 | } 90 | 91 | input ProductsFilter { 92 | maxPrice: Float 93 | minPrice: Float 94 | available: Boolean=true 95 | sortBy: SortBy=price 96 | sort: SortingOrder=DESCENDING 97 | resultsPerPage: Int = 10 98 | page: Int = 1 99 | } 100 | 101 | type Query { 102 | allProducts: [Product!]! 103 | allIngredients: [Ingredient!]! 104 | products(input: ProductsFilter!): [Product!]! 105 | product(id: ID!): Product 106 | ingredient(id: ID!): Ingredient 107 | } 108 | 109 | input IngredientRecipeInput { 110 | ingredient: ID! 111 | quantity: Float! 112 | unit: MeasureUnit! 113 | } 114 | 115 | input AddProductInput { 116 | price: Float 117 | size: Sizes 118 | ingredients: [IngredientRecipeInput!]! 119 | hasFilling: Boolean = false 120 | hasNutsToppingOption: Boolean = false 121 | hasCreamOnTopOption: Boolean = false 122 | hasServeOnIceOption: Boolean = false 123 | } 124 | 125 | input AddIngredientInput { 126 | supplier: AddSupplier 127 | stock: AddStock 128 | description: [String!]! 129 | } 130 | 131 | input AddStock { 132 | quantity: Float! 133 | unit: MeasureUnit! 134 | } 135 | 136 | input AddSupplier { 137 | address: String! 138 | contactNumber: String! 139 | email: String! 140 | } 141 | 142 | enum ProductType { 143 | cake 144 | beverage 145 | } 146 | 147 | type Mutation { 148 | addSupplier(name: String!, input: AddSupplier!): Supplier! 149 | addIngredient(name: String!, input: AddIngredientInput!): Ingredient! 150 | addProduct(name: String!, type: ProductType!, input: AddProductInput!): Product! 151 | updateProduct(id: ID!, input: AddProductInput!): Product! 152 | deleteProduct(id: ID!): Boolean! 153 | updateStock(id: ID!, changeAmount: AddStock): Ingredient! 154 | } 155 | 156 | schema { 157 | query: Query, 158 | mutation: Mutation 159 | } 160 | -------------------------------------------------------------------------------- /ch10/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | black = "*" 8 | 9 | [packages] 10 | uvicorn = "*" 11 | ariadne = "*" 12 | gql = "*" 13 | lock = "*" 14 | 15 | [requires] 16 | python_version = "3.10" 17 | 18 | [pipenv] 19 | allow_prereleases = true 20 | -------------------------------------------------------------------------------- /ch10/exceptions.py: -------------------------------------------------------------------------------- 1 | class ItemNotFoundError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /ch10/server.py: -------------------------------------------------------------------------------- 1 | from ariadne.asgi import GraphQL 2 | 3 | from web.schema import schema 4 | 5 | server = GraphQL(schema, debug=True) 6 | -------------------------------------------------------------------------------- /ch10/web/data.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | 4 | suppliers = [ 5 | { 6 | 'id': '92f2daae-a4f8-4aae-8d74-51dd74e5de6d', 7 | 'name': 'Milk Supplier', 8 | 'address': '77 Milk Way', 9 | 'contactNumber': '0987654321', 10 | 'email': 'milk@milksupplier.com', 11 | }, 12 | ] 13 | 14 | ingredients = [ 15 | { 16 | 'id': '602f2ab3-97bd-468e-a88b-bb9e00531fd0', 17 | 'name': 'Milk', 18 | 'stock': { 19 | 'quantity': 100.00, 20 | 'unit': 'LITERS', 21 | }, 22 | 'products': [], 23 | 'supplier': '92f2daae-a4f8-4aae-8d74-51dd74e5de6d', 24 | 'lastUpdated': datetime.utcnow(), 25 | }, 26 | ] 27 | 28 | 29 | products = [ 30 | { 31 | 'id': '6961ca64-78f3-41d4-bc3b-a63550754bd8', 32 | 'name': 'Walnut Bomb', 33 | 'price': 37.00, 34 | 'size': 'MEDIUM', 35 | 'available': False, 36 | 'ingredients': [ 37 | { 38 | 'ingredient': '602f2ab3-97bd-468e-a88b-bb9e00531fd0', 39 | 'quantity': 100.00, 40 | 'unit': 'LITERS', 41 | } 42 | ], 43 | 'hasFilling': False, 44 | 'hasNutsToppingOption': True, 45 | 'lastUpdated': datetime.utcnow(), 46 | }, 47 | { 48 | 'id': 'e4e33d0b-1355-4735-9505-749e3fdf8a16', 49 | 'name': 'Cappuccino Star', 50 | 'price': 12.50, 51 | 'size': 'SMALL', 52 | 'available': True, 53 | 'ingredients': [ 54 | { 55 | 'ingredient': '602f2ab3-97bd-468e-a88b-bb9e00531fd0', 56 | 'quantity': 100.00, 57 | 'unit': 'LITERS', 58 | } 59 | ], 60 | 'hasCreamOnTopOption': True, 61 | 'hasServeOnIceOption': True, 62 | 'lastUpdated': datetime.utcnow(), 63 | }, 64 | ] 65 | -------------------------------------------------------------------------------- /ch10/web/mutations.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from ariadne import MutationType 5 | 6 | from exceptions import ItemNotFoundError 7 | from web.data import products, ingredients, suppliers 8 | 9 | mutation = MutationType() 10 | 11 | 12 | @mutation.field('addSupplier') 13 | def resolve_add_supplier(*_, name, input): 14 | input['name'] = name 15 | input['id'] = uuid.uuid4() 16 | suppliers.append(input) 17 | return input 18 | 19 | 20 | @mutation.field('addIngredient') 21 | def resolve_add_ingredient(*_, name, input): 22 | input['name'] = name 23 | input['id'] = uuid.uuid4() 24 | input['lastUpdated'] = datetime.utcnow() 25 | ingredients.append(input) 26 | return input 27 | 28 | 29 | @mutation.field('addProduct') 30 | def resolve_add_product(*_, name, type, input): 31 | product = { 32 | 'id': uuid.uuid4(), 33 | 'name': name, 34 | 'available': input.get('available', False), 35 | 'ingredients': input.get('ingredients', []), 36 | 'lastUpdated': datetime.utcnow(), 37 | } 38 | if type == 'cake': 39 | product.update({ 40 | 'hasFilling': input['hasFilling'], 41 | 'hasNutsToppingOption': input['hasNutsToppingOption'], 42 | }) 43 | else: 44 | product.update({ 45 | 'hasCreamOnTopOption': input['hasCreamOnTopOption'], 46 | 'hasServeOnIceOption': input['hasServeOnIceOption'], 47 | }) 48 | products.append(product) 49 | return product 50 | 51 | 52 | @mutation.field('updateProduct') 53 | def resolve_update_product(*_, id, input): 54 | for product in products: 55 | if product['id'] == id: 56 | product.update(input) 57 | product['lastUpdated'] = datetime.utcnow() 58 | return product 59 | raise ItemNotFoundError(f'Product with ID {id} not found') 60 | 61 | 62 | @mutation.field('deleteProduct') 63 | def resolve_delete_product(*_, id): 64 | for index, product in enumerate(products): 65 | if product['id'] == id: 66 | products.pop(index) 67 | return True 68 | raise ItemNotFoundError(f'Product with ID {id} not found') 69 | 70 | 71 | @mutation.field('updateStock') 72 | def resolve_update_stock(*_, id, changeAmount): 73 | for ingredient in ingredients: 74 | if ingredient['id'] == id: 75 | ingredient['stock'] = changeAmount 76 | return ingredient 77 | raise ItemNotFoundError(f'Ingredient with ID {id} not found') 78 | -------------------------------------------------------------------------------- /ch10/web/products.graphql: -------------------------------------------------------------------------------- 1 | scalar Datetime 2 | 3 | type Supplier { 4 | id: ID! 5 | name: String! 6 | address: String! 7 | contactNumber: String! 8 | email: String! 9 | ingredients: [Ingredient!]! 10 | } 11 | 12 | enum MeasureUnit { 13 | LITERS 14 | KILOGRAMS 15 | UNITS 16 | } 17 | 18 | type Stock { 19 | quantity: Float! 20 | unit: MeasureUnit! 21 | } 22 | 23 | type Ingredient { 24 | id: ID! 25 | name: String! 26 | stock: Stock! 27 | products: [Product!]! 28 | supplier: Supplier 29 | description: [String!] 30 | lastUpdated: Datetime! 31 | } 32 | 33 | type IngredientRecipe { 34 | ingredient: Ingredient! 35 | quantity: Float! 36 | unit: MeasureUnit! 37 | } 38 | 39 | enum Sizes { 40 | SMALL 41 | MEDIUM 42 | BIG 43 | } 44 | 45 | interface ProductInterface { 46 | id: ID! 47 | name: String! 48 | price: Float 49 | size: Sizes 50 | ingredients: [IngredientRecipe!] 51 | available: Boolean! 52 | lastUpdated: Datetime! 53 | } 54 | 55 | type Beverage implements ProductInterface { 56 | id: ID! 57 | name: String! 58 | price: Float 59 | size: Sizes 60 | ingredients: [IngredientRecipe!]! 61 | available: Boolean! 62 | lastUpdated: Datetime! 63 | hasCreamOnTopOption: Boolean! 64 | hasServeOnIceOption: Boolean! 65 | } 66 | 67 | type Cake implements ProductInterface { 68 | id: ID! 69 | name: String! 70 | price: Float 71 | size: Sizes 72 | ingredients: [IngredientRecipe!] 73 | available: Boolean! 74 | lastUpdated: Datetime! 75 | hasFilling: Boolean! 76 | hasNutsToppingOption: Boolean! 77 | } 78 | 79 | union Product = Beverage | Cake 80 | 81 | enum SortingOrder { 82 | ASCENDING 83 | DESCENDING 84 | } 85 | 86 | enum SortBy { 87 | price 88 | name 89 | } 90 | 91 | input ProductsFilter { 92 | maxPrice: Float 93 | minPrice: Float 94 | available: Boolean=true 95 | sortBy: SortBy=price 96 | sort: SortingOrder=DESCENDING 97 | resultsPerPage: Int = 10 98 | page: Int = 1 99 | } 100 | 101 | type Query { 102 | allProducts: [Product!]! 103 | allIngredients: [Ingredient!]! 104 | products(input: ProductsFilter!): [Product!]! 105 | product(id: ID!): Product 106 | ingredient(id: ID!): Ingredient 107 | } 108 | 109 | input IngredientRecipeInput { 110 | ingredient: ID! 111 | quantity: Float! 112 | unit: MeasureUnit! 113 | } 114 | 115 | input AddProductInput { 116 | price: Float 117 | size: Sizes 118 | ingredients: [IngredientRecipeInput!]! 119 | hasFilling: Boolean = false 120 | hasNutsToppingOption: Boolean = false 121 | hasCreamOnTopOption: Boolean = false 122 | hasServeOnIceOption: Boolean = false 123 | } 124 | 125 | input AddIngredientInput { 126 | supplier: AddSupplier 127 | stock: AddStock 128 | description: [String!]! 129 | } 130 | 131 | input AddStock { 132 | quantity: Float! 133 | unit: MeasureUnit! 134 | } 135 | 136 | input AddSupplier { 137 | address: String! 138 | contactNumber: String! 139 | email: String! 140 | } 141 | 142 | enum ProductType { 143 | cake 144 | beverage 145 | } 146 | 147 | type Mutation { 148 | addSupplier(name: String!, input: AddSupplier!): Supplier! 149 | addIngredient(name: String!, input: AddIngredientInput!): Ingredient! 150 | addProduct(name: String!, type: ProductType!, input: AddProductInput!): Product! 151 | updateProduct(id: ID!, input: AddProductInput!): Product! 152 | deleteProduct(id: ID!): Boolean! 153 | updateStock(id: ID!, changeAmount: AddStock): Ingredient! 154 | } 155 | 156 | schema { 157 | query: Query, 158 | mutation: Mutation 159 | } 160 | -------------------------------------------------------------------------------- /ch10/web/queries.py: -------------------------------------------------------------------------------- 1 | from itertools import islice 2 | 3 | from ariadne import QueryType 4 | 5 | from exceptions import ItemNotFoundError 6 | from web.data import products, ingredients 7 | 8 | query = QueryType() 9 | 10 | 11 | @query.field('allProducts') 12 | def resolve_all_products(*_): 13 | return products 14 | 15 | 16 | @query.field('allIngredients') 17 | def resolve_all_ingredients(*_): 18 | return ingredients 19 | 20 | 21 | def get_page(items, items_per_page, page): 22 | page = page - 1 23 | start = items_per_page * page if page > 0 else page 24 | stop = start + items_per_page 25 | return list(islice(items, start, stop)) 26 | 27 | 28 | @query.field('products') 29 | def resolve_products(*_, input=None): 30 | filtered = [product for product in products] 31 | if input is None: 32 | return filtered 33 | filtered = [ 34 | product for product in filtered 35 | if product['available'] is input['available'] 36 | ] 37 | if input.get('minPrice') is not None: 38 | filtered = [ 39 | product for product in filtered 40 | if product['price'] >= input['minPrice'] 41 | ] 42 | if input.get('maxPrice') is not None: 43 | filtered = [ 44 | product for product in filtered 45 | if product['price'] <= input['maxPrice'] 46 | ] 47 | filtered.sort( 48 | key=lambda product: product.get(input['sortBy'], 0), 49 | reverse=input['sort'] == 'DESCENDING' 50 | ) 51 | return get_page(filtered, input['resultsPerPage'], input['page']) 52 | 53 | 54 | @query.field('product') 55 | def resolve_product(*_, id): 56 | for product in products: 57 | if product['id'] == id: 58 | return product 59 | raise ItemNotFoundError(f'Product with ID {id} not found') 60 | 61 | 62 | @query.field('ingredient') 63 | def resolve_ingredient(*_, id): 64 | for ingredient in ingredients: 65 | if ingredient['id'] == id: 66 | return ingredient 67 | raise ItemNotFoundError(f'Ingredient with ID {id} not found') 68 | -------------------------------------------------------------------------------- /ch10/web/schema.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from ariadne import make_executable_schema 4 | 5 | from web.mutations import mutation 6 | from web.queries import query 7 | from web.types import product_type, datetime_scalar, product_interface, ingredient_type, supplier_type 8 | 9 | 10 | schema = make_executable_schema( 11 | (Path(__file__).parent / 'products.graphql').read_text(), 12 | [query, mutation, product_interface, product_type, ingredient_type, supplier_type, datetime_scalar] 13 | ) 14 | -------------------------------------------------------------------------------- /ch10/web/types.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from datetime import datetime 3 | 4 | from ariadne import UnionType, ScalarType, InterfaceType, ObjectType 5 | 6 | from web.data import ingredients, suppliers, products 7 | 8 | product_interface = InterfaceType("ProductInterface") 9 | product_type = UnionType("Product") 10 | ingredient_type = ObjectType("Ingredient") 11 | supplier_type = ObjectType("Supplier") 12 | 13 | datetime_scalar = ScalarType("Datetime") 14 | 15 | 16 | @datetime_scalar.serializer 17 | def serialize_datetime_scalar(date): 18 | return date.isoformat() 19 | 20 | 21 | @datetime_scalar.value_parser 22 | def parse_datetime_scalar(date): 23 | return datetime.fromisoformat(date) 24 | 25 | 26 | @product_type.type_resolver 27 | def resolve_product_type(obj, *_): 28 | if "hasFilling" in obj: 29 | return "Cake" 30 | return "Beverage" 31 | 32 | 33 | @product_interface.field("ingredients") 34 | def resolve_product_ingredients(product, _): 35 | recipe = [copy.copy(ingredient) for ingredient in product.get("ingredients", [])] 36 | for ingredient_recipe in recipe: 37 | for ingredient in ingredients: 38 | if ingredient["id"] == ingredient_recipe["ingredient"]: 39 | ingredient_recipe["ingredient"] = ingredient 40 | return recipe 41 | 42 | 43 | @ingredient_type.field("supplier") 44 | def resolve_ingredient_suppliers(ingredient, _): 45 | if ingredient.get("supplier") is not None: 46 | for supplier in suppliers: 47 | if supplier["id"] == ingredient["supplier"]: 48 | return supplier 49 | 50 | 51 | @ingredient_type.field("products") 52 | def resolve_ingredient_products(ingredient, _): 53 | return [ 54 | product 55 | for product in products 56 | if ingredient["id"] in product.get("ingredients", []) 57 | ] 58 | 59 | 60 | @supplier_type.field("ingredients") 61 | def resolve_supplier_ingredients(supplier, _): 62 | return [ 63 | ingredient 64 | for ingredient in ingredients 65 | if supplier["id"] in ingredient.get("suppliers", []) 66 | ] 67 | -------------------------------------------------------------------------------- /ch11/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | alembic = "*" 8 | black = "*" 9 | 10 | [packages] 11 | cryptography = "*" 12 | fastapi = "*" 13 | pyjwt = "*" 14 | requests = "*" 15 | sqlalchemy = "*" 16 | uvicorn = "*" 17 | pyyaml = "*" 18 | 19 | [requires] 20 | python_version = "3.10" 21 | 22 | [pipenv] 23 | allow_prereleases = true 24 | -------------------------------------------------------------------------------- /ch11/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | sqlalchemy.url = sqlite:///orders.db 39 | 40 | 41 | [post_write_hooks] 42 | # post_write_hooks defines scripts or Python functions that are run 43 | # on newly generated revision scripts. See the documentation for further 44 | # detail and examples 45 | 46 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 47 | # hooks=black 48 | # black.type=console_scripts 49 | # black.entrypoint=black 50 | # black.options=-l 79 51 | 52 | # Logging configuration 53 | [loggers] 54 | keys = root,sqlalchemy,alembic 55 | 56 | [handlers] 57 | keys = console 58 | 59 | [formatters] 60 | keys = generic 61 | 62 | [logger_root] 63 | level = WARN 64 | handlers = console 65 | qualname = 66 | 67 | [logger_sqlalchemy] 68 | level = WARN 69 | handlers = 70 | qualname = sqlalchemy.engine 71 | 72 | [logger_alembic] 73 | level = INFO 74 | handlers = 75 | qualname = alembic 76 | 77 | [handler_console] 78 | class = StreamHandler 79 | args = (sys.stderr,) 80 | level = NOTSET 81 | formatter = generic 82 | 83 | [formatter_generic] 84 | format = %(levelname)-5.5s [%(name)s] %(message)s 85 | datefmt = %H:%M:%S 86 | -------------------------------------------------------------------------------- /ch11/exceptions.py: -------------------------------------------------------------------------------- 1 | class OutOfStockError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /ch11/jwt_generator.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from pathlib import Path 3 | 4 | import jwt 5 | from cryptography.hazmat.primitives import serialization 6 | 7 | 8 | def generate_jwt(): 9 | now = datetime.utcnow() 10 | payload = { 11 | "iss": "https://auth.coffeemesh.io/", 12 | "sub": "ec7bbccf-ca89-4af3-82ac-b41e4831a962", 13 | "aud": "http://127.0.0.1:8000/orders", 14 | "iat": now.timestamp(), 15 | "exp": (now + timedelta(hours=24)).timestamp(), 16 | "scope": "openid", 17 | } 18 | 19 | private_key_text = Path("private_key.pem").read_text() 20 | private_key = serialization.load_pem_private_key( 21 | private_key_text.encode(), 22 | password=None, 23 | ) 24 | return jwt.encode(payload=payload, key=private_key, algorithm="RS256") 25 | 26 | 27 | print(generate_jwt()) 28 | -------------------------------------------------------------------------------- /ch11/machine_to_machine_test.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def get_access_token(): 5 | payload = { 6 | "client_id": "", 7 | "client_secret": "", 8 | "audience": "http://127.0.0.1:8000/orders", 9 | "grant_type": "client_credentials" 10 | } 11 | 12 | response = requests.post( 13 | "https://coffeemesh-dev.eu.auth0.com/oauth/token", 14 | json=payload, 15 | headers={'content-type': "application/json"} 16 | ) 17 | 18 | return response.json()['access_token'] 19 | 20 | 21 | def create_order(token): 22 | order_payload = { 23 | 'order': [{ 24 | 'product': 'latte', 25 | 'size': 'small', 26 | 'quantity': 1 27 | }] 28 | } 29 | 30 | order = requests.post( 31 | 'http://127.0.0.1:8000/orders', 32 | json=order_payload, 33 | headers={'content-type': "application/json", "Authorization": f"Bearer {token}"} 34 | ) 35 | 36 | return order.json() 37 | 38 | 39 | access_token = get_access_token() 40 | print(access_token) 41 | order = create_order(access_token) 42 | print(order) 43 | -------------------------------------------------------------------------------- /ch11/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /ch11/migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | 6 | from alembic import context 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | 12 | # Interpret the config file for Python logging. 13 | # This line sets up loggers basically. 14 | fileConfig(config.config_file_name) 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | from orders.repository.models import Base 19 | target_metadata = Base.metadata 20 | # target_metadata = None 21 | 22 | # other values from the config, defined by the needs of env.py, 23 | # can be acquired: 24 | # my_important_option = config.get_main_option("my_important_option") 25 | # ... etc. 26 | 27 | 28 | def run_migrations_offline(): 29 | """Run migrations in 'offline' mode. 30 | 31 | This configures the context with just a URL 32 | and not an Engine, though an Engine is acceptable 33 | here as well. By skipping the Engine creation 34 | we don't even need a DBAPI to be available. 35 | 36 | Calls to context.execute() here emit the given string to the 37 | script output. 38 | 39 | """ 40 | url = config.get_main_option("sqlalchemy.url") 41 | context.configure( 42 | url=url, 43 | target_metadata=target_metadata, 44 | literal_binds=True, 45 | dialect_opts={"paramstyle": "named"}, 46 | ) 47 | 48 | with context.begin_transaction(): 49 | context.run_migrations() 50 | 51 | 52 | def run_migrations_online(): 53 | """Run migrations in 'online' mode. 54 | 55 | In this scenario we need to create an Engine 56 | and associate a connection with the context. 57 | 58 | """ 59 | connectable = engine_from_config( 60 | config.get_section(config.config_ini_section), 61 | prefix="sqlalchemy.", 62 | poolclass=pool.NullPool, 63 | ) 64 | 65 | with connectable.connect() as connection: 66 | context.configure( 67 | connection=connection, 68 | target_metadata=target_metadata, 69 | render_as_batch=True, 70 | ) 71 | 72 | with context.begin_transaction(): 73 | context.run_migrations() 74 | 75 | 76 | if context.is_offline_mode(): 77 | run_migrations_offline() 78 | else: 79 | run_migrations_online() 80 | -------------------------------------------------------------------------------- /ch11/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /ch11/migrations/versions/bd1046019404_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: bd1046019404 4 | Revises: 5 | Create Date: 2020-12-06 21:44:18.071169 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'bd1046019404' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('order', 22 | sa.Column('id', sa.String(), nullable=False), 23 | sa.Column('status', sa.String(), nullable=False), 24 | sa.Column('created', sa.DateTime(), nullable=True), 25 | sa.Column('schedule_id', sa.String(), nullable=True), 26 | sa.Column('delivery_id', sa.String(), nullable=True), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | op.create_table('order_item', 30 | sa.Column('id', sa.String(), nullable=False), 31 | sa.Column('order_id', sa.String(), nullable=True), 32 | sa.Column('product', sa.String(), nullable=False), 33 | sa.Column('size', sa.String(), nullable=False), 34 | sa.Column('quantity', sa.Integer(), nullable=False), 35 | sa.ForeignKeyConstraint(['order_id'], ['order.id'], ), 36 | sa.PrimaryKeyConstraint('id') 37 | ) 38 | # ### end Alembic commands ### 39 | 40 | 41 | def downgrade(): 42 | # ### commands auto generated by Alembic - please adjust! ### 43 | op.drop_table('order_item') 44 | op.drop_table('order') 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /ch11/migrations/versions/cf6a8fb1fd44_add_user_id_to_order_table.py: -------------------------------------------------------------------------------- 1 | """Add user id to order table 2 | 3 | Revision ID: cf6a8fb1fd44 4 | Revises: bd1046019404 5 | Create Date: 2021-11-07 12:28:17.852145 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'cf6a8fb1fd44' 14 | down_revision = 'bd1046019404' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('order', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('user_id', sa.String(), nullable=False)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('order', schema=None) as batch_op: 30 | batch_op.drop_column('user_id') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /ch11/orders/exceptions.py: -------------------------------------------------------------------------------- 1 | class OutOfStockError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /ch11/orders/orders_service/exceptions.py: -------------------------------------------------------------------------------- 1 | class OrderNotFoundError(Exception): 2 | pass 3 | 4 | 5 | class APIIntegrationError(Exception): 6 | pass 7 | 8 | 9 | class InvalidActionError(Exception): 10 | pass 11 | -------------------------------------------------------------------------------- /ch11/orders/orders_service/orders.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from orders.orders_service.exceptions import ( 4 | APIIntegrationError, 5 | InvalidActionError, 6 | ) 7 | 8 | 9 | class OrderItem: 10 | def __init__(self, id, product, quantity, size): 11 | self.id = id 12 | self.product = product 13 | self.quantity = quantity 14 | self.size = size 15 | 16 | def dict(self): 17 | return { 18 | "product": self.product, 19 | "size": self.size, 20 | "quantity": self.quantity, 21 | } 22 | 23 | 24 | class Order: 25 | def __init__( 26 | self, 27 | id, 28 | created, 29 | items, 30 | status, 31 | schedule_id=None, 32 | delivery_id=None, 33 | order_=None, 34 | ): 35 | self._order = order_ 36 | self._id = id 37 | self._created = created 38 | self.items = [OrderItem(**item) for item in items] 39 | self._status = status 40 | self.schedule_id = schedule_id 41 | self.delivery_id = delivery_id 42 | 43 | @property 44 | def id(self): 45 | return self._id or self._order.id 46 | 47 | @property 48 | def created(self): 49 | return self._created or self._order.created 50 | 51 | @property 52 | def status(self): 53 | return self._status or self._order.status 54 | 55 | def cancel(self): 56 | if self.status == "progress": 57 | kitchen_base_url = "http://localhost:3000/kitchen" 58 | response = requests.post( 59 | f"{kitchen_base_url}/schedules/{self.schedule_id}/cancel", 60 | json={"order": [item.dict() for item in self.items]}, 61 | ) 62 | if response.status_code == 200: 63 | return 64 | raise APIIntegrationError(f"Could not cancel order with id {self.id}") 65 | if self.status == "delivery": 66 | raise InvalidActionError(f"Cannot cancel order with id {self.id}") 67 | 68 | def pay(self): 69 | response = requests.post( 70 | "http://localhost:3001/payments", json={"order_id": self.id} 71 | ) 72 | if response.status_code == 201: 73 | return 74 | raise APIIntegrationError( 75 | f"Could not process payment for order with id {self.id}" 76 | ) 77 | 78 | def schedule(self): 79 | response = requests.post( 80 | "http://localhost:3000/kitchen/schedules", 81 | json={"order": [item.dict() for item in self.items]}, 82 | ) 83 | if response.status_code == 201: 84 | return response.json()["id"] 85 | raise APIIntegrationError(f"Could not schedule order with id {self.id}") 86 | 87 | def dict(self): 88 | return { 89 | "id": self.id, 90 | "order": [item.dict() for item in self.items], 91 | "status": self.status, 92 | "created": self.created, 93 | } 94 | -------------------------------------------------------------------------------- /ch11/orders/orders_service/orders_service.py: -------------------------------------------------------------------------------- 1 | from orders.orders_service.exceptions import OrderNotFoundError 2 | from orders.repository.orders_repository import OrdersRepository 3 | 4 | 5 | class OrdersService: 6 | def __init__(self, orders_repository: OrdersRepository): 7 | self.orders_repository = orders_repository 8 | 9 | def place_order(self, items, user_id): 10 | return self.orders_repository.add(items, user_id) 11 | 12 | def get_order(self, order_id, **filters): 13 | order = self.orders_repository.get(order_id, **filters) 14 | if order is not None: 15 | return order 16 | raise OrderNotFoundError(f"Order with id {order_id} not found") 17 | 18 | def update_order(self, order_id, user_id, **payload): 19 | order = self.orders_repository.get(order_id, user_id=user_id) 20 | if order is None: 21 | raise OrderNotFoundError(f"Order with id {order_id} not found") 22 | return self.orders_repository.update(order_id, **payload) 23 | 24 | def list_orders(self, **filters): 25 | limit = filters.pop("limit", None) 26 | return self.orders_repository.list(limit=limit, **filters) 27 | 28 | def pay_order(self, order_id, user_id): 29 | order = self.orders_repository.get(order_id, user_id=user_id) 30 | if order is None: 31 | raise OrderNotFoundError(f"Order with id {order_id} not found") 32 | order.pay() 33 | schedule_id = order.schedule() 34 | return self.orders_repository.update( 35 | order_id, status="progress", schedule_id=schedule_id 36 | ) 37 | 38 | def cancel_order(self, order_id, user_id): 39 | order = self.orders_repository.get(order_id, user_id=user_id) 40 | if order is None: 41 | raise OrderNotFoundError(f"Order with id {order_id} not found") 42 | order.cancel() 43 | return self.orders_repository.update(order_id, status="cancelled") 44 | 45 | def delete_order(self, order_id, user_id): 46 | order = self.orders_repository.get(order_id, user_id=user_id) 47 | if order is None: 48 | raise OrderNotFoundError(f"Order with id {order_id} not found") 49 | return self.orders_repository.delete(order_id) 50 | -------------------------------------------------------------------------------- /ch11/orders/repository/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from sqlalchemy import Column, Integer, String, ForeignKey, DateTime 5 | from sqlalchemy.ext.declarative import declarative_base 6 | from sqlalchemy.orm import relationship 7 | 8 | Base = declarative_base() 9 | 10 | 11 | def generate_uuid(): 12 | return str(uuid.uuid4()) 13 | 14 | 15 | class OrderModel(Base): 16 | __tablename__ = "order" 17 | 18 | id = Column(String, primary_key=True, default=generate_uuid) 19 | user_id = Column(String, nullable=False) 20 | items = relationship("OrderItemModel", backref="order") 21 | status = Column(String, nullable=False, default="created") 22 | created = Column(DateTime, default=datetime.utcnow) 23 | schedule_id = Column(String) 24 | delivery_id = Column(String) 25 | 26 | def dict(self): 27 | return { 28 | "id": self.id, 29 | "items": [item.dict() for item in self.items], 30 | "status": self.status, 31 | "created": self.created, 32 | "schedule_id": self.schedule_id, 33 | "delivery_id": self.delivery_id, 34 | } 35 | 36 | 37 | class OrderItemModel(Base): 38 | __tablename__ = "order_item" 39 | 40 | id = Column(String, primary_key=True, default=generate_uuid) 41 | order_id = Column(String, ForeignKey("order.id")) 42 | product = Column(String, nullable=False) 43 | size = Column(String, nullable=False) 44 | quantity = Column(Integer, nullable=False) 45 | 46 | def dict(self): 47 | return { 48 | "id": self.id, 49 | "product": self.product, 50 | "size": self.size, 51 | "quantity": self.quantity, 52 | } 53 | -------------------------------------------------------------------------------- /ch11/orders/repository/orders_repository.py: -------------------------------------------------------------------------------- 1 | from orders.orders_service.orders import Order 2 | from orders.repository.models import OrderModel, OrderItemModel 3 | 4 | 5 | class OrdersRepository: 6 | def __init__(self, session): 7 | self.session = session 8 | 9 | def add(self, items, user_id): 10 | record = OrderModel( 11 | items=[OrderItemModel(**item) for item in items], 12 | user_id=user_id, 13 | ) 14 | self.session.add(record) 15 | return Order(**record.dict(), order_=record) 16 | 17 | def _get(self, id_, **filters): 18 | return ( 19 | self.session.query(OrderModel) 20 | .filter(OrderModel.id == str(id_)) 21 | .filter_by(**filters) 22 | .first() 23 | ) 24 | 25 | def get(self, id_, **filters): 26 | order = self._get(id_, **filters) 27 | if order is not None: 28 | return Order(**order.dict()) 29 | 30 | def list(self, limit=None, **filters): 31 | query = self.session.query(OrderModel) 32 | if "cancelled" in filters: 33 | cancelled = filters.pop("cancelled") 34 | if cancelled: 35 | query = query.filter(OrderModel.status == "cancelled") 36 | else: 37 | query = query.filter(OrderModel.status != "cancelled") 38 | records = query.filter_by(**filters).limit(limit).all() 39 | return [Order(**record.dict()) for record in records] 40 | 41 | def update(self, id_, **payload): 42 | record = self._get(id_) 43 | if "items" in payload: 44 | for item in record.items: 45 | self.session.delete(item) 46 | record.items = [OrderItemModel(**item) for item in payload.pop("items")] 47 | for key, value in payload.items(): 48 | setattr(record, key, value) 49 | return Order(**record.dict()) 50 | 51 | def delete(self, id_): 52 | self.session.delete(self._get(id_)) 53 | -------------------------------------------------------------------------------- /ch11/orders/repository/unit_of_work.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import sessionmaker 3 | 4 | 5 | class UnitOfWork: 6 | def __init__(self): 7 | self.session_maker = sessionmaker(bind=create_engine("sqlite:///orders.db")) 8 | 9 | def __enter__(self): 10 | self.session = self.session_maker() 11 | return self 12 | 13 | def __exit__(self, exc_type, exc_val, traceback): 14 | if exc_type is not None: 15 | self.rollback() 16 | self.session.close() 17 | self.session.close() 18 | 19 | def commit(self): 20 | self.session.commit() 21 | 22 | def rollback(self): 23 | self.session.rollback() 24 | -------------------------------------------------------------------------------- /ch11/orders/web/api/auth.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import jwt 4 | from cryptography.x509 import load_pem_x509_certificate 5 | 6 | 7 | public_key_text = (Path(__file__).parent / "../../../public_key.pem").read_text() 8 | public_key = load_pem_x509_certificate(public_key_text.encode()).public_key() 9 | 10 | 11 | def decode_and_validate_token(access_token): 12 | """ 13 | Validates an access token. If the token is valid, it returns the token payload. 14 | """ 15 | return jwt.decode( 16 | access_token, 17 | key=public_key, 18 | algorithms=["RS256"], 19 | audience=["http://127.0.0.1:8000/orders"], 20 | ) 21 | -------------------------------------------------------------------------------- /ch11/orders/web/api/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import List, Optional 4 | from uuid import UUID 5 | 6 | from pydantic import BaseModel, Extra, conint, conlist, validator 7 | 8 | 9 | class Size(Enum): 10 | small = "small" 11 | medium = "medium" 12 | big = "big" 13 | 14 | 15 | class Status(Enum): 16 | created = "created" 17 | paid = "paid" 18 | progress = "progress" 19 | cancelled = "cancelled" 20 | dispatched = "dispatched" 21 | delivered = "delivered" 22 | 23 | 24 | class OrderItemSchema(BaseModel): 25 | product: str 26 | size: Size 27 | quantity: Optional[conint(ge=1, strict=True)] = 1 28 | 29 | class Config: 30 | extra = Extra.forbid 31 | 32 | @validator("quantity") 33 | def quantity_non_nullable(cls, value): 34 | assert value is not None, "quantity may not be None" 35 | return value 36 | 37 | 38 | class CreateOrderSchema(BaseModel): 39 | order: conlist(OrderItemSchema, min_items=1) 40 | 41 | class Config: 42 | extra = Extra.forbid 43 | 44 | 45 | class GetOrderSchema(CreateOrderSchema): 46 | id: UUID 47 | created: datetime 48 | status: Status 49 | 50 | 51 | class GetOrdersSchema(BaseModel): 52 | orders: List[GetOrderSchema] 53 | 54 | class Config: 55 | extra = Extra.forbid 56 | -------------------------------------------------------------------------------- /ch11/orders/web/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import yaml 5 | from fastapi import FastAPI 6 | from jwt import ( 7 | ExpiredSignatureError, 8 | ImmatureSignatureError, 9 | InvalidAlgorithmError, 10 | InvalidAudienceError, 11 | InvalidKeyError, 12 | InvalidSignatureError, 13 | InvalidTokenError, 14 | MissingRequiredClaimError, 15 | ) 16 | from starlette import status 17 | from starlette.middleware.base import ( 18 | RequestResponseEndpoint, 19 | BaseHTTPMiddleware, 20 | ) 21 | from starlette.middleware.cors import CORSMiddleware 22 | from starlette.requests import Request 23 | from starlette.responses import Response, JSONResponse 24 | 25 | from orders.web.api.auth import decode_and_validate_token 26 | 27 | app = FastAPI(debug=True, openapi_url="/openapi/orders.json", docs_url="/docs/orders") 28 | 29 | oas_doc = yaml.safe_load((Path(__file__).parent / "../../oas.yaml").read_text()) 30 | 31 | 32 | app.openapi = lambda: oas_doc 33 | 34 | 35 | class AuthorizeRequestMiddleware(BaseHTTPMiddleware): 36 | async def dispatch( 37 | self, request: Request, call_next: RequestResponseEndpoint 38 | ) -> Response: 39 | if os.getenv("AUTH_ON", "False") != "True": 40 | request.state.user_id = "test" 41 | return await call_next(request) 42 | 43 | if request.url.path in ["/docs/orders", "/openapi/orders.json"]: 44 | return await call_next(request) 45 | if request.method == "OPTIONS": 46 | return await call_next(request) 47 | 48 | bearer_token = request.headers.get("Authorization") 49 | if not bearer_token: 50 | return JSONResponse( 51 | status_code=status.HTTP_401_UNAUTHORIZED, 52 | content={ 53 | "detail": "Missing access token", 54 | "body": "Missing access token", 55 | }, 56 | ) 57 | try: 58 | auth_token = bearer_token.split(" ")[1].strip() 59 | token_payload = decode_and_validate_token(auth_token) 60 | except ( 61 | ExpiredSignatureError, 62 | ImmatureSignatureError, 63 | InvalidAlgorithmError, 64 | InvalidAudienceError, 65 | InvalidKeyError, 66 | InvalidSignatureError, 67 | InvalidTokenError, 68 | MissingRequiredClaimError, 69 | ) as error: 70 | return JSONResponse( 71 | status_code=status.HTTP_401_UNAUTHORIZED, 72 | content={"detail": str(error), "body": str(error)}, 73 | ) 74 | else: 75 | request.state.user_id = token_payload["sub"] 76 | return await call_next(request) 77 | 78 | 79 | app.add_middleware(AuthorizeRequestMiddleware) 80 | 81 | 82 | app.add_middleware( 83 | CORSMiddleware, 84 | allow_origins=["*"], 85 | allow_credentials=True, 86 | allow_methods=["*"], 87 | allow_headers=["*"], 88 | ) 89 | 90 | 91 | from orders.web.api import api 92 | -------------------------------------------------------------------------------- /ch11/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@stoplight/prism-cli": "^4.1.1" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ch11/payments.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | 3 | info: 4 | title: Payments API 5 | description: API to process payments for orders 6 | version: 1.0.0 7 | 8 | servers: 9 | - url: https://coffeemesh.com 10 | description: main production server 11 | - url: https://coffeemesh-staging.com 12 | description: staging server for testing purposes only 13 | 14 | paths: 15 | /payments: 16 | post: 17 | summary: Schedules an order for production 18 | requestBody: 19 | required: true 20 | content: 21 | application/json: 22 | schema: 23 | type: object 24 | required: 25 | - order_id 26 | - status 27 | properties: 28 | order_id: 29 | type: string 30 | format: uuid 31 | 32 | responses: 33 | '201': 34 | description: A JSON representation of the scheduled order 35 | content: 36 | application/json: 37 | schema: 38 | type: object 39 | required: 40 | - payment_id 41 | properties: 42 | payment_id: 43 | type: string 44 | format: uuid 45 | status: 46 | type: string 47 | enum: 48 | - invalid 49 | - paid 50 | - pending 51 | -------------------------------------------------------------------------------- /ch11/private_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDSoqxmFnnH1RfV 3 | lgePZH5Q7ekYe6fyjKu+IsTbRLcQAmTs+H0EiwUZ6UELwZ2wOOE6j4XHMPf1yiO/ 4 | ojAlN8u3Sg98sDJTnF68vRyLmBEdqonR50pC9FIkNVC9m8XFlvRwtojdvLSl1dwM 5 | e32n789XOjRCZ6RNvEhEnvsBBjSYnXmX25R7owk5qUMetJeJQQK5uFU2I13NONU0 6 | TirzFN2rhdamzpKHRzHjjvfsZfXlaLhzjLfzpk3VTq+fOfitVSHx2lOEHQzXALl2 7 | CtsOEk2Lu2ffsohZwp3nFPjE5mtpUTuRSaEW6OpeprSkWvG4WGYc7BVOOnWNms8e 8 | At5Nt//LAgMBAAECggEAUt3Kw1L+UB7GhLHEgaZAh6hBdu9XEHZFLsVQ+w6akoLO 9 | n+fWj03+EMaSX4Spe+W0viwurkHWm20OCVtOY6YC0DYjx6Mt+XTgVJJ1w3ls6mXo 10 | WJsMvTCPjE0pWZ8J/IU534oAaHPQAhoTuxluQv52bNOqMaHCow56w/xjtXByisNa 11 | AfYh4GVBWyYn+Zbma2Ogp+7ulpYFjNjhLQUKRojDGTAoTTfG+s8hjTKwODp9pzUQ 12 | tCr1GHOo3F3bFacvxQCH0WxvwFP3fmvYK29bxWUl/Z7cM5UwKxqGp6wU6efjqYvI 13 | Fw6Yak+KwFtLjVPF68tN9ntkU9b9/KY+qSsREVsmgQKBgQD7h8QKu48EITz0Zlwd 14 | C2ETK3m0UZRon2jEeXW+PzK6EFTwTnO/wghBLa+RkMvv4NjdenI8rknwYcLrP8Rt 15 | zd4k/mq2VzzsDA0UuDUjXgzbuYMNXpwAlJcVmSfaZF0KDVIZ2uIiWezFiENI0RqT 16 | j7XuXAubiJt8IOInA3CEGoQ6iwKBgQDWYN+CnbPepW4S2cINvwTDFoE1FA2Fic+R 17 | lG2JJ07Q3j/Ewu6f8quHyYUSRQ5DhWxwfe1msaYvKXflBj6C+jr/rAOVnyQ5HXLZ 18 | KfyHLURKn6m8HLEZEZEmAU58nRNq+exZftCWzp6K2WgE7K+1m6FeOl/yRK31fIap 19 | M6o/yNw3wQKBgQCXCk3EjCAzQKpTsGu73Str0X2BtENEGAVXhgAYP+b8J/Z5XwLO 20 | sXs3eHGnHaX447IWPQMAQUCRIoNjtKUFssukt0npOLWSoSHxwTPXixB5mQqDKr7O 21 | 8mtPQurVj9L2yEz2zaNhMVKmw050GWy2E2QSQB+QRBXqEez7tGsKSMoCRQKBgQCI 22 | OM5OBT/CfoRPXie87GBuRuKbg76D2GoZK6PevyeJ+W+z69oNsPnmMttoHJFPvnyF 23 | jr9HviLHXSZeVXVrbO4IgJlWfeValafg7pkUnGMEuCf27JRsRYliCPqCnJ02INFa 24 | nQaWjXyY5kT+vBd64wXLBnTpUVLo5tP6uGW6Wjv1AQKBgHc3OH8IZQSlfMoq5V2J 25 | oOEAv1gNnI4iYwpEqpq6X66wzsTr8JbUYpEsis0DJ7bV3EqPB4g69LJFcEq+/Wi7 26 | LQfORwIw4okEznejZ6C+tGTgvCV2qrDwm7c+F+QkjwmUDJiZHkoldUOTnQJwU78q 27 | vdVhGbUr3cR1U1btKvQBpnBj 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /ch11/pubkey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0qKsZhZ5x9UX1ZYHj2R+ 3 | UO3pGHun8oyrviLE20S3EAJk7Ph9BIsFGelBC8GdsDjhOo+FxzD39cojv6IwJTfL 4 | t0oPfLAyU5xevL0ci5gRHaqJ0edKQvRSJDVQvZvFxZb0cLaI3by0pdXcDHt9p+/P 5 | Vzo0QmekTbxIRJ77AQY0mJ15l9uUe6MJOalDHrSXiUECubhVNiNdzTjVNE4q8xTd 6 | q4XWps6Sh0cx44737GX15Wi4c4y386ZN1U6vnzn4rVUh8dpThB0M1wC5dgrbDhJN 7 | i7tn37KIWcKd5xT4xOZraVE7kUmhFujqXqa0pFrxuFhmHOwVTjp1jZrPHgLeTbf/ 8 | ywIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /ch11/public_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICpjCCAY4CCQDHM4WGoMj+8zANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDApj 3 | b2ZmZWVtZXNoMB4XDTIxMTEyODE1MzYzN1oXDTIxMTIyODE1MzYzN1owFTETMBEG 4 | A1UEAwwKY29mZmVlbWVzaDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB 5 | ANKirGYWecfVF9WWB49kflDt6Rh7p/KMq74ixNtEtxACZOz4fQSLBRnpQQvBnbA4 6 | 4TqPhccw9/XKI7+iMCU3y7dKD3ywMlOcXry9HIuYER2qidHnSkL0UiQ1UL2bxcWW 7 | 9HC2iN28tKXV3Ax7fafvz1c6NEJnpE28SESe+wEGNJideZfblHujCTmpQx60l4lB 8 | Arm4VTYjXc041TROKvMU3auF1qbOkodHMeOO9+xl9eVouHOMt/OmTdVOr585+K1V 9 | IfHaU4QdDNcAuXYK2w4STYu7Z9+yiFnCnecU+MTma2lRO5FJoRbo6l6mtKRa8bhY 10 | ZhzsFU46dY2azx4C3k23/8sCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAqAL+rxru 11 | kne3GaiwfPqmO1nKa1rkCj4mKCjzH+fbeOcYquDO1T1zd+8VWynHZUznWn15fmKF 12 | BP5foCP4xjJlNtCztDNmtDH6RVdZ3tOw2c8cJiP+jvBtCSgKgRG3srpx+xeE1S75 13 | l9vfhg40qNgvVDPrTmJv0gL//YKvXC/an/dYZWGbYkm/aCot0pDLWuGLdvWBsF9c 14 | yXIk/gxQii7uyp+j0jXfJCb3wSFHhog5fl4gIsHPp5kK8l+1xxF+XoM7YvAFso81 15 | VYbO/e2YrAlXSnLsPmeHlXJNfxwLs4c5LfFrPvyMlGKTb3LGHXDfkV+q9YZ1CD7P 16 | iOjXwWFX1Q2h7Q== 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /ch12/README.md: -------------------------------------------------------------------------------- 1 | # Chapter 12: Testing and validating APIs 2 | 3 | Here you learn how to test and validate your APIs. You'll learn about property-based testing, and how 4 | to apply it using Python's excellent property-based testing library [Hypothesis](https://github.com/HypothesisWorks/hypothesis). 5 | You'll also learn how to use the classic API testing framework [Dredd](https://github.com/apiaryio/dredd), 6 | and the more modern and excellent framework [Schemathesis](https://github.com/schemathesis/schemathesis). 7 | 8 | To keep things simple, the chapter recommends to test the orders API against 9 | the state of the implementation after chapter 6. However, it's also possible 10 | to continue straight from chapter 11. You don't need to make any special changes 11 | to the code. You can simply copy the orders service from chapter 11 instead of 12 | chapter 6 and run the tests just the same. Just make sure you run the API with 13 | the `AUTH_ON` flag set to `False` to make sure authorization doesn't get in the way 14 | of the testing. In this chapter, we're validating that the API works as documented. 15 | We're not running security tests. 16 | 17 | One thing to bear in mind is that, after chapter 7, our service has a database, and 18 | therefore it'll be loaded with resources after running the test. Specially after running 19 | the schemathesis test suite. My recommendation is start with a clean database every 20 | time you run the tests. To do that, follow the following steps: 21 | 22 | 1. Delete the database file: 23 | 24 | ```bash 25 | $ rm orders.db 26 | ``` 27 | 28 | 2. Then recreate the database by running the migrations: 29 | 30 | ```bash 31 | $ PYTHONPATH=`pwd` alembic upgrade heads 32 | ``` 33 | 34 | When running this kind of test against your own applications, bear this in mind too - 35 | they'll overload your test databases, so make sure you have a clean database in 36 | every test run. 37 | -------------------------------------------------------------------------------- /ch12/orders/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | dredd-hooks = "*" 8 | schemathesis = "*" 9 | black = "*" 10 | 11 | [packages] 12 | fastapi = "*" 13 | uvicorn = "*" 14 | schemathesis = "==3.15.3" 15 | 16 | [requires] 17 | python_version = "3.10" 18 | 19 | [pipenv] 20 | allow_prereleases = true 21 | -------------------------------------------------------------------------------- /ch12/orders/orders/api/api.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | from typing import Optional 4 | from uuid import UUID 5 | 6 | from fastapi import HTTPException 7 | from starlette import status 8 | from starlette.responses import Response 9 | 10 | from orders.app import app 11 | from orders.api.schemas import GetOrderSchema, CreateOrderSchema, GetOrdersSchema 12 | 13 | orders = [] 14 | 15 | 16 | @app.get('/orders', response_model=GetOrdersSchema) 17 | def get_orders(cancelled: Optional[bool] = None, limit: Optional[int] = None): 18 | if cancelled is None and limit is None: 19 | return {'orders': orders} 20 | 21 | query_set = [order for order in orders] 22 | 23 | if cancelled is not None: 24 | if cancelled: 25 | query_set = [order for order in query_set if order['status'] == 'cancelled'] # noqa: E501 26 | else: 27 | query_set = [order for order in query_set if order['status'] != 'cancelled'] # noqa: E501 28 | 29 | if limit is not None and len(query_set) > limit: 30 | return {'orders': query_set[:limit]} 31 | 32 | return {'orders': query_set} 33 | 34 | 35 | @app.post('/orders', status_code=status.HTTP_201_CREATED, response_model=GetOrderSchema) # noqa: E501 36 | def create_order(order: CreateOrderSchema): 37 | order = order.dict() 38 | order['id'] = uuid.uuid4() 39 | order['created'] = datetime.utcnow() 40 | order['status'] = 'created' 41 | orders.append(order) 42 | return order 43 | 44 | 45 | @app.get('/orders/{order_id}', response_model=GetOrderSchema) 46 | def get_order(order_id: UUID): 47 | for order in orders: 48 | if order['id'] == order_id: 49 | return order 50 | raise HTTPException( 51 | status_code=404, detail=f'Order with ID {order_id} not found' 52 | ) 53 | 54 | 55 | @app.put('/orders/{order_id}', response_model=GetOrderSchema) 56 | def update_order(order_id: UUID, order_details: CreateOrderSchema): 57 | for order in orders: 58 | if order['id'] == order_id: 59 | order.update(order_details) 60 | return order 61 | raise HTTPException( 62 | status_code=404, detail=f'Order with ID {order_id} not found' 63 | ) 64 | 65 | 66 | @app.delete('/orders/{order_id}', status_code=status.HTTP_204_NO_CONTENT, response_class=Response) 67 | def delete_order(order_id: UUID): 68 | for index, order in enumerate(orders): 69 | if order['id'] == order_id: 70 | orders.pop(index) 71 | return 72 | raise HTTPException( 73 | status_code=404, detail=f'Order with ID {order_id} not found' 74 | ) 75 | 76 | 77 | @app.post('/orders/{order_id}/cancel', response_model=GetOrderSchema) 78 | def cancel_order(order_id: UUID): 79 | for order in orders: 80 | if order['id'] == order_id: 81 | order['status'] = 'cancelled' 82 | return order 83 | raise HTTPException( 84 | status_code=404, detail=f'Order with ID {order_id} not found' 85 | ) 86 | 87 | 88 | @app.post('/orders/{order_id}/pay', response_model=GetOrderSchema) 89 | def pay_order(order_id: UUID): 90 | for order in orders: 91 | if order['id'] == order_id: 92 | order['status'] = 'paid' 93 | return order 94 | raise HTTPException( 95 | status_code=404, detail=f'Order with ID {order_id} not found' 96 | ) 97 | -------------------------------------------------------------------------------- /ch12/orders/orders/api/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import List, Optional 4 | from uuid import UUID 5 | 6 | from pydantic import BaseModel, Extra, conint, conlist, validator 7 | 8 | 9 | class Size(Enum): 10 | small = 'small' 11 | medium = 'medium' 12 | big = 'big' 13 | 14 | 15 | class Status(Enum): 16 | created = 'created' 17 | paid = 'paid' 18 | progress = 'progress' 19 | cancelled = 'cancelled' 20 | dispatched = 'dispatched' 21 | delivered = 'delivered' 22 | 23 | 24 | class OrderItemSchema(BaseModel): 25 | product: str 26 | size: Size 27 | quantity: Optional[conint(ge=1, strict=True)] = 1 28 | 29 | class Config: 30 | extra = Extra.forbid 31 | 32 | @validator('quantity') 33 | def quantity_non_nullable(cls, value): 34 | assert value is not None, 'quantity may not be None' 35 | return value 36 | 37 | 38 | class CreateOrderSchema(BaseModel): 39 | order: conlist(OrderItemSchema, min_items=1) 40 | 41 | class Config: 42 | extra = Extra.forbid 43 | 44 | 45 | class GetOrderSchema(CreateOrderSchema): 46 | id: UUID 47 | created: datetime 48 | status: Status 49 | 50 | 51 | class GetOrdersSchema(BaseModel): 52 | orders: List[GetOrderSchema] 53 | 54 | class Config: 55 | extra = Extra.forbid 56 | -------------------------------------------------------------------------------- /ch12/orders/orders/app.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import yaml 4 | from fastapi import FastAPI 5 | 6 | app = FastAPI(debug=True, openapi_url="/openapi/orders.json", docs_url="/docs/orders") 7 | 8 | oas_doc = yaml.safe_load((Path(__file__).parent / "../oas.yaml").read_text()) 9 | 10 | app.openapi = lambda: oas_doc 11 | 12 | from orders.api import api 13 | -------------------------------------------------------------------------------- /ch12/orders/orders/exceptions.py: -------------------------------------------------------------------------------- 1 | class OutOfStockError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /ch12/orders/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "dredd": "^14.0.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ch12/orders/test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import hypothesis.strategies as st 4 | import jsonschema 5 | import yaml 6 | from fastapi.testclient import TestClient 7 | from hypothesis import given, Verbosity, settings 8 | from jsonschema import ValidationError, RefResolver 9 | 10 | from orders.app import app 11 | 12 | 13 | orders_api_spec = yaml.full_load( 14 | (Path(__file__).parent / 'oas.yaml').read_text() 15 | ) 16 | create_order_schema = orders_api_spec['components']['schemas']['CreateOrderSchema'] 17 | 18 | 19 | def is_valid_payload(payload, schema): 20 | try: 21 | jsonschema.validate( 22 | payload, schema=schema, 23 | resolver=RefResolver('', orders_api_spec) 24 | ) 25 | except ValidationError: 26 | return False 27 | else: 28 | return True 29 | 30 | 31 | test_client = TestClient(app=app) 32 | 33 | values_strategy = ( 34 | st.none() | 35 | st.booleans() | 36 | st.text() | 37 | st.integers() 38 | ) 39 | 40 | order_item_strategy = st.fixed_dictionaries( 41 | { 42 | 'product': values_strategy, 43 | 'size': st.one_of(st.sampled_from(('small', 'medium', 'big'))) | values_strategy, 44 | 'quantity': values_strategy 45 | } 46 | ) 47 | 48 | strategy = st.fixed_dictionaries({ 49 | 'order': st.lists(order_item_strategy) 50 | }) 51 | 52 | 53 | @settings(verbosity=Verbosity.verbose, max_examples=500) 54 | @given(strategy) 55 | def test(payload): 56 | response = test_client.post('/orders', json=payload) 57 | if is_valid_payload(payload, create_order_schema): 58 | assert response.status_code == 201 59 | else: 60 | assert response.status_code == 422 61 | -------------------------------------------------------------------------------- /ch12/products/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | schemathesis = "*" 8 | pytest = "*" 9 | black = "*" 10 | 11 | [packages] 12 | uvicorn = "*" 13 | ariadne = "*" 14 | gql = "*" 15 | lock = "*" 16 | 17 | [requires] 18 | python_version = "3.10" 19 | 20 | [pipenv] 21 | allow_prereleases = true 22 | -------------------------------------------------------------------------------- /ch12/products/exceptions.py: -------------------------------------------------------------------------------- 1 | class ItemNotFoundError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /ch12/products/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "graphql-faker": "^2.0.0-rc.25" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ch12/products/server.py: -------------------------------------------------------------------------------- 1 | from ariadne.asgi import GraphQL 2 | 3 | from web.schema import schema 4 | 5 | server = GraphQL(schema, debug=True) 6 | -------------------------------------------------------------------------------- /ch12/products/test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import graphql 4 | import requests 5 | from graphql import parse 6 | from hypothesis import settings, given 7 | from hypothesis_graphql._strategies import queries 8 | from hypothesis_graphql._strategies.queries import make_selection_set_node 9 | from hypothesis_graphql.types import SelectionNodes 10 | 11 | 12 | def make_query_document(selections: SelectionNodes) -> graphql.DocumentNode: 13 | """Create top-level node for a query AST.""" 14 | return graphql.DocumentNode( 15 | kind="document", 16 | definitions=[ 17 | graphql.OperationDefinitionNode( 18 | kind="operation_definition", 19 | operation=graphql.OperationType.MUTATION, 20 | selection_set=make_selection_set_node(selections=selections), 21 | ) 22 | ], 23 | ) 24 | 25 | 26 | schema = Path('web/products.graphql').read_text() 27 | parsed_schema = graphql.build_schema(schema) 28 | add_ingredient_mutation = parsed_schema.mutation_type.fields['addIngredient'] 29 | context = queries.Context(parsed_schema) 30 | strategy = ( 31 | queries.subset_of_fields({'addIngredient': add_ingredient_mutation}) 32 | .flatmap(lambda field: queries.lists_of_field_nodes(context, field)) 33 | .map(make_query_document).map(graphql.print_ast) 34 | ) 35 | 36 | 37 | @settings(deadline=None, max_examples=500) 38 | @given(strategy) 39 | def test(case): 40 | response = requests.post('http://127.0.0.1:8000/graphql', json={'query': case}) 41 | if graphql.validate(parsed_schema, parse(case)): 42 | assert response.status_code == 400 43 | else: 44 | assert response.status_code == 200 45 | -------------------------------------------------------------------------------- /ch12/products/web/data.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | 4 | suppliers = [ 5 | { 6 | 'id': '92f2daae-a4f8-4aae-8d74-51dd74e5de6d', 7 | 'name': 'Milk Supplier', 8 | 'address': '77 Milk Way', 9 | 'contactNumber': '0987654321', 10 | 'email': 'milk@milksupplier.com', 11 | }, 12 | ] 13 | 14 | ingredients = [ 15 | { 16 | 'id': '602f2ab3-97bd-468e-a88b-bb9e00531fd0', 17 | 'name': 'Milk', 18 | 'stock': { 19 | 'quantity': 100.00, 20 | 'unit': 'LITERS', 21 | }, 22 | 'products': [], 23 | 'supplier': '92f2daae-a4f8-4aae-8d74-51dd74e5de6d', 24 | 'lastUpdated': datetime.utcnow(), 25 | }, 26 | ] 27 | 28 | 29 | products = [ 30 | { 31 | 'id': '6961ca64-78f3-41d4-bc3b-a63550754bd8', 32 | 'name': 'Walnut Bomb', 33 | 'price': 37.00, 34 | 'size': 'MEDIUM', 35 | 'available': False, 36 | 'ingredients': [ 37 | { 38 | 'ingredient': '602f2ab3-97bd-468e-a88b-bb9e00531fd0', 39 | 'quantity': 100.00, 40 | 'unit': 'LITERS', 41 | } 42 | ], 43 | 'hasFilling': False, 44 | 'hasNutsToppingOption': True, 45 | 'lastUpdated': datetime.utcnow(), 46 | }, 47 | { 48 | 'id': 'e4e33d0b-1355-4735-9505-749e3fdf8a16', 49 | 'name': 'Cappuccino Star', 50 | 'price': 12.50, 51 | 'size': 'SMALL', 52 | 'available': True, 53 | 'ingredients': [ 54 | { 55 | 'ingredient': '602f2ab3-97bd-468e-a88b-bb9e00531fd0', 56 | 'quantity': 100.00, 57 | 'unit': 'LITERS', 58 | } 59 | ], 60 | 'hasCreamOnTopOption': True, 61 | 'hasServeOnIceOption': True, 62 | 'lastUpdated': datetime.utcnow(), 63 | }, 64 | ] 65 | -------------------------------------------------------------------------------- /ch12/products/web/mutations.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from ariadne import MutationType 5 | 6 | from exceptions import ItemNotFoundError 7 | from web.data import products, ingredients, suppliers 8 | 9 | mutation = MutationType() 10 | 11 | 12 | @mutation.field('addSupplier') 13 | def resolve_add_supplier(*_, name, input): 14 | input['name'] = name 15 | input['id'] = uuid.uuid4() 16 | suppliers.append(input) 17 | return input 18 | 19 | 20 | @mutation.field('addIngredient') 21 | def resolve_add_ingredient(*_, name, input): 22 | input['name'] = name 23 | input['id'] = uuid.uuid4() 24 | input['lastUpdated'] = datetime.utcnow() 25 | ingredients.append(input) 26 | return input 27 | 28 | 29 | @mutation.field('addProduct') 30 | def resolve_add_product(*_, name, type, input): 31 | product = { 32 | 'id': uuid.uuid4(), 33 | 'name': name, 34 | 'available': input.get('available', False), 35 | 'ingredients': input.get('ingredients', []), 36 | 'lastUpdated': datetime.utcnow(), 37 | } 38 | if type == 'cake': 39 | product.update({ 40 | 'hasFilling': input['hasFilling'], 41 | 'hasNutsToppingOption': input['hasNutsToppingOption'], 42 | }) 43 | else: 44 | product.update({ 45 | 'hasCreamOnTopOption': input['hasCreamOnTopOption'], 46 | 'hasServeOnIceOption': input['hasServeOnIceOption'], 47 | }) 48 | products.append(product) 49 | return product 50 | 51 | 52 | @mutation.field('updateProduct') 53 | def resolve_update_product(*_, id, input): 54 | for product in products: 55 | if product['id'] == id: 56 | product.update(input) 57 | product['lastUpdated'] = datetime.utcnow() 58 | return product 59 | raise ItemNotFoundError(f'Product with ID {id} not found') 60 | 61 | 62 | @mutation.field('deleteProduct') 63 | def resolve_delete_product(*_, id): 64 | for index, product in enumerate(products): 65 | if product['id'] == id: 66 | products.pop(index) 67 | return True 68 | raise ItemNotFoundError(f'Product with ID {id} not found') 69 | 70 | 71 | @mutation.field('updateStock') 72 | def resolve_update_stock(*_, id, changeAmount): 73 | for ingredient in ingredients: 74 | if ingredient['id'] == id: 75 | ingredient['stock'] = changeAmount 76 | return ingredient 77 | raise ItemNotFoundError(f'Ingredient with ID {id} not found') 78 | -------------------------------------------------------------------------------- /ch12/products/web/queries.py: -------------------------------------------------------------------------------- 1 | from itertools import islice 2 | 3 | from ariadne import QueryType 4 | 5 | from exceptions import ItemNotFoundError 6 | from web.data import products, ingredients 7 | 8 | query = QueryType() 9 | 10 | 11 | @query.field('allProducts') 12 | def resolve_all_products(*_): 13 | return products 14 | 15 | 16 | @query.field('allIngredients') 17 | def resolve_all_ingredients(*_): 18 | return ingredients 19 | 20 | 21 | def get_page(items, items_per_page, page): 22 | page = page - 1 23 | start = items_per_page * page if page > 0 else page 24 | stop = start + items_per_page 25 | return list(islice(items, start, stop)) 26 | 27 | 28 | @query.field('products') 29 | def resolve_products(*_, input=None): 30 | filtered = [product for product in products] 31 | if input is None: 32 | return filtered 33 | filtered = [ 34 | product for product in filtered 35 | if product['available'] is input['available'] 36 | ] 37 | if input.get('minPrice') is not None: 38 | filtered = [ 39 | product for product in filtered 40 | if product['price'] >= input['minPrice'] 41 | ] 42 | if input.get('maxPrice') is not None: 43 | filtered = [ 44 | product for product in filtered 45 | if product['price'] <= input['maxPrice'] 46 | ] 47 | filtered.sort( 48 | key=lambda product: product[input['sortBy']], 49 | reverse=input['sort'] == 'DESCENDING' 50 | ) 51 | return get_page(filtered, input['resultsPerPage'], input['page']) 52 | 53 | 54 | @query.field('product') 55 | def resolve_product(*_, id): 56 | for product in products: 57 | if product['id'] == id: 58 | return product 59 | raise ItemNotFoundError(f'Product with ID {id} not found') 60 | 61 | 62 | @query.field('ingredient') 63 | def resolve_ingredient(*_, id): 64 | for ingredient in ingredients: 65 | if ingredient['id'] == id: 66 | return ingredient 67 | raise ItemNotFoundError(f'Ingredient with ID {id} not found') 68 | -------------------------------------------------------------------------------- /ch12/products/web/schema.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from ariadne import make_executable_schema 4 | 5 | from web.mutations import mutation 6 | from web.queries import query 7 | from web.types import product_type, datetime_scalar, product_interface, ingredient_type, supplier_type 8 | 9 | 10 | schema = make_executable_schema( 11 | (Path(__file__).parent / 'products.graphql').read_text(), 12 | [query, mutation, product_interface, product_type, ingredient_type, supplier_type, datetime_scalar] 13 | ) 14 | -------------------------------------------------------------------------------- /ch12/products/web/types.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from datetime import datetime 3 | 4 | from ariadne import UnionType, ScalarType, InterfaceType, ObjectType 5 | 6 | from web.data import ingredients, suppliers, products 7 | 8 | product_interface = InterfaceType('ProductInterface') 9 | product_type = UnionType('Product') 10 | ingredient_type = ObjectType('Ingredient') 11 | supplier_type = ObjectType('Supplier') 12 | 13 | datetime_scalar = ScalarType('Datetime') 14 | 15 | 16 | @datetime_scalar.serializer 17 | def serialize_datetime_scalar(date): 18 | return date.isoformat() 19 | 20 | 21 | @datetime_scalar.value_parser 22 | def parse_datetime_scalar(date): 23 | return datetime.fromisoformat(date) 24 | 25 | 26 | @product_type.type_resolver 27 | def resolve_product_type(obj, *_): 28 | if 'hasFilling' in obj: 29 | return 'Cake' 30 | return 'Beverage' 31 | 32 | 33 | @product_interface.field('ingredients') 34 | def resolve_product_ingredients(product, _): 35 | recipe = [copy.copy(ingredient) for ingredient in product.get('ingredients', [])] 36 | for ingredient_recipe in recipe: 37 | for ingredient in ingredients: 38 | if ingredient['id'] == ingredient_recipe['ingredient']: 39 | ingredient_recipe['ingredient'] = ingredient 40 | return recipe 41 | 42 | 43 | @ingredient_type.field('supplier') 44 | def resolve_ingredient_suppliers(ingredient, _): 45 | if ingredient.get('supplier') is not None: 46 | for supplier in suppliers: 47 | if supplier['id'] == ingredient['supplier']: 48 | return supplier 49 | 50 | 51 | @ingredient_type.field('products') 52 | def resolve_ingredient_products(ingredient, _): 53 | return [ 54 | product for product in products 55 | if ingredient['id'] in product.get('ingredients', []) 56 | ] 57 | 58 | 59 | @supplier_type.field('ingredients') 60 | def resolve_supplier_ingredients(supplier, _): 61 | return [ 62 | ingredient for ingredient in ingredients 63 | if supplier['id'] in ingredient.get('suppliers', []) 64 | ] 65 | -------------------------------------------------------------------------------- /ch13/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | RUN mkdir -p /orders/orders 4 | 5 | WORKDIR /orders 6 | 7 | RUN pip install -U pip && pip install pipenv 8 | 9 | COPY Pipfile Pipfile.lock /orders/ 10 | 11 | RUN pipenv install --system --deploy 12 | 13 | COPY orders/orders_service /orders/orders/orders_service/ 14 | COPY orders/repository /orders/orders/repository/ 15 | COPY orders/web /orders/orders/web/ 16 | COPY oas.yaml /orders/oas.yaml 17 | COPY public_key.pem /orders/public_key.pem 18 | COPY private.pem /orders/private.pem 19 | 20 | EXPOSE 8000 21 | 22 | CMD ["uvicorn", "orders.web.app:app", "--host", "0.0.0.0"] 23 | -------------------------------------------------------------------------------- /ch13/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | alembic = "*" 8 | black = "*" 9 | awscli = "*" 10 | 11 | [packages] 12 | cryptography = "*" 13 | fastapi = "*" 14 | pyjwt = "*" 15 | requests = "*" 16 | sqlalchemy = "*" 17 | uvicorn = "*" 18 | pyyaml = "*" 19 | psycopg2-binary = "*" 20 | 21 | [requires] 22 | python_version = "3.10" 23 | 24 | [pipenv] 25 | allow_prereleases = true 26 | -------------------------------------------------------------------------------- /ch13/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | sqlalchemy.url = 39 | 40 | 41 | [post_write_hooks] 42 | # post_write_hooks defines scripts or Python functions that are run 43 | # on newly generated revision scripts. See the documentation for further 44 | # detail and examples 45 | 46 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 47 | # hooks=black 48 | # black.type=console_scripts 49 | # black.entrypoint=black 50 | # black.options=-l 79 51 | 52 | # Logging configuration 53 | [loggers] 54 | keys = root,sqlalchemy,alembic 55 | 56 | [handlers] 57 | keys = console 58 | 59 | [formatters] 60 | keys = generic 61 | 62 | [logger_root] 63 | level = WARN 64 | handlers = console 65 | qualname = 66 | 67 | [logger_sqlalchemy] 68 | level = WARN 69 | handlers = 70 | qualname = sqlalchemy.engine 71 | 72 | [logger_alembic] 73 | level = INFO 74 | handlers = 75 | qualname = alembic 76 | 77 | [handler_console] 78 | class = StreamHandler 79 | args = (sys.stderr,) 80 | level = NOTSET 81 | formatter = generic 82 | 83 | [formatter_generic] 84 | format = %(levelname)-5.5s [%(name)s] %(message)s 85 | datefmt = %H:%M:%S 86 | -------------------------------------------------------------------------------- /ch13/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | 5 | database: 6 | image: postgres:14.3 7 | ports: 8 | - 5432:5432 9 | environment: 10 | POSTGRES_PASSWORD: postgres 11 | POSTGRES_USER: postgres 12 | POSTGRES_DB: postgres 13 | volumes: 14 | - database-data:/var/lib/postgresql/data 15 | 16 | api: 17 | build: . 18 | ports: 19 | - 8000:8000 20 | depends_on: 21 | - database 22 | environment: 23 | DB_URL: postgresql://postgres:postgres@database:5432/postgres 24 | 25 | volumes: 26 | database-data: 27 | -------------------------------------------------------------------------------- /ch13/machine_to_machine_test.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def get_access_token(): 5 | payload = { 6 | "client_id": "", 7 | "client_secret": "", 8 | "audience": "http://127.0.0.1:8000/orders", 9 | "grant_type": "client_credentials" 10 | } 11 | 12 | response = requests.post( 13 | "https://coffeemesh-dev.eu.auth0.com/oauth/token", 14 | json=payload, 15 | headers={'content-type': "application/json"} 16 | ) 17 | 18 | return response.json()['access_token'] 19 | 20 | 21 | def create_order(token): 22 | order_payload = { 23 | 'order': [{ 24 | 'product': 'asdf', 25 | 'size': 'small', 26 | 'quantity': 1 27 | }] 28 | } 29 | 30 | order = requests.post( 31 | 'http://127.0.0.1:8000/orders', 32 | json=order_payload, 33 | headers={'content-type': "application/json", "Authorization": f"Bearer {token}"} 34 | ) 35 | 36 | return order.json() 37 | 38 | 39 | access_token = get_access_token() 40 | print(access_token) 41 | order = create_order(access_token) 42 | print(order) 43 | -------------------------------------------------------------------------------- /ch13/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /ch13/migrations/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from logging.config import fileConfig 4 | 5 | from sqlalchemy import engine_from_config, create_engine 6 | from sqlalchemy import pool 7 | 8 | from alembic import context 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) 17 | 18 | # add your model's MetaData object here 19 | # for 'autogenerate' support 20 | from orders.repository.models import Base 21 | target_metadata = Base.metadata 22 | # target_metadata = None 23 | 24 | # other values from the config, defined by the needs of env.py, 25 | # can be acquired: 26 | # my_important_option = config.get_main_option("my_important_option") 27 | # ... etc. 28 | 29 | 30 | def run_migrations_offline(): 31 | """Run migrations in 'offline' mode. 32 | 33 | This configures the context with just a URL 34 | and not an Engine, though an Engine is acceptable 35 | here as well. By skipping the Engine creation 36 | we don't even need a DBAPI to be available. 37 | 38 | Calls to context.execute() here emit the given string to the 39 | script output. 40 | 41 | """ 42 | url = os.getenv('DB_URL') 43 | 44 | assert url is not None, 'DB_URL environment variable needed.' 45 | 46 | context.configure( 47 | url=url, 48 | target_metadata=target_metadata, 49 | literal_binds=True, 50 | dialect_opts={"paramstyle": "named"}, 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online(): 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | url = os.getenv('DB_URL') 65 | 66 | assert url is not None, 'DB_URL environment variable needed.' 67 | 68 | connectable = create_engine(url) 69 | 70 | with connectable.connect() as connection: 71 | context.configure( 72 | connection=connection, 73 | target_metadata=target_metadata, 74 | render_as_batch=True, 75 | ) 76 | 77 | with context.begin_transaction(): 78 | context.run_migrations() 79 | 80 | 81 | if context.is_offline_mode(): 82 | run_migrations_offline() 83 | else: 84 | run_migrations_online() 85 | -------------------------------------------------------------------------------- /ch13/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /ch13/migrations/versions/bd1046019404_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: bd1046019404 4 | Revises: 5 | Create Date: 2020-12-06 21:44:18.071169 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'bd1046019404' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('order', 22 | sa.Column('id', sa.String(), nullable=False), 23 | sa.Column('status', sa.String(), nullable=False), 24 | sa.Column('created', sa.DateTime(), nullable=True), 25 | sa.Column('schedule_id', sa.String(), nullable=True), 26 | sa.Column('delivery_id', sa.String(), nullable=True), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | op.create_table('order_item', 30 | sa.Column('id', sa.String(), nullable=False), 31 | sa.Column('order_id', sa.String(), nullable=True), 32 | sa.Column('product', sa.String(), nullable=False), 33 | sa.Column('size', sa.String(), nullable=False), 34 | sa.Column('quantity', sa.Integer(), nullable=False), 35 | sa.ForeignKeyConstraint(['order_id'], ['order.id'], ), 36 | sa.PrimaryKeyConstraint('id') 37 | ) 38 | # ### end Alembic commands ### 39 | 40 | 41 | def downgrade(): 42 | # ### commands auto generated by Alembic - please adjust! ### 43 | op.drop_table('order_item') 44 | op.drop_table('order') 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /ch13/migrations/versions/cf6a8fb1fd44_add_user_id_to_order_table.py: -------------------------------------------------------------------------------- 1 | """Add user id to order table 2 | 3 | Revision ID: cf6a8fb1fd44 4 | Revises: bd1046019404 5 | Create Date: 2021-11-07 12:28:17.852145 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'cf6a8fb1fd44' 14 | down_revision = 'bd1046019404' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('order', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('user_id', sa.String(), nullable=False)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('order', schema=None) as batch_op: 30 | batch_op.drop_column('user_id') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /ch13/orders/exceptions.py: -------------------------------------------------------------------------------- 1 | class OutOfStockError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /ch13/orders/orders_service/exceptions.py: -------------------------------------------------------------------------------- 1 | class OrderNotFoundError(Exception): 2 | pass 3 | 4 | 5 | class APIIntegrationError(Exception): 6 | pass 7 | 8 | 9 | class InvalidActionError(Exception): 10 | pass 11 | -------------------------------------------------------------------------------- /ch13/orders/orders_service/orders.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from orders.orders_service.exceptions import APIIntegrationError, InvalidActionError 4 | 5 | 6 | class OrderItem: 7 | def __init__(self, id, product, quantity, size): 8 | self.id = id 9 | self.product = product 10 | self.quantity = quantity 11 | self.size = size 12 | 13 | def dict(self): 14 | return {"product": self.product, "size": self.size, "quantity": self.quantity} 15 | 16 | 17 | class Order: 18 | def __init__( 19 | self, 20 | id, 21 | created, 22 | items, 23 | status, 24 | schedule_id=None, 25 | delivery_id=None, 26 | order_=None, 27 | ): 28 | self._order = order_ 29 | self._id = id 30 | self._created = created 31 | self.items = [OrderItem(**item) for item in items] 32 | self._status = status 33 | self.schedule_id = schedule_id 34 | self.delivery_id = delivery_id 35 | 36 | @property 37 | def id(self): 38 | return self._id or self._order.id 39 | 40 | @property 41 | def created(self): 42 | return self._created or self._order.created 43 | 44 | @property 45 | def status(self): 46 | return self._status or self._order.status 47 | 48 | def cancel(self): 49 | if self.status == "progress": 50 | response = requests.post( 51 | f"http://localhost:3000/kitchen/schedules/{self.schedule_id}/cancel", 52 | json={"order": [item.dict() for item in self.items]}, 53 | ) 54 | if response.status_code == 200: 55 | return 56 | raise APIIntegrationError(f"Could not cancel order with id {self.id}") 57 | if self.status == "delivery": 58 | raise InvalidActionError(f"Cannot cancel order with id {self.id}") 59 | 60 | def pay(self): 61 | response = requests.post( 62 | "http://localhost:3001/payments", json={"order_id": self.id} 63 | ) 64 | if response.status_code == 201: 65 | return 66 | raise APIIntegrationError( 67 | f"Could not process payment for order with id {self.id}" 68 | ) 69 | 70 | def schedule(self): 71 | response = requests.post( 72 | "http://localhost:3000/kitchen/schedules", 73 | json={"order": [item.dict() for item in self.items]}, 74 | ) 75 | if response.status_code == 201: 76 | return response.json()["id"] 77 | raise APIIntegrationError(f"Could not schedule order with id {self.id}") 78 | 79 | def dict(self): 80 | return { 81 | "id": self.id, 82 | "order": [item.dict() for item in self.items], 83 | "status": self.status, 84 | "created": self.created, 85 | } 86 | -------------------------------------------------------------------------------- /ch13/orders/orders_service/orders_service.py: -------------------------------------------------------------------------------- 1 | from orders.orders_service.exceptions import OrderNotFoundError 2 | from orders.repository.orders_repository import OrdersRepository 3 | 4 | 5 | class OrdersService: 6 | def __init__(self, orders_repository: OrdersRepository): 7 | self.orders_repository = orders_repository 8 | 9 | def place_order(self, items, user_id): 10 | return self.orders_repository.add(items, user_id) 11 | 12 | def get_order(self, order_id, **filters): 13 | order = self.orders_repository.get(order_id, **filters) 14 | if order is not None: 15 | return order 16 | raise OrderNotFoundError(f"Order with id {order_id} not found") 17 | 18 | def update_order(self, order_id, user_id, **payload): 19 | order = self.orders_repository.get(order_id, user_id=user_id) 20 | if order is None: 21 | raise OrderNotFoundError(f"Order with id {order_id} not found") 22 | return self.orders_repository.update(order_id, **payload) 23 | 24 | def list_orders(self, **filters): 25 | limit = filters.pop("limit", None) 26 | return self.orders_repository.list(limit=limit, **filters) 27 | 28 | def pay_order(self, order_id, user_id): 29 | order = self.orders_repository.get(order_id, user_id=user_id) 30 | if order is None: 31 | raise OrderNotFoundError(f"Order with id {order_id} not found") 32 | order.pay() 33 | schedule_id = order.schedule() 34 | return self.orders_repository.update( 35 | order_id, status="progress", schedule_id=schedule_id 36 | ) 37 | 38 | def cancel_order(self, order_id, user_id): 39 | order = self.orders_repository.get(order_id, user_id=user_id) 40 | if order is None: 41 | raise OrderNotFoundError(f"Order with id {order_id} not found") 42 | order.cancel() 43 | return self.orders_repository.update(order_id, status="cancelled") 44 | 45 | def delete_order(self, order_id, user_id): 46 | order = self.orders_repository.get(order_id, user_id=user_id) 47 | if order is None: 48 | raise OrderNotFoundError(f"Order with id {order_id} not found") 49 | return self.orders_repository.delete(order_id) 50 | -------------------------------------------------------------------------------- /ch13/orders/repository/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from sqlalchemy import Column, Integer, String, ForeignKey, DateTime 5 | from sqlalchemy.ext.declarative import declarative_base 6 | from sqlalchemy.orm import relationship 7 | 8 | Base = declarative_base() 9 | 10 | 11 | def generate_uuid(): 12 | return str(uuid.uuid4()) 13 | 14 | 15 | class OrderModel(Base): 16 | __tablename__ = "order" 17 | 18 | id = Column(String, primary_key=True, default=generate_uuid) 19 | user_id = Column(String, nullable=False) 20 | items = relationship("OrderItemModel", backref="order") 21 | status = Column(String, nullable=False, default="created") 22 | created = Column(DateTime, default=datetime.utcnow) 23 | schedule_id = Column(String) 24 | delivery_id = Column(String) 25 | 26 | def dict(self): 27 | return { 28 | "id": self.id, 29 | "items": [item.dict() for item in self.items], 30 | "status": self.status, 31 | "created": self.created, 32 | "schedule_id": self.schedule_id, 33 | "delivery_id": self.delivery_id, 34 | } 35 | 36 | 37 | class OrderItemModel(Base): 38 | __tablename__ = "order_item" 39 | 40 | id = Column(String, primary_key=True, default=generate_uuid) 41 | order_id = Column(String, ForeignKey("order.id")) 42 | product = Column(String, nullable=False) 43 | size = Column(String, nullable=False) 44 | quantity = Column(Integer, nullable=False) 45 | 46 | def dict(self): 47 | return { 48 | "id": self.id, 49 | "product": self.product, 50 | "size": self.size, 51 | "quantity": self.quantity, 52 | } 53 | -------------------------------------------------------------------------------- /ch13/orders/repository/orders_repository.py: -------------------------------------------------------------------------------- 1 | from orders.orders_service.orders import Order 2 | from orders.repository.models import OrderModel, OrderItemModel 3 | 4 | 5 | class OrdersRepository: 6 | def __init__(self, session): 7 | self.session = session 8 | 9 | def add(self, items, user_id): 10 | record = OrderModel( 11 | items=[OrderItemModel(**item) for item in items], user_id=user_id 12 | ) 13 | self.session.add(record) 14 | return Order(**record.dict(), order_=record) 15 | 16 | def _get(self, id_, **filters): 17 | return ( 18 | self.session.query(OrderModel) 19 | .filter(OrderModel.id == str(id_)) 20 | .filter_by(**filters) 21 | .first() 22 | ) 23 | 24 | def get(self, id_, **filters): 25 | order = self._get(id_, **filters) 26 | if order is not None: 27 | return Order(**order.dict()) 28 | 29 | def list(self, limit=None, **filters): 30 | query = self.session.query(OrderModel) 31 | if "cancelled" in filters: 32 | cancelled = filters.pop("cancelled") 33 | if cancelled: 34 | query = query.filter(OrderModel.status == "cancelled") 35 | else: 36 | query = query.filter(OrderModel.status != "cancelled") 37 | records = query.filter_by(**filters).limit(limit).all() 38 | return [Order(**record.dict()) for record in records] 39 | 40 | def update(self, id_, **payload): 41 | record = self._get(id_) 42 | if "items" in payload: 43 | for item in record.items: 44 | self.session.delete(item) 45 | record.items = [OrderItemModel(**item) for item in payload.pop("items")] 46 | for key, value in payload.items(): 47 | setattr(record, key, value) 48 | return Order(**record.dict()) 49 | 50 | def delete(self, id_): 51 | self.session.delete(self._get(id_)) 52 | -------------------------------------------------------------------------------- /ch13/orders/repository/unit_of_work.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.orm import sessionmaker 5 | 6 | 7 | DB_URL = os.getenv('DB_URL') 8 | 9 | assert DB_URL is not None, 'DB_URL environment variable needed.' 10 | 11 | 12 | class UnitOfWork: 13 | def __init__(self): 14 | self.session_maker = sessionmaker(bind=create_engine(DB_URL)) 15 | 16 | def __enter__(self): 17 | self.session = self.session_maker() 18 | return self 19 | 20 | def __exit__(self, exc_type, exc_val, traceback): 21 | if exc_type is not None: 22 | self.rollback() 23 | self.session.close() 24 | self.session.close() 25 | 26 | def commit(self): 27 | self.session.commit() 28 | 29 | def rollback(self): 30 | self.session.rollback() 31 | -------------------------------------------------------------------------------- /ch13/orders/web/api/auth.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import jwt 4 | from cryptography.x509 import load_pem_x509_certificate 5 | 6 | 7 | public_key_text = (Path(__file__).parent / "../../../public_key.pem").read_text() 8 | public_key = load_pem_x509_certificate(public_key_text.encode()).public_key() 9 | 10 | 11 | def decode_and_validate_token(access_token): 12 | """ 13 | Validates an access token. If the token is valid, it returns the token payload. 14 | """ 15 | return jwt.decode( 16 | access_token, 17 | key=public_key, 18 | algorithms=["RS256"], 19 | audience=["http://127.0.0.1:8000/orders"], 20 | ) 21 | -------------------------------------------------------------------------------- /ch13/orders/web/api/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import List, Optional 4 | from uuid import UUID 5 | 6 | from pydantic import BaseModel, Extra, conint, conlist, validator 7 | 8 | 9 | class Size(Enum): 10 | small = "small" 11 | medium = "medium" 12 | big = "big" 13 | 14 | 15 | class Status(Enum): 16 | created = "created" 17 | paid = "paid" 18 | progress = "progress" 19 | cancelled = "cancelled" 20 | dispatched = "dispatched" 21 | delivered = "delivered" 22 | 23 | 24 | class OrderItemSchema(BaseModel): 25 | product: str 26 | size: Size 27 | quantity: Optional[conint(ge=1, strict=True)] = 1 28 | 29 | class Config: 30 | extra = Extra.forbid 31 | 32 | @validator("quantity") 33 | def quantity_non_nullable(cls, value): 34 | assert value is not None, "quantity may not be None" 35 | return value 36 | 37 | 38 | class CreateOrderSchema(BaseModel): 39 | order: conlist(OrderItemSchema, min_items=1) 40 | 41 | class Config: 42 | extra = Extra.forbid 43 | 44 | 45 | class GetOrderSchema(CreateOrderSchema): 46 | id: UUID 47 | created: datetime 48 | status: Status 49 | 50 | 51 | class GetOrdersSchema(BaseModel): 52 | orders: List[GetOrderSchema] 53 | 54 | class Config: 55 | extra = Extra.forbid 56 | -------------------------------------------------------------------------------- /ch13/orders/web/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import yaml 5 | from fastapi import FastAPI 6 | from jwt import ( 7 | ExpiredSignatureError, 8 | ImmatureSignatureError, 9 | InvalidAlgorithmError, 10 | InvalidAudienceError, 11 | InvalidKeyError, 12 | InvalidSignatureError, 13 | InvalidTokenError, 14 | MissingRequiredClaimError, 15 | ) 16 | from starlette import status 17 | from starlette.middleware.base import RequestResponseEndpoint, BaseHTTPMiddleware 18 | from starlette.middleware.cors import CORSMiddleware 19 | from starlette.requests import Request 20 | from starlette.responses import Response, JSONResponse 21 | 22 | from orders.web.api.auth import decode_and_validate_token 23 | 24 | app = FastAPI(debug=True, openapi_url="/openapi/orders.json", docs_url="/docs/orders") 25 | 26 | oas_doc = yaml.safe_load((Path(__file__).parent / "../../oas.yaml").read_text()) 27 | 28 | app.openapi = lambda: oas_doc 29 | 30 | 31 | class AuthorizeRequestMiddleware(BaseHTTPMiddleware): 32 | async def dispatch( 33 | self, request: Request, call_next: RequestResponseEndpoint 34 | ) -> Response: 35 | if os.getenv("AUTH_ON", "False") != "True": 36 | request.state.user_id = "test" 37 | return await call_next(request) 38 | 39 | if request.url.path in ["/docs/orders", "/openapi/orders.json"]: 40 | return await call_next(request) 41 | if request.method == "OPTIONS": 42 | return await call_next(request) 43 | 44 | bearer_token = request.headers.get("Authorization") 45 | if not bearer_token: 46 | return JSONResponse( 47 | status_code=status.HTTP_401_UNAUTHORIZED, 48 | content={ 49 | "detail": "Missing access token", 50 | "body": "Missing access token", 51 | }, 52 | ) 53 | try: 54 | auth_token = bearer_token.split(" ")[1].strip() 55 | token_payload = decode_and_validate_token(auth_token) 56 | except ( 57 | ExpiredSignatureError, 58 | ImmatureSignatureError, 59 | InvalidAlgorithmError, 60 | InvalidAudienceError, 61 | InvalidKeyError, 62 | InvalidSignatureError, 63 | InvalidTokenError, 64 | MissingRequiredClaimError, 65 | ) as error: 66 | return JSONResponse( 67 | status_code=status.HTTP_401_UNAUTHORIZED, 68 | content={"detail": str(error), "body": str(error)}, 69 | ) 70 | else: 71 | request.state.user_id = token_payload["sub"] 72 | return await call_next(request) 73 | 74 | 75 | app.add_middleware(AuthorizeRequestMiddleware) 76 | 77 | 78 | app.add_middleware( 79 | CORSMiddleware, 80 | allow_origins=["*"], 81 | allow_credentials=True, 82 | allow_methods=["*"], 83 | allow_headers=["*"], 84 | ) 85 | 86 | 87 | from orders.web.api import api 88 | -------------------------------------------------------------------------------- /ch13/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@stoplight/prism-cli": "^4.1.1" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ch13/payments.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | 3 | info: 4 | title: Payments API 5 | description: API to process payments for orders 6 | version: 1.0.0 7 | 8 | servers: 9 | - url: https://coffeemesh.com 10 | description: main production server 11 | - url: https://coffeemesh-staging.com 12 | description: staging server for testing purposes only 13 | 14 | paths: 15 | /payments: 16 | post: 17 | summary: Schedules an order for production 18 | requestBody: 19 | required: true 20 | content: 21 | application/json: 22 | schema: 23 | type: object 24 | required: 25 | - order_id 26 | - status 27 | properties: 28 | order_id: 29 | type: string 30 | format: uuid 31 | 32 | responses: 33 | '201': 34 | description: A JSON representation of the scheduled order 35 | content: 36 | application/json: 37 | schema: 38 | type: object 39 | required: 40 | - payment_id 41 | properties: 42 | payment_id: 43 | type: string 44 | format: uuid 45 | status: 46 | type: string 47 | enum: 48 | - invalid 49 | - paid 50 | - pending 51 | -------------------------------------------------------------------------------- /ch13/private.pem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abunuwas/microservice-apis/9f83ff700e7918f5275238a36ae1d69503812706/ch13/private.pem -------------------------------------------------------------------------------- /ch13/pubkey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0qKsZhZ5x9UX1ZYHj2R+ 3 | UO3pGHun8oyrviLE20S3EAJk7Ph9BIsFGelBC8GdsDjhOo+FxzD39cojv6IwJTfL 4 | t0oPfLAyU5xevL0ci5gRHaqJ0edKQvRSJDVQvZvFxZb0cLaI3by0pdXcDHt9p+/P 5 | Vzo0QmekTbxIRJ77AQY0mJ15l9uUe6MJOalDHrSXiUECubhVNiNdzTjVNE4q8xTd 6 | q4XWps6Sh0cx44737GX15Wi4c4y386ZN1U6vnzn4rVUh8dpThB0M1wC5dgrbDhJN 7 | i7tn37KIWcKd5xT4xOZraVE7kUmhFujqXqa0pFrxuFhmHOwVTjp1jZrPHgLeTbf/ 8 | ywIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /ch13/public_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICpjCCAY4CCQDHM4WGoMj+8zANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDApj 3 | b2ZmZWVtZXNoMB4XDTIxMTEyODE1MzYzN1oXDTIxMTIyODE1MzYzN1owFTETMBEG 4 | A1UEAwwKY29mZmVlbWVzaDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB 5 | ANKirGYWecfVF9WWB49kflDt6Rh7p/KMq74ixNtEtxACZOz4fQSLBRnpQQvBnbA4 6 | 4TqPhccw9/XKI7+iMCU3y7dKD3ywMlOcXry9HIuYER2qidHnSkL0UiQ1UL2bxcWW 7 | 9HC2iN28tKXV3Ax7fafvz1c6NEJnpE28SESe+wEGNJideZfblHujCTmpQx60l4lB 8 | Arm4VTYjXc041TROKvMU3auF1qbOkodHMeOO9+xl9eVouHOMt/OmTdVOr585+K1V 9 | IfHaU4QdDNcAuXYK2w4STYu7Z9+yiFnCnecU+MTma2lRO5FJoRbo6l6mtKRa8bhY 10 | ZhzsFU46dY2azx4C3k23/8sCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAqAL+rxru 11 | kne3GaiwfPqmO1nKa1rkCj4mKCjzH+fbeOcYquDO1T1zd+8VWynHZUznWn15fmKF 12 | BP5foCP4xjJlNtCztDNmtDH6RVdZ3tOw2c8cJiP+jvBtCSgKgRG3srpx+xeE1S75 13 | l9vfhg40qNgvVDPrTmJv0gL//YKvXC/an/dYZWGbYkm/aCot0pDLWuGLdvWBsF9c 14 | yXIk/gxQii7uyp+j0jXfJCb3wSFHhog5fl4gIsHPp5kK8l+1xxF+XoM7YvAFso81 15 | VYbO/e2YrAlXSnLsPmeHlXJNfxwLs4c5LfFrPvyMlGKTb3LGHXDfkV+q9YZ1CD7P 16 | iOjXwWFX1Q2h7Q== 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /ch14/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | RUN mkdir -p /orders/orders 4 | 5 | WORKDIR /orders 6 | 7 | RUN pip install -U pip && pip install pipenv 8 | 9 | COPY Pipfile Pipfile.lock /orders/ 10 | 11 | RUN pipenv install --system --deploy 12 | 13 | COPY orders/orders_service /orders/orders/orders_service/ 14 | COPY orders/repository /orders/orders/repository/ 15 | COPY orders/web /orders/orders/web/ 16 | COPY oas.yaml /orders/oas.yaml 17 | COPY public_key.pem /orders/public_key.pem 18 | COPY private.pem /orders/private.pem 19 | 20 | EXPOSE 8000 21 | 22 | CMD ["uvicorn", "orders.web.app:app", "--host", "0.0.0.0"] 23 | -------------------------------------------------------------------------------- /ch14/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | alembic = "*" 8 | black = "*" 9 | awscli = "*" 10 | 11 | [packages] 12 | cryptography = "*" 13 | fastapi = "*" 14 | pyjwt = "*" 15 | requests = "*" 16 | sqlalchemy = "*" 17 | uvicorn = "*" 18 | pyyaml = "*" 19 | psycopg2-binary = "*" 20 | 21 | [requires] 22 | python_version = "3.10" 23 | 24 | [pipenv] 25 | allow_prereleases = true 26 | -------------------------------------------------------------------------------- /ch14/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | sqlalchemy.url = 39 | 40 | 41 | [post_write_hooks] 42 | # post_write_hooks defines scripts or Python functions that are run 43 | # on newly generated revision scripts. See the documentation for further 44 | # detail and examples 45 | 46 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 47 | # hooks=black 48 | # black.type=console_scripts 49 | # black.entrypoint=black 50 | # black.options=-l 79 51 | 52 | # Logging configuration 53 | [loggers] 54 | keys = root,sqlalchemy,alembic 55 | 56 | [handlers] 57 | keys = console 58 | 59 | [formatters] 60 | keys = generic 61 | 62 | [logger_root] 63 | level = WARN 64 | handlers = console 65 | qualname = 66 | 67 | [logger_sqlalchemy] 68 | level = WARN 69 | handlers = 70 | qualname = sqlalchemy.engine 71 | 72 | [logger_alembic] 73 | level = INFO 74 | handlers = 75 | qualname = alembic 76 | 77 | [handler_console] 78 | class = StreamHandler 79 | args = (sys.stderr,) 80 | level = NOTSET 81 | formatter = generic 82 | 83 | [formatter_generic] 84 | format = %(levelname)-5.5s [%(name)s] %(message)s 85 | datefmt = %H:%M:%S 86 | -------------------------------------------------------------------------------- /ch14/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | 5 | database: 6 | image: postgres:14.3 7 | ports: 8 | - 5432:5432 9 | environment: 10 | POSTGRES_PASSWORD: postgres 11 | POSTGRES_USER: postgres 12 | POSTGRES_DB: postgres 13 | volumes: 14 | - database-data:/var/lib/postgresql/data 15 | 16 | api: 17 | build: . 18 | ports: 19 | - 8000:8000 20 | depends_on: 21 | - database 22 | environment: 23 | DB_URL: postgresql://postgres:postgres@database:5432/postgres 24 | 25 | volumes: 26 | database-data: 27 | -------------------------------------------------------------------------------- /ch14/machine_to_machine_test.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def get_access_token(): 5 | payload = { 6 | "client_id": "", 7 | "client_secret": "", 8 | "audience": "http://127.0.0.1:8000/orders", 9 | "grant_type": "client_credentials" 10 | } 11 | 12 | response = requests.post( 13 | "https://coffeemesh-dev.eu.auth0.com/oauth/token", 14 | json=payload, 15 | headers={'content-type': "application/json"} 16 | ) 17 | 18 | return response.json()['access_token'] 19 | 20 | 21 | def create_order(token): 22 | order_payload = { 23 | 'order': [{ 24 | 'product': 'asdf', 25 | 'size': 'small', 26 | 'quantity': 1 27 | }] 28 | } 29 | 30 | order = requests.post( 31 | 'http://127.0.0.1:8000/orders', 32 | json=order_payload, 33 | headers={'content-type': "application/json", "Authorization": f"Bearer {token}"} 34 | ) 35 | 36 | return order.json() 37 | 38 | 39 | access_token = get_access_token() 40 | print(access_token) 41 | order = create_order(access_token) 42 | print(order) 43 | -------------------------------------------------------------------------------- /ch14/migrations.dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | RUN mkdir -p /orders/orders 4 | 5 | WORKDIR /orders 6 | 7 | RUN pip install -U pip && pip install pipenv 8 | 9 | COPY Pipfile Pipfile.lock /orders/ 10 | 11 | RUN pipenv install --dev --system --deploy 12 | 13 | COPY orders/repository /orders/orders/repository/ 14 | COPY migrations /orders/migrations 15 | COPY alembic.ini /orders/alembic.ini 16 | 17 | ENV PYTHONPATH=/orders 18 | 19 | CMD ["alembic", "upgrade", "heads"] 20 | 21 | -------------------------------------------------------------------------------- /ch14/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /ch14/migrations/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from logging.config import fileConfig 4 | 5 | from sqlalchemy import engine_from_config, create_engine 6 | from sqlalchemy import pool 7 | 8 | from alembic import context 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) 17 | 18 | # add your model's MetaData object here 19 | # for 'autogenerate' support 20 | from orders.repository.models import Base 21 | target_metadata = Base.metadata 22 | # target_metadata = None 23 | 24 | # other values from the config, defined by the needs of env.py, 25 | # can be acquired: 26 | # my_important_option = config.get_main_option("my_important_option") 27 | # ... etc. 28 | 29 | 30 | def run_migrations_offline(): 31 | """Run migrations in 'offline' mode. 32 | 33 | This configures the context with just a URL 34 | and not an Engine, though an Engine is acceptable 35 | here as well. By skipping the Engine creation 36 | we don't even need a DBAPI to be available. 37 | 38 | Calls to context.execute() here emit the given string to the 39 | script output. 40 | 41 | """ 42 | url = os.getenv('DB_URL') 43 | 44 | assert url is not None, 'DB_URL environment variable needed.' 45 | 46 | context.configure( 47 | url=url, 48 | target_metadata=target_metadata, 49 | literal_binds=True, 50 | dialect_opts={"paramstyle": "named"}, 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online(): 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | url = os.getenv('DB_URL') 65 | 66 | assert url is not None, 'DB_URL environment variable needed.' 67 | 68 | connectable = create_engine(url) 69 | 70 | with connectable.connect() as connection: 71 | context.configure( 72 | connection=connection, 73 | target_metadata=target_metadata, 74 | render_as_batch=True, 75 | ) 76 | 77 | with context.begin_transaction(): 78 | context.run_migrations() 79 | 80 | 81 | if context.is_offline_mode(): 82 | run_migrations_offline() 83 | else: 84 | run_migrations_online() 85 | -------------------------------------------------------------------------------- /ch14/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /ch14/migrations/versions/bd1046019404_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: bd1046019404 4 | Revises: 5 | Create Date: 2020-12-06 21:44:18.071169 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'bd1046019404' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('order', 22 | sa.Column('id', sa.String(), nullable=False), 23 | sa.Column('status', sa.String(), nullable=False), 24 | sa.Column('created', sa.DateTime(), nullable=True), 25 | sa.Column('schedule_id', sa.String(), nullable=True), 26 | sa.Column('delivery_id', sa.String(), nullable=True), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | op.create_table('order_item', 30 | sa.Column('id', sa.String(), nullable=False), 31 | sa.Column('order_id', sa.String(), nullable=True), 32 | sa.Column('product', sa.String(), nullable=False), 33 | sa.Column('size', sa.String(), nullable=False), 34 | sa.Column('quantity', sa.Integer(), nullable=False), 35 | sa.ForeignKeyConstraint(['order_id'], ['order.id'], ), 36 | sa.PrimaryKeyConstraint('id') 37 | ) 38 | # ### end Alembic commands ### 39 | 40 | 41 | def downgrade(): 42 | # ### commands auto generated by Alembic - please adjust! ### 43 | op.drop_table('order_item') 44 | op.drop_table('order') 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /ch14/migrations/versions/cf6a8fb1fd44_add_user_id_to_order_table.py: -------------------------------------------------------------------------------- 1 | """Add user id to order table 2 | 3 | Revision ID: cf6a8fb1fd44 4 | Revises: bd1046019404 5 | Create Date: 2021-11-07 12:28:17.852145 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'cf6a8fb1fd44' 14 | down_revision = 'bd1046019404' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('order', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('user_id', sa.String(), nullable=False)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('order', schema=None) as batch_op: 30 | batch_op.drop_column('user_id') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /ch14/orders-migrations-job.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | name: orders-service-migrations 5 | namespace: orders-service 6 | labels: 7 | app: orders-service 8 | spec: 9 | ttlSecondsAfterFinished: 30 10 | template: 11 | spec: 12 | containers: 13 | - name: orders-service-migrations 14 | image: .dkr.ecr.eu-west-1.amazonaws.com/coffeemesh-orders-migrations:1.3 15 | imagePullPolicy: Always 16 | envFrom: 17 | - secretRef: 18 | name: db-credentials 19 | restartPolicy: Never 20 | -------------------------------------------------------------------------------- /ch14/orders-service-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: orders-service 5 | namespace: orders-service 6 | labels: 7 | app: orders-service 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: orders-service 13 | template: 14 | metadata: 15 | labels: 16 | app: orders-service 17 | spec: 18 | containers: 19 | - name: orders-service 20 | image: .dkr.ecr.eu-west-1.amazonaws.com/coffeemesh-orders:1.3 21 | ports: 22 | - containerPort: 8000 23 | imagePullPolicy: Always 24 | envFrom: 25 | - secretRef: 26 | name: db-credentials 27 | -------------------------------------------------------------------------------- /ch14/orders-service-ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | name: orders-service-ingress 5 | namespace: orders-service 6 | annotations: 7 | kubernetes.io/ingress.class: alb 8 | alb.ingress.kubernetes.io/target-type: ip 9 | alb.ingress.kubernetes.io/scheme: internet-facing 10 | spec: 11 | rules: 12 | - http: 13 | paths: 14 | - path: /orders 15 | pathType: Prefix 16 | backend: 17 | service: 18 | name: orders-service 19 | port: 20 | number: 80 21 | - path: /docs/orders 22 | pathType: Prefix 23 | backend: 24 | service: 25 | name: orders-service 26 | port: 27 | number: 80 28 | - path: /openapi/orders.json 29 | pathType: Prefix 30 | backend: 31 | service: 32 | name: orders-service 33 | port: 34 | number: 80 35 | -------------------------------------------------------------------------------- /ch14/orders-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: orders-service 5 | namespace: orders-service 6 | labels: 7 | app: orders-service 8 | spec: 9 | selector: 10 | app: orders-service 11 | type: ClusterIP 12 | ports: 13 | - name: http 14 | port: 80 15 | targetPort: 8000 16 | -------------------------------------------------------------------------------- /ch14/orders/exceptions.py: -------------------------------------------------------------------------------- 1 | class OutOfStockError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /ch14/orders/orders_service/exceptions.py: -------------------------------------------------------------------------------- 1 | class OrderNotFoundError(Exception): 2 | pass 3 | 4 | 5 | class APIIntegrationError(Exception): 6 | pass 7 | 8 | 9 | class InvalidActionError(Exception): 10 | pass 11 | -------------------------------------------------------------------------------- /ch14/orders/orders_service/orders.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from orders.orders_service.exceptions import APIIntegrationError, InvalidActionError 4 | 5 | 6 | class OrderItem: 7 | def __init__(self, id, product, quantity, size): 8 | self.id = id 9 | self.product = product 10 | self.quantity = quantity 11 | self.size = size 12 | 13 | def dict(self): 14 | return {"product": self.product, "size": self.size, "quantity": self.quantity} 15 | 16 | 17 | class Order: 18 | def __init__( 19 | self, 20 | id, 21 | created, 22 | items, 23 | status, 24 | schedule_id=None, 25 | delivery_id=None, 26 | order_=None, 27 | ): 28 | self._order = order_ 29 | self._id = id 30 | self._created = created 31 | self.items = [OrderItem(**item) for item in items] 32 | self._status = status 33 | self.schedule_id = schedule_id 34 | self.delivery_id = delivery_id 35 | 36 | @property 37 | def id(self): 38 | return self._id or self._order.id 39 | 40 | @property 41 | def created(self): 42 | return self._created or self._order.created 43 | 44 | @property 45 | def status(self): 46 | return self._status or self._order.status 47 | 48 | def cancel(self): 49 | if self.status == "progress": 50 | response = requests.post( 51 | f"http://localhost:3000/kitchen/schedules/{self.schedule_id}/cancel", 52 | json={"order": [item.dict() for item in self.items]}, 53 | ) 54 | if response.status_code == 200: 55 | return 56 | raise APIIntegrationError(f"Could not cancel order with id {self.id}") 57 | if self.status == "delivery": 58 | raise InvalidActionError(f"Cannot cancel order with id {self.id}") 59 | 60 | def pay(self): 61 | response = requests.post( 62 | "http://localhost:3001/payments", json={"order_id": self.id} 63 | ) 64 | if response.status_code == 201: 65 | return 66 | raise APIIntegrationError( 67 | f"Could not process payment for order with id {self.id}" 68 | ) 69 | 70 | def schedule(self): 71 | response = requests.post( 72 | "http://localhost:3000/kitchen/schedules", 73 | json={"order": [item.dict() for item in self.items]}, 74 | ) 75 | if response.status_code == 201: 76 | return response.json()["id"] 77 | raise APIIntegrationError(f"Could not schedule order with id {self.id}") 78 | 79 | def dict(self): 80 | return { 81 | "id": self.id, 82 | "order": [item.dict() for item in self.items], 83 | "status": self.status, 84 | "created": self.created, 85 | } 86 | -------------------------------------------------------------------------------- /ch14/orders/orders_service/orders_service.py: -------------------------------------------------------------------------------- 1 | from orders.orders_service.exceptions import OrderNotFoundError 2 | from orders.repository.orders_repository import OrdersRepository 3 | 4 | 5 | class OrdersService: 6 | def __init__(self, orders_repository: OrdersRepository): 7 | self.orders_repository = orders_repository 8 | 9 | def place_order(self, items, user_id): 10 | return self.orders_repository.add(items, user_id) 11 | 12 | def get_order(self, order_id, **filters): 13 | order = self.orders_repository.get(order_id, **filters) 14 | if order is not None: 15 | return order 16 | raise OrderNotFoundError(f"Order with id {order_id} not found") 17 | 18 | def update_order(self, order_id, user_id, **payload): 19 | order = self.orders_repository.get(order_id, user_id=user_id) 20 | if order is None: 21 | raise OrderNotFoundError(f"Order with id {order_id} not found") 22 | return self.orders_repository.update(order_id, **payload) 23 | 24 | def list_orders(self, **filters): 25 | limit = filters.pop("limit", None) 26 | return self.orders_repository.list(limit=limit, **filters) 27 | 28 | def pay_order(self, order_id, user_id): 29 | order = self.orders_repository.get(order_id, user_id=user_id) 30 | if order is None: 31 | raise OrderNotFoundError(f"Order with id {order_id} not found") 32 | order.pay() 33 | schedule_id = order.schedule() 34 | return self.orders_repository.update( 35 | order_id, status="progress", schedule_id=schedule_id 36 | ) 37 | 38 | def cancel_order(self, order_id, user_id): 39 | order = self.orders_repository.get(order_id, user_id=user_id) 40 | if order is None: 41 | raise OrderNotFoundError(f"Order with id {order_id} not found") 42 | order.cancel() 43 | return self.orders_repository.update(order_id, status="cancelled") 44 | 45 | def delete_order(self, order_id, user_id): 46 | order = self.orders_repository.get(order_id, user_id=user_id) 47 | if order is None: 48 | raise OrderNotFoundError(f"Order with id {order_id} not found") 49 | return self.orders_repository.delete(order_id) 50 | -------------------------------------------------------------------------------- /ch14/orders/repository/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from sqlalchemy import Column, Integer, String, ForeignKey, DateTime 5 | from sqlalchemy.ext.declarative import declarative_base 6 | from sqlalchemy.orm import relationship 7 | 8 | Base = declarative_base() 9 | 10 | 11 | def generate_uuid(): 12 | return str(uuid.uuid4()) 13 | 14 | 15 | class OrderModel(Base): 16 | __tablename__ = "order" 17 | 18 | id = Column(String, primary_key=True, default=generate_uuid) 19 | user_id = Column(String, nullable=False) 20 | items = relationship("OrderItemModel", backref="order") 21 | status = Column(String, nullable=False, default="created") 22 | created = Column(DateTime, default=datetime.utcnow) 23 | schedule_id = Column(String) 24 | delivery_id = Column(String) 25 | 26 | def dict(self): 27 | return { 28 | "id": self.id, 29 | "items": [item.dict() for item in self.items], 30 | "status": self.status, 31 | "created": self.created, 32 | "schedule_id": self.schedule_id, 33 | "delivery_id": self.delivery_id, 34 | } 35 | 36 | 37 | class OrderItemModel(Base): 38 | __tablename__ = "order_item" 39 | 40 | id = Column(String, primary_key=True, default=generate_uuid) 41 | order_id = Column(String, ForeignKey("order.id")) 42 | product = Column(String, nullable=False) 43 | size = Column(String, nullable=False) 44 | quantity = Column(Integer, nullable=False) 45 | 46 | def dict(self): 47 | return { 48 | "id": self.id, 49 | "product": self.product, 50 | "size": self.size, 51 | "quantity": self.quantity, 52 | } 53 | -------------------------------------------------------------------------------- /ch14/orders/repository/orders_repository.py: -------------------------------------------------------------------------------- 1 | from orders.orders_service.orders import Order 2 | from orders.repository.models import OrderModel, OrderItemModel 3 | 4 | 5 | class OrdersRepository: 6 | def __init__(self, session): 7 | self.session = session 8 | 9 | def add(self, items, user_id): 10 | record = OrderModel( 11 | items=[OrderItemModel(**item) for item in items], user_id=user_id 12 | ) 13 | self.session.add(record) 14 | return Order(**record.dict(), order_=record) 15 | 16 | def _get(self, id_, **filters): 17 | return ( 18 | self.session.query(OrderModel) 19 | .filter(OrderModel.id == str(id_)) 20 | .filter_by(**filters) 21 | .first() 22 | ) 23 | 24 | def get(self, id_, **filters): 25 | order = self._get(id_, **filters) 26 | if order is not None: 27 | return Order(**order.dict()) 28 | 29 | def list(self, limit=None, **filters): 30 | query = self.session.query(OrderModel) 31 | if "cancelled" in filters: 32 | cancelled = filters.pop("cancelled") 33 | if cancelled: 34 | query = query.filter(OrderModel.status == "cancelled") 35 | else: 36 | query = query.filter(OrderModel.status != "cancelled") 37 | records = query.filter_by(**filters).limit(limit).all() 38 | return [Order(**record.dict()) for record in records] 39 | 40 | def update(self, id_, **payload): 41 | record = self._get(id_) 42 | if "items" in payload: 43 | for item in record.items: 44 | self.session.delete(item) 45 | record.items = [OrderItemModel(**item) for item in payload.pop("items")] 46 | for key, value in payload.items(): 47 | setattr(record, key, value) 48 | return Order(**record.dict()) 49 | 50 | def delete(self, id_): 51 | self.session.delete(self._get(id_)) 52 | -------------------------------------------------------------------------------- /ch14/orders/repository/unit_of_work.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.orm import sessionmaker 5 | 6 | 7 | DB_URL = os.getenv('DB_URL') 8 | 9 | assert DB_URL is not None, 'DB_URL environment variable needed.' 10 | 11 | 12 | class UnitOfWork: 13 | def __init__(self): 14 | self.session_maker = sessionmaker(bind=create_engine(DB_URL)) 15 | 16 | def __enter__(self): 17 | self.session = self.session_maker() 18 | return self 19 | 20 | def __exit__(self, exc_type, exc_val, traceback): 21 | if exc_type is not None: 22 | self.rollback() 23 | self.session.close() 24 | self.session.close() 25 | 26 | def commit(self): 27 | self.session.commit() 28 | 29 | def rollback(self): 30 | self.session.rollback() 31 | -------------------------------------------------------------------------------- /ch14/orders/web/api/auth.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import jwt 4 | from cryptography.x509 import load_pem_x509_certificate 5 | 6 | 7 | public_key_text = (Path(__file__).parent / "../../../public_key.pem").read_text() 8 | public_key = load_pem_x509_certificate(public_key_text.encode()).public_key() 9 | 10 | 11 | def decode_and_validate_token(access_token): 12 | """ 13 | Validates an access token. If the token is valid, it returns the token payload. 14 | """ 15 | return jwt.decode( 16 | access_token, 17 | key=public_key, 18 | algorithms=["RS256"], 19 | audience=["http://127.0.0.1:8000/orders"], 20 | ) 21 | -------------------------------------------------------------------------------- /ch14/orders/web/api/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import List, Optional 4 | from uuid import UUID 5 | 6 | from pydantic import BaseModel, Extra, conint, conlist, validator 7 | 8 | 9 | class Size(Enum): 10 | small = "small" 11 | medium = "medium" 12 | big = "big" 13 | 14 | 15 | class Status(Enum): 16 | created = "created" 17 | paid = "paid" 18 | progress = "progress" 19 | cancelled = "cancelled" 20 | dispatched = "dispatched" 21 | delivered = "delivered" 22 | 23 | 24 | class OrderItemSchema(BaseModel): 25 | product: str 26 | size: Size 27 | quantity: Optional[conint(ge=1, strict=True)] = 1 28 | 29 | class Config: 30 | extra = Extra.forbid 31 | 32 | @validator("quantity") 33 | def quantity_non_nullable(cls, value): 34 | assert value is not None, "quantity may not be None" 35 | return value 36 | 37 | 38 | class CreateOrderSchema(BaseModel): 39 | order: conlist(OrderItemSchema, min_items=1) 40 | 41 | class Config: 42 | extra = Extra.forbid 43 | 44 | 45 | class GetOrderSchema(CreateOrderSchema): 46 | id: UUID 47 | created: datetime 48 | status: Status 49 | 50 | 51 | class GetOrdersSchema(BaseModel): 52 | orders: List[GetOrderSchema] 53 | 54 | class Config: 55 | extra = Extra.forbid 56 | -------------------------------------------------------------------------------- /ch14/orders/web/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import yaml 5 | from fastapi import FastAPI 6 | from jwt import ( 7 | ExpiredSignatureError, 8 | ImmatureSignatureError, 9 | InvalidAlgorithmError, 10 | InvalidAudienceError, 11 | InvalidKeyError, 12 | InvalidSignatureError, 13 | InvalidTokenError, 14 | MissingRequiredClaimError, 15 | ) 16 | from starlette import status 17 | from starlette.middleware.base import RequestResponseEndpoint, BaseHTTPMiddleware 18 | from starlette.middleware.cors import CORSMiddleware 19 | from starlette.requests import Request 20 | from starlette.responses import Response, JSONResponse 21 | 22 | from orders.web.api.auth import decode_and_validate_token 23 | 24 | app = FastAPI(debug=True, openapi_url="/openapi/orders.json", docs_url="/docs/orders") 25 | 26 | oas_doc = yaml.safe_load((Path(__file__).parent / "../../oas.yaml").read_text()) 27 | 28 | app.openapi = lambda: oas_doc 29 | 30 | 31 | class AuthorizeRequestMiddleware(BaseHTTPMiddleware): 32 | async def dispatch( 33 | self, request: Request, call_next: RequestResponseEndpoint 34 | ) -> Response: 35 | if os.getenv("AUTH_ON", "False") != "True": 36 | request.state.user_id = "test" 37 | return await call_next(request) 38 | 39 | if request.url.path in ["/docs/orders", "/openapi/orders.json"]: 40 | return await call_next(request) 41 | if request.method == "OPTIONS": 42 | return await call_next(request) 43 | 44 | bearer_token = request.headers.get("Authorization") 45 | if not bearer_token: 46 | return JSONResponse( 47 | status_code=status.HTTP_401_UNAUTHORIZED, 48 | content={ 49 | "detail": "Missing access token", 50 | "body": "Missing access token", 51 | }, 52 | ) 53 | try: 54 | auth_token = bearer_token.split(" ")[1].strip() 55 | token_payload = decode_and_validate_token(auth_token) 56 | except ( 57 | ExpiredSignatureError, 58 | ImmatureSignatureError, 59 | InvalidAlgorithmError, 60 | InvalidAudienceError, 61 | InvalidKeyError, 62 | InvalidSignatureError, 63 | InvalidTokenError, 64 | MissingRequiredClaimError, 65 | ) as error: 66 | return JSONResponse( 67 | status_code=status.HTTP_401_UNAUTHORIZED, 68 | content={"detail": str(error), "body": str(error)}, 69 | ) 70 | else: 71 | request.state.user_id = token_payload["sub"] 72 | return await call_next(request) 73 | 74 | 75 | app.add_middleware(AuthorizeRequestMiddleware) 76 | 77 | 78 | app.add_middleware( 79 | CORSMiddleware, 80 | allow_origins=["*"], 81 | allow_credentials=True, 82 | allow_methods=["*"], 83 | allow_headers=["*"], 84 | ) 85 | 86 | 87 | from orders.web.api import api 88 | -------------------------------------------------------------------------------- /ch14/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@stoplight/prism-cli": "^4.1.1" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ch14/payments.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | 3 | info: 4 | title: Payments API 5 | description: API to process payments for orders 6 | version: 1.0.0 7 | 8 | servers: 9 | - url: https://coffeemesh.com 10 | description: main production server 11 | - url: https://coffeemesh-staging.com 12 | description: staging server for testing purposes only 13 | 14 | paths: 15 | /payments: 16 | post: 17 | summary: Schedules an order for production 18 | requestBody: 19 | required: true 20 | content: 21 | application/json: 22 | schema: 23 | type: object 24 | required: 25 | - order_id 26 | - status 27 | properties: 28 | order_id: 29 | type: string 30 | format: uuid 31 | 32 | responses: 33 | '201': 34 | description: A JSON representation of the scheduled order 35 | content: 36 | application/json: 37 | schema: 38 | type: object 39 | required: 40 | - payment_id 41 | properties: 42 | payment_id: 43 | type: string 44 | format: uuid 45 | status: 46 | type: string 47 | enum: 48 | - invalid 49 | - paid 50 | - pending 51 | -------------------------------------------------------------------------------- /ch14/private.pem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abunuwas/microservice-apis/9f83ff700e7918f5275238a36ae1d69503812706/ch14/private.pem -------------------------------------------------------------------------------- /ch14/pubkey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0qKsZhZ5x9UX1ZYHj2R+ 3 | UO3pGHun8oyrviLE20S3EAJk7Ph9BIsFGelBC8GdsDjhOo+FxzD39cojv6IwJTfL 4 | t0oPfLAyU5xevL0ci5gRHaqJ0edKQvRSJDVQvZvFxZb0cLaI3by0pdXcDHt9p+/P 5 | Vzo0QmekTbxIRJ77AQY0mJ15l9uUe6MJOalDHrSXiUECubhVNiNdzTjVNE4q8xTd 6 | q4XWps6Sh0cx44737GX15Wi4c4y386ZN1U6vnzn4rVUh8dpThB0M1wC5dgrbDhJN 7 | i7tn37KIWcKd5xT4xOZraVE7kUmhFujqXqa0pFrxuFhmHOwVTjp1jZrPHgLeTbf/ 8 | ywIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /ch14/public_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICpjCCAY4CCQDHM4WGoMj+8zANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDApj 3 | b2ZmZWVtZXNoMB4XDTIxMTEyODE1MzYzN1oXDTIxMTIyODE1MzYzN1owFTETMBEG 4 | A1UEAwwKY29mZmVlbWVzaDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB 5 | ANKirGYWecfVF9WWB49kflDt6Rh7p/KMq74ixNtEtxACZOz4fQSLBRnpQQvBnbA4 6 | 4TqPhccw9/XKI7+iMCU3y7dKD3ywMlOcXry9HIuYER2qidHnSkL0UiQ1UL2bxcWW 7 | 9HC2iN28tKXV3Ax7fafvz1c6NEJnpE28SESe+wEGNJideZfblHujCTmpQx60l4lB 8 | Arm4VTYjXc041TROKvMU3auF1qbOkodHMeOO9+xl9eVouHOMt/OmTdVOr585+K1V 9 | IfHaU4QdDNcAuXYK2w4STYu7Z9+yiFnCnecU+MTma2lRO5FJoRbo6l6mtKRa8bhY 10 | ZhzsFU46dY2azx4C3k23/8sCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAqAL+rxru 11 | kne3GaiwfPqmO1nKa1rkCj4mKCjzH+fbeOcYquDO1T1zd+8VWynHZUznWn15fmKF 12 | BP5foCP4xjJlNtCztDNmtDH6RVdZ3tOw2c8cJiP+jvBtCSgKgRG3srpx+xeE1S75 13 | l9vfhg40qNgvVDPrTmJv0gL//YKvXC/an/dYZWGbYkm/aCot0pDLWuGLdvWBsF9c 14 | yXIk/gxQii7uyp+j0jXfJCb3wSFHhog5fl4gIsHPp5kK8l+1xxF+XoM7YvAFso81 15 | VYbO/e2YrAlXSnLsPmeHlXJNfxwLs4c5LfFrPvyMlGKTb3LGHXDfkV+q9YZ1CD7P 16 | iOjXwWFX1Q2h7Q== 17 | -----END CERTIFICATE----- 18 | --------------------------------------------------------------------------------