├── 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 |
--------------------------------------------------------------------------------