├── .devcontainer ├── devcontainer.json └── docker-compose.yml ├── .env ├── .flake8 ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── .vscode ├── launch.json └── settings.json ├── Dockerfile ├── Makefile ├── README.md ├── alembic.ini ├── docker-compose.yml ├── mypy.ini ├── requirements ├── requirements.txt ├── test-requirements.in └── test-requirements.txt ├── setup.py ├── src └── orders_api │ ├── __init__.py │ ├── app.py │ ├── config.py │ ├── db │ ├── __init__.py │ ├── models.py │ ├── schemas │ │ ├── __init__.py │ │ ├── order.py │ │ ├── product.py │ │ ├── store.py │ │ └── utils.py │ └── session.py │ ├── main.py │ ├── migrations │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── 0addb6ab66cc_products_add_description_field.py │ │ ├── 2120875f27c4_create_initial_schema.py │ │ └── b2a7734ce390_stores_add_email_field.py │ ├── mock.py │ ├── routers │ ├── __init__.py │ ├── orders.py │ ├── products.py │ └── stores.py │ └── services │ ├── __init__.py │ ├── base.py │ ├── orders.py │ ├── products.py │ └── stores.py └── tests ├── conftest.py ├── routers ├── test_orders.py ├── test_products.py └── test_stores.py └── test_app.py /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Existing Docker Compose (Extend)", 3 | 4 | "dockerComposeFile": [ 5 | "../docker-compose.yml", 6 | "docker-compose.yml", 7 | ], 8 | 9 | "service": "vscode", 10 | 11 | "workspaceFolder": "/workspace", 12 | 13 | "settings": { 14 | "terminal.integrated.shell.linux": null 15 | }, 16 | "extensions": [ 17 | "ms-python.python", 18 | "ms-python.vscode-pylance" 19 | ], 20 | "remoteUser": "user" 21 | } -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | vscode: 4 | user: user 5 | image: orders_api:latest 6 | volumes: 7 | - .:/workspace:cached 8 | depends_on: 9 | - db 10 | environment: 11 | DATABASE_URL: "postgresql://postgres:mypassword@db/order_api_testdb" 12 | # Overrides default command so things don't shut down after the process ends. 13 | command: /bin/sh -c "while sleep 1000; do :; done" 14 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | COMPOSE_PROJECT_NAME=ordersapi 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503 3 | per-file-ignores = 4 | __init__.py: F401,F403 5 | max-line-length = 88 6 | max-complexity = 18 7 | select = B,C,E,F,W,T4,B9 8 | # We need to configure the mypy.ini because the flake8-mypy's default 9 | # options don't properly override it, so if we don't specify it we get 10 | # half of the config from mypy.ini and half from flake8-mypy. 11 | mypy_config = mypy.ini 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | __pycache__ 4 | .pytest_cache 5 | .eggs 6 | /htmlcov 7 | .coverage 8 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output = 3 3 | include_trailing_comma = True 4 | force_grid_wrap = 0 5 | use_parentheses = True 6 | ensure_newline_before_comments = True 7 | line_length = 88 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - id: flake8 8 | - repo: https://github.com/psf/black 9 | rev: 20.8b1 10 | hooks: 11 | - id: black 12 | - repo: https://github.com/pre-commit/mirrors-mypy 13 | rev: 'v0.790' 14 | hooks: 15 | - id: mypy 16 | - repo: https://github.com/pre-commit/mirrors-isort 17 | rev: 'v5.6.4' 18 | hooks: 19 | - id: isort 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Python: Remote Attach", 5 | "type": "python", 6 | "request": "attach", 7 | "connect": { 8 | "host": "api", 9 | "port": 5678 10 | }, 11 | "pathMappings": [ 12 | { 13 | "localRoot": "${workspaceFolder}", 14 | "remoteRoot": "." 15 | } 16 | ] 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "/opt/orders_api/bin/python", 3 | "python.formatting.provider": "black", 4 | "python.formatting.blackPath": "/opt/orders_api/bin/black", 5 | "python.testing.pytestArgs": [ 6 | "tests" 7 | ], 8 | "python.testing.unittestEnabled": false, 9 | "python.testing.nosetestsEnabled": false, 10 | "python.testing.pytestEnabled": true, 11 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | ARG USER_ID 4 | ARG GROUP_ID 5 | 6 | COPY requirements/test-requirements.txt /tmp/test-requirements.txt 7 | 8 | RUN set -x \ 9 | && python -m venv /opt/orders_api \ 10 | && /opt/orders_api/bin/python -m pip install -U pip wheel setuptools \ 11 | && /opt/orders_api/bin/python -m pip install --no-cache-dir -q -r /tmp/test-requirements.txt \ 12 | && mkdir -p /workspace && chown -R $USER_ID:$GROUP_ID /workspace && chown -R $USER_ID:$GROUP_ID /opt/orders_api 13 | RUN addgroup --gid $GROUP_ID user 14 | RUN adduser --disabled-password --gecos '' --uid $USER_ID --gid $GROUP_ID user 15 | USER user 16 | 17 | WORKDIR /workspace 18 | 19 | ENV PATH="/opt/orders_api/bin:${PATH}" 20 | 21 | ENV PYTHONUNBUFFERED 1 22 | ENV PYTHONPATH /workspace/src 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # also include the Docker Compose file of the VSCode config to avoid warnings about orphaned services 2 | dc = docker-compose -f docker-compose.yml -f .devcontainer/docker-compose.yml 3 | user_id:=$(shell id -u) 4 | group_id:=$(shell id -g) 5 | 6 | build: 7 | docker build . -t orders_api --build-arg USER_ID=$(user_id) --build-arg GROUP_ID=$(group_id) 8 | 9 | setup-db: 10 | $(dc) run --rm api alembic upgrade head 11 | 12 | run: 13 | $(dc) up -d api 14 | 15 | delete-db: 16 | $(dc) stop db 17 | $(dc) rm -v db 18 | $(dc) up -d db 19 | sleep 2 20 | 21 | recreate-db: delete-db setup-db 22 | 23 | insert-mockdata: 24 | $(dc) run --rm api python -m orders_api.mock 25 | 26 | compile-requirements: 27 | $(dc) run --rm api bash -c "\ 28 | python -m pip install -U pip-tools && \ 29 | pip-compile -U --resolver=backtracking -o requirements/requirements.txt && \ 30 | pip-compile -U --resolver=backtracking requirements/test-requirements.in -o requirements/test-requirements.txt" 31 | 32 | alembic-revision: 33 | $(dc) run --rm api alembic revision --autogenerate -m $(msg) 34 | 35 | logs: 36 | $(dc) logs -f 37 | 38 | check-black: 39 | $(dc) run --rm api black --check src tests 40 | 41 | black: 42 | $(dc) run --rm api black src tests 43 | 44 | check-isort: 45 | $(dc) run --rm api isort src tests --check 46 | 47 | isort: 48 | $(dc) run --rm api isort src tests 49 | 50 | flake8: 51 | $(dc) run --rm api flake8 src tests 52 | 53 | test: 54 | $(dc) run --rm -e POSTGRES_DB="order_api_testdb" api python -m pytest tests -sv 55 | 56 | test-cov: 57 | $(dc) run --rm -e POSTGRES_DB="order_api_testdb" api python -m pytest tests -sv --cov orders_api --cov-report html 58 | 59 | test-cov-term: 60 | $(dc) run --rm -e POSTGRES_DB="order_api_testdb" api python -m pytest tests -sv --cov orders_api --cov-report term-missing 61 | 62 | mypy: 63 | $(dc) run --rm api mypy src tests 64 | 65 | # run all checks, formatting, typing and tests 66 | ci: check-black check-isort mypy flake8 test 67 | 68 | bootstrap: build setup-db 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Orders API 2 | 3 | An example python project serving as a reference. (see also this [blog series](https://www.patrick-muehlbauer.com/articles/python-docker-compose-vscode)) 4 | 5 | ## Development Environment 6 | 7 | This projects comes with a `docker-compose` setup and a `Makefile` (yes, a `Makefile` :D) for executing the most frequent commands: 8 | 9 | # building the image 10 | make build 11 | 12 | # initialize the database 13 | make setup-db 14 | 15 | # starting the api service 16 | make run 17 | 18 | # to access the logs 19 | make logs 20 | 21 | # executing the tests 22 | make test 23 | 24 | It can also be used with `vscode`. 25 | 26 | ## pre-commit 27 | 28 | To avoid committing and pushing changes that will fail CI because of simple formatting issues, you can install the `pre-commit` hooks. 29 | 30 | # install pre-commit 31 | pip install --user pre-commit 32 | 33 | # install the hooks 34 | pre-commit install 35 | 36 | Now whenever you commit changes, checks like `black` and `mypy` will be run against the edited files. 37 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = src/orders_api/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 src/orders_api/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 src/orders_api/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 = driver://user:pass@localhost/dbname 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 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | api: 5 | image: orders_api:latest 6 | ports: 7 | - "8000:8000" 8 | command: uvicorn --reload --host 0.0.0.0 --port 8000 orders_api.main:app 9 | volumes: 10 | - .:/workspace:z 11 | depends_on: 12 | - db 13 | environment: 14 | DATABASE_URL: "postgresql://postgres:mypassword@db/orders_api_db" 15 | 16 | db: 17 | image: postgres:13 18 | ports: 19 | - "2345:5432" 20 | environment: 21 | POSTGRES_USER: "postgres" 22 | POSTGRES_PASSWORD: "mypassword" 23 | POSTGRES_DB: "orders_api_db" 24 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # Specify the target platform details in config, so your developers are 3 | # free to run mypy on Windows, Linux, or macOS and get consistent 4 | # results. 5 | python_version=3.11 6 | platform=linux 7 | 8 | # flake8-mypy expects the two following for sensible formatting 9 | show_column_numbers=True 10 | 11 | # show error messages from unrelated files 12 | follow_imports=normal 13 | 14 | # suppress errors about unsatisfied imports 15 | ignore_missing_imports=True 16 | 17 | # be strict 18 | disallow_untyped_calls=True 19 | warn_return_any=True 20 | strict_optional=True 21 | warn_no_return=True 22 | warn_redundant_casts=True 23 | warn_unused_ignores=True 24 | disallow_any_generics=True 25 | 26 | # The following are off by default. Flip them on if you feel 27 | # adventurous. 28 | disallow_untyped_defs=True 29 | check_untyped_defs=True 30 | 31 | exclude = (?x)^src/orders_api/migrations/*$ 32 | 33 | 34 | [mypy-aiohttp.*] 35 | follow_imports=skip 36 | [mypy-_version] 37 | follow_imports=skip 38 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements/requirements.txt --resolver=backtracking 6 | # 7 | alembic==1.10.3 8 | # via orders-api (setup.py) 9 | anyio==3.6.2 10 | # via starlette 11 | click==8.1.3 12 | # via uvicorn 13 | dnspython==2.3.0 14 | # via email-validator 15 | email-validator==1.3.1 16 | # via pydantic 17 | fastapi==0.95.1 18 | # via orders-api (setup.py) 19 | greenlet==2.0.2 20 | # via sqlalchemy 21 | h11==0.14.0 22 | # via uvicorn 23 | idna==2.10 24 | # via 25 | # anyio 26 | # email-validator 27 | # orders-api (setup.py) 28 | mako==1.2.4 29 | # via alembic 30 | markupsafe==2.1.2 31 | # via mako 32 | psycopg2==2.9.6 33 | # via orders-api (setup.py) 34 | pydantic[email]==1.10.7 35 | # via 36 | # fastapi 37 | # orders-api (setup.py) 38 | sniffio==1.3.0 39 | # via anyio 40 | sqlalchemy==2.0.9 41 | # via 42 | # alembic 43 | # orders-api (setup.py) 44 | starlette==0.26.1 45 | # via fastapi 46 | typing-extensions==4.5.0 47 | # via 48 | # alembic 49 | # pydantic 50 | # sqlalchemy 51 | uvicorn==0.21.1 52 | # via orders-api (setup.py) 53 | -------------------------------------------------------------------------------- /requirements/test-requirements.in: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | # black, isort and mypy versions are synced with those in .pre-commit-config.yaml 4 | black 5 | isort 6 | mypy 7 | 8 | httpx 9 | debugpy 10 | flake8 11 | pytest 12 | pytest-cov 13 | sqlalchemy-utils 14 | sqlalchemy2-stubs 15 | -------------------------------------------------------------------------------- /requirements/test-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements/test-requirements.txt --resolver=backtracking requirements/test-requirements.in 6 | # 7 | alembic==1.10.3 8 | # via -r requirements/requirements.txt 9 | anyio==3.6.2 10 | # via 11 | # -r requirements/requirements.txt 12 | # httpcore 13 | # starlette 14 | black==23.3.0 15 | # via -r requirements/test-requirements.in 16 | certifi==2022.12.7 17 | # via 18 | # httpcore 19 | # httpx 20 | click==8.1.3 21 | # via 22 | # -r requirements/requirements.txt 23 | # black 24 | # uvicorn 25 | coverage[toml]==7.2.3 26 | # via pytest-cov 27 | debugpy==1.6.7 28 | # via -r requirements/test-requirements.in 29 | dnspython==2.3.0 30 | # via 31 | # -r requirements/requirements.txt 32 | # email-validator 33 | email-validator==1.3.1 34 | # via 35 | # -r requirements/requirements.txt 36 | # pydantic 37 | fastapi==0.95.1 38 | # via -r requirements/requirements.txt 39 | flake8==6.0.0 40 | # via -r requirements/test-requirements.in 41 | greenlet==2.0.2 42 | # via 43 | # -r requirements/requirements.txt 44 | # sqlalchemy 45 | h11==0.14.0 46 | # via 47 | # -r requirements/requirements.txt 48 | # httpcore 49 | # uvicorn 50 | httpcore==0.17.0 51 | # via httpx 52 | httpx==0.24.0 53 | # via -r requirements/test-requirements.in 54 | idna==2.10 55 | # via 56 | # -r requirements/requirements.txt 57 | # anyio 58 | # email-validator 59 | # httpx 60 | iniconfig==2.0.0 61 | # via pytest 62 | isort==5.12.0 63 | # via -r requirements/test-requirements.in 64 | mako==1.2.4 65 | # via 66 | # -r requirements/requirements.txt 67 | # alembic 68 | markupsafe==2.1.2 69 | # via 70 | # -r requirements/requirements.txt 71 | # mako 72 | mccabe==0.7.0 73 | # via flake8 74 | mypy==1.2.0 75 | # via -r requirements/test-requirements.in 76 | mypy-extensions==1.0.0 77 | # via 78 | # black 79 | # mypy 80 | packaging==23.1 81 | # via 82 | # black 83 | # pytest 84 | pathspec==0.11.1 85 | # via black 86 | platformdirs==3.2.0 87 | # via black 88 | pluggy==1.0.0 89 | # via pytest 90 | psycopg2==2.9.6 91 | # via -r requirements/requirements.txt 92 | pycodestyle==2.10.0 93 | # via flake8 94 | pydantic[email]==1.10.7 95 | # via 96 | # -r requirements/requirements.txt 97 | # fastapi 98 | pyflakes==3.0.1 99 | # via flake8 100 | pytest==7.3.1 101 | # via 102 | # -r requirements/test-requirements.in 103 | # pytest-cov 104 | pytest-cov==4.0.0 105 | # via -r requirements/test-requirements.in 106 | sniffio==1.3.0 107 | # via 108 | # -r requirements/requirements.txt 109 | # anyio 110 | # httpcore 111 | # httpx 112 | sqlalchemy==2.0.9 113 | # via 114 | # -r requirements/requirements.txt 115 | # alembic 116 | # sqlalchemy-utils 117 | sqlalchemy-utils==0.41.0 118 | # via -r requirements/test-requirements.in 119 | sqlalchemy2-stubs==0.0.2a33 120 | # via -r requirements/test-requirements.in 121 | starlette==0.26.1 122 | # via 123 | # -r requirements/requirements.txt 124 | # fastapi 125 | typing-extensions==4.5.0 126 | # via 127 | # -r requirements/requirements.txt 128 | # alembic 129 | # mypy 130 | # pydantic 131 | # sqlalchemy 132 | # sqlalchemy2-stubs 133 | uvicorn==0.21.1 134 | # via -r requirements/requirements.txt 135 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | install_requires = [ 4 | "alembic", 5 | "fastapi", 6 | "idna<3", # resolve dependency version conflict 7 | "psycopg2", 8 | "pydantic[email]", 9 | "sqlalchemy", 10 | "uvicorn", 11 | ] 12 | 13 | 14 | setup( 15 | name="orders_api", 16 | install_requires=install_requires, 17 | use_scm_version=True, 18 | setup_requires=["setuptools_scm"], 19 | packages=find_packages(where="src"), 20 | package_dir={"": "src"}, 21 | include_package_data=True, 22 | ) 23 | -------------------------------------------------------------------------------- /src/orders_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treebee/orders-api/c7b67b21646b7a6e01a1b5682ce4731663a52d71/src/orders_api/__init__.py -------------------------------------------------------------------------------- /src/orders_api/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | 4 | def create_app() -> FastAPI: 5 | app = FastAPI( 6 | title="orders", 7 | description="Simple API to order products from different stores.", 8 | version="1.0", 9 | ) 10 | 11 | @app.get("/health") 12 | async def health() -> str: 13 | return "ok" 14 | 15 | from orders_api.routers import orders, products, stores 16 | 17 | app.include_router(orders.router) 18 | app.include_router(products.router) 19 | app.include_router(stores.router) 20 | return app 21 | -------------------------------------------------------------------------------- /src/orders_api/config.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from os import getenv 3 | 4 | from pydantic import BaseSettings, PostgresDsn 5 | 6 | 7 | class Settings(BaseSettings): 8 | database_url: PostgresDsn 9 | 10 | 11 | @lru_cache 12 | def get_settings() -> Settings: 13 | settings = Settings() # type: ignore 14 | if (db := getenv("POSTGRES_DB")) is not None: 15 | settings.database_url = PostgresDsn.build( 16 | scheme="postgresql", 17 | user=settings.database_url.user, 18 | password=settings.database_url.password, 19 | host=settings.database_url.host, 20 | port=settings.database_url.port, 21 | path=f"/{db}", 22 | ) 23 | return settings 24 | -------------------------------------------------------------------------------- /src/orders_api/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treebee/orders-api/c7b67b21646b7a6e01a1b5682ce4731663a52d71/src/orders_api/db/__init__.py -------------------------------------------------------------------------------- /src/orders_api/db/models.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | import uuid 3 | from typing import List, Optional 4 | 5 | import sqlalchemy as sa 6 | from sqlalchemy.dialects.postgresql import UUID 7 | from sqlalchemy.ext.hybrid import hybrid_property 8 | from sqlalchemy.orm import RelationshipProperty, registry, relationship 9 | from sqlalchemy.orm.decl_api import DeclarativeMeta 10 | from sqlalchemy.sql.schema import ForeignKey, UniqueConstraint 11 | from sqlalchemy_utils import EmailType 12 | 13 | mapper_registry = registry() 14 | 15 | 16 | class Base(metaclass=DeclarativeMeta): 17 | __abstract__ = True 18 | 19 | registry = mapper_registry 20 | metadata = mapper_registry.metadata 21 | 22 | __init__ = mapper_registry.constructor 23 | 24 | 25 | class Store(Base): 26 | __tablename__ = "stores" 27 | 28 | store_id = sa.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 29 | city = sa.Column(sa.Text, nullable=False) 30 | country = sa.Column(sa.Text, nullable=False) 31 | currency = sa.Column(sa.String(3), nullable=False) 32 | domain = sa.Column(sa.Text) 33 | name = sa.Column(sa.Text, nullable=False) 34 | phone = sa.Column(sa.Text) 35 | street = sa.Column(sa.Text, nullable=False) 36 | zipcode = sa.Column(sa.Text, nullable=False) 37 | email: Optional[EmailType] = sa.Column(EmailType) 38 | 39 | 40 | class Product(Base): 41 | __tablename__ = "products" 42 | __allow_unmapped__ = True 43 | 44 | product_id = sa.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 45 | store_id = sa.Column(ForeignKey("stores.store_id"), nullable=False) 46 | store: RelationshipProperty[Store] = relationship("Store", backref="products") 47 | name = sa.Column(sa.Text, nullable=False) 48 | price = sa.Column(sa.Numeric(12, 2), nullable=False) 49 | description = sa.Column(sa.Text) 50 | 51 | __table_args__ = (UniqueConstraint("name", "store_id", name="uix_products"),) 52 | 53 | 54 | class Order(Base): 55 | __tablename__ = "orders" 56 | __allow_unmapped__ = True 57 | 58 | order_id = sa.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 59 | date = sa.Column(sa.DateTime, server_default=sa.func.now(), nullable=False) 60 | items: List["OrderItem"] = relationship("OrderItem", backref="order") # type: ignore 61 | 62 | @hybrid_property 63 | def total(self) -> decimal.Decimal: 64 | total_price = decimal.Decimal("0.0") 65 | for item in self.items: 66 | total_price += decimal.Decimal(item.product.price * item.quantity) 67 | return total_price 68 | 69 | 70 | class OrderItem(Base): 71 | __tablename__ = "order_items" 72 | __allow_unmapped__ = True 73 | 74 | order_id = sa.Column(ForeignKey("orders.order_id"), primary_key=True) 75 | product_id = sa.Column(ForeignKey("products.product_id"), primary_key=True) 76 | product: RelationshipProperty[Product] = relationship("Product", uselist=False) 77 | quantity = sa.Column(sa.Integer, nullable=False) 78 | -------------------------------------------------------------------------------- /src/orders_api/db/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from .order import * 2 | from .product import * 3 | from .store import * 4 | -------------------------------------------------------------------------------- /src/orders_api/db/schemas/order.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List 3 | 4 | from pydantic import BaseModel, Field 5 | from pydantic.types import UUID4 6 | 7 | from .product import Product 8 | from .utils import to_camel 9 | 10 | 11 | class OrderItemCreate(BaseModel): 12 | product_id: UUID4 13 | quantity: int = Field(..., gt=0) 14 | 15 | class Config: 16 | orm_mode = True 17 | alias_generator = to_camel 18 | allow_population_by_field_name = True 19 | 20 | 21 | class OrderCreate(BaseModel): 22 | items: List[OrderItemCreate] 23 | 24 | 25 | class Order(BaseModel): 26 | order_id: UUID4 27 | date: datetime 28 | total: float 29 | 30 | class Config: 31 | orm_mode = True 32 | alias_generator = to_camel 33 | allow_population_by_field_name = True 34 | 35 | 36 | # for order details 37 | class OrderItem(BaseModel): 38 | quantity: int = Field(..., gt=0) 39 | product: Product 40 | 41 | class Config: 42 | orm_mode = True 43 | alias_generator = to_camel 44 | allow_population_by_field_name = True 45 | 46 | 47 | class OrderDetail(Order): 48 | order_id: UUID4 49 | date: datetime 50 | items: List[OrderItem] 51 | -------------------------------------------------------------------------------- /src/orders_api/db/schemas/product.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | from pydantic.types import UUID4, condecimal 5 | 6 | from .store import Store 7 | from .utils import to_camel 8 | 9 | 10 | class ProductBase(BaseModel): 11 | description: Optional[str] 12 | 13 | class Config: 14 | alias_generator = to_camel 15 | allow_population_by_field_name = True 16 | 17 | 18 | class ProductUpdate(ProductBase): 19 | name: Optional[str] 20 | price: Optional[condecimal(decimal_places=2)] # type: ignore 21 | 22 | 23 | class ProductCreate(ProductBase): 24 | store_id: UUID4 25 | name: str 26 | price: condecimal(decimal_places=2) # type: ignore 27 | 28 | 29 | class Product(ProductCreate): 30 | product_id: UUID4 31 | store: Store 32 | 33 | class Config: 34 | orm_mode = True 35 | -------------------------------------------------------------------------------- /src/orders_api/db/schemas/store.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, EmailStr, HttpUrl 4 | from pydantic.types import UUID4, constr 5 | 6 | from .utils import to_camel 7 | 8 | 9 | class StoreBase(BaseModel): 10 | name: Optional[str] 11 | city: Optional[str] 12 | country: Optional[str] 13 | currency: Optional[constr(min_length=3, max_length=3)] # type: ignore 14 | domain: Optional[HttpUrl] 15 | phone: Optional[str] 16 | street: Optional[str] 17 | zipcode: Optional[str] 18 | email: Optional[EmailStr] 19 | 20 | 21 | class StoreUpdate(StoreBase): 22 | pass 23 | 24 | 25 | class StoreCreate(StoreBase): 26 | name: str 27 | city: str 28 | country: str 29 | currency: constr(min_length=3, max_length=3) # type: ignore 30 | zipcode: str 31 | street: str 32 | 33 | 34 | class Store(StoreCreate): 35 | store_id: UUID4 36 | 37 | class Config: 38 | orm_mode = True 39 | alias_generator = to_camel 40 | allow_population_by_field_name = True 41 | -------------------------------------------------------------------------------- /src/orders_api/db/schemas/utils.py: -------------------------------------------------------------------------------- 1 | def to_camel(string: str) -> str: 2 | if "_" not in string: 3 | return string 4 | words = string.split("_") 5 | words = [words[0]] + [word.capitalize() for word in words[1:]] 6 | return "".join(words) 7 | -------------------------------------------------------------------------------- /src/orders_api/db/session.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import Generator 3 | 4 | from sqlalchemy import create_engine 5 | from sqlalchemy.orm import scoped_session, sessionmaker 6 | 7 | from orders_api.config import get_settings 8 | 9 | engine = create_engine(get_settings().database_url, pool_pre_ping=True) 10 | 11 | 12 | @lru_cache 13 | def create_session() -> scoped_session: 14 | Session = scoped_session( 15 | sessionmaker(autocommit=False, autoflush=False, bind=engine) 16 | ) 17 | return Session 18 | 19 | 20 | # Dependency 21 | def get_session() -> Generator[scoped_session, None, None]: 22 | Session = create_session() 23 | try: 24 | yield Session 25 | finally: 26 | Session.remove() 27 | -------------------------------------------------------------------------------- /src/orders_api/main.py: -------------------------------------------------------------------------------- 1 | from orders_api.app import create_app 2 | 3 | app = create_app() 4 | -------------------------------------------------------------------------------- /src/orders_api/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /src/orders_api/migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | from os import getenv 3 | 4 | from alembic import context 5 | 6 | from orders_api.db.models import Base 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 myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | 21 | target_metadata = Base.metadata 22 | 23 | # other values from the config, defined by the needs of env.py, 24 | # can be acquired: 25 | # my_important_option = config.get_main_option("my_important_option") 26 | # ... etc. 27 | 28 | 29 | def run_migrations_offline(): 30 | """Run migrations in 'offline' mode. 31 | 32 | This configures the context with just a URL 33 | and not an Engine, though an Engine is acceptable 34 | here as well. By skipping the Engine creation 35 | we don't even need a DBAPI to be available. 36 | 37 | Calls to context.execute() here emit the given string to the 38 | script output. 39 | 40 | """ 41 | context.configure( 42 | url=getenv("DATABASE_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 | from orders_api.db.session import engine 60 | 61 | connectable = engine 62 | with connectable.connect() as connection: 63 | context.configure(connection=connection, target_metadata=target_metadata) 64 | 65 | with context.begin_transaction(): 66 | context.run_migrations() 67 | 68 | 69 | if context.is_offline_mode(): 70 | run_migrations_offline() 71 | else: 72 | run_migrations_online() 73 | -------------------------------------------------------------------------------- /src/orders_api/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 | -------------------------------------------------------------------------------- /src/orders_api/migrations/versions/0addb6ab66cc_products_add_description_field.py: -------------------------------------------------------------------------------- 1 | """products_add-description-field 2 | 3 | Revision ID: 0addb6ab66cc 4 | Revises: b2a7734ce390 5 | Create Date: 2021-01-27 17:06:53.310843 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "0addb6ab66cc" 13 | down_revision = "b2a7734ce390" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column("products", sa.Column("description", sa.Text(), nullable=True)) 21 | # ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.drop_column("products", "description") 27 | # ### end Alembic commands ### 28 | -------------------------------------------------------------------------------- /src/orders_api/migrations/versions/2120875f27c4_create_initial_schema.py: -------------------------------------------------------------------------------- 1 | """create-initial-schema 2 | 3 | Revision ID: 2120875f27c4 4 | Revises: 5 | Create Date: 2021-01-12 16:23:24.297032 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "2120875f27c4" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.create_table( 21 | "orders", 22 | sa.Column("order_id", postgresql.UUID(as_uuid=True), nullable=False), 23 | sa.Column( 24 | "date", sa.DateTime(), server_default=sa.text("now()"), nullable=False 25 | ), 26 | sa.PrimaryKeyConstraint("order_id"), 27 | ) 28 | op.create_table( 29 | "stores", 30 | sa.Column("store_id", postgresql.UUID(as_uuid=True), nullable=False), 31 | sa.Column("city", sa.Text(), nullable=False), 32 | sa.Column("country", sa.Text(), nullable=False), 33 | sa.Column("currency", sa.String(length=3), nullable=False), 34 | sa.Column("domain", sa.Text(), nullable=True), 35 | sa.Column("name", sa.Text(), nullable=False), 36 | sa.Column("phone", sa.Text(), nullable=True), 37 | sa.Column("street", sa.Text(), nullable=False), 38 | sa.Column("zipcode", sa.Text(), nullable=False), 39 | sa.PrimaryKeyConstraint("store_id"), 40 | ) 41 | op.create_table( 42 | "products", 43 | sa.Column("product_id", postgresql.UUID(as_uuid=True), nullable=False), 44 | sa.Column("store_id", postgresql.UUID(as_uuid=True), nullable=False), 45 | sa.Column("name", sa.Text(), nullable=False), 46 | sa.Column("price", sa.Numeric(precision=12, scale=2), nullable=False), 47 | sa.ForeignKeyConstraint( 48 | ["store_id"], 49 | ["stores.store_id"], 50 | ), 51 | sa.PrimaryKeyConstraint("product_id"), 52 | sa.UniqueConstraint("name", "store_id", name="uix_products"), 53 | ) 54 | op.create_table( 55 | "order_items", 56 | sa.Column("order_id", postgresql.UUID(as_uuid=True), nullable=False), 57 | sa.Column("product_id", postgresql.UUID(as_uuid=True), nullable=False), 58 | sa.Column("quantity", sa.Integer(), nullable=False), 59 | sa.ForeignKeyConstraint( 60 | ["order_id"], 61 | ["orders.order_id"], 62 | ), 63 | sa.ForeignKeyConstraint( 64 | ["product_id"], 65 | ["products.product_id"], 66 | ), 67 | sa.PrimaryKeyConstraint("order_id", "product_id"), 68 | ) 69 | 70 | 71 | def downgrade(): 72 | op.drop_table("order_items") 73 | op.drop_table("products") 74 | op.drop_table("stores") 75 | op.drop_table("orders") 76 | -------------------------------------------------------------------------------- /src/orders_api/migrations/versions/b2a7734ce390_stores_add_email_field.py: -------------------------------------------------------------------------------- 1 | """stores_add-email-field 2 | 3 | Revision ID: b2a7734ce390 4 | Revises: 2120875f27c4 5 | Create Date: 2021-01-27 14:33:28.464701 6 | 7 | """ 8 | import sqlalchemy as sa 9 | import sqlalchemy_utils 10 | from alembic import op 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "b2a7734ce390" 14 | down_revision = "2120875f27c4" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.add_column( 21 | "stores", 22 | sa.Column( 23 | "email", sqlalchemy_utils.types.email.EmailType(length=255), nullable=True 24 | ), 25 | ) 26 | 27 | 28 | def downgrade(): 29 | op.drop_column("stores", "email") 30 | -------------------------------------------------------------------------------- /src/orders_api/mock.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import scoped_session 2 | 3 | from orders_api.db.models import Product, Store 4 | from orders_api.db.session import create_session 5 | 6 | stores = [ 7 | { 8 | "name": "Venus", 9 | "city": "Karlsruhe", 10 | "country": "Germany", 11 | "zipcode": "76131", 12 | "street": "Europaplatz 2", 13 | "email": "kundenservice@venus.com", 14 | "phone": "0721 3526 345", 15 | "currency": "EUR", 16 | "domain": "https://online-store.venus.com", 17 | }, 18 | { 19 | "name": "Media Bazar", 20 | "city": "Munich", 21 | "country": "Germany", 22 | "zipcode": "80337", 23 | "street": "Karl-Wilhelm-Strasse 5a-c", 24 | "email": "hello@media-bazar.de", 25 | "phone": "089 325 815", 26 | "currency": "EUR", 27 | "domain": "https://media-bazar.de", 28 | }, 29 | ] 30 | 31 | products = [ 32 | { 33 | "name": "GameStation 6", 34 | "description": "Play the greatest games on this next-gen entertainment system.", 35 | "price": 349.49, 36 | }, 37 | { 38 | "name": "Adventure Quest: Dark Shadows - for GameStation 6", 39 | "description": "Explore the wide lands of Left Earth and stop the rise of the Dark Empire.", 40 | "price": 59.99, 41 | }, 42 | { 43 | "name": "6 inch Plastic Planters with Saucers", 44 | "description": "The Flower Plant Pots are designed with matte finishing exterior in soft, round shapes, bringing out a modern minimalistic styled ceramic like visual representation, with brief stripes making the planters special, fits for any home/office décor. And smooth glossy inner finish for easy cleaning. Great for replanting, and for flower plant lovers", 45 | "price": 17.49, 46 | }, 47 | { 48 | "name": "GameStation 6", 49 | "description": "Play the greatest games on this next-gen entertainment system.", 50 | "price": 399.99, 51 | }, 52 | ] 53 | 54 | 55 | def insert_mock_data(session: scoped_session) -> None: 56 | store_rows = [Store(**store) for store in stores] 57 | session.add_all(store_rows) 58 | session.flush() 59 | product_rows = [Product(**product) for product in products] 60 | product_rows[0].store = store_rows[0] 61 | product_rows[1].store = store_rows[1] 62 | product_rows[2].store = store_rows[1] 63 | product_rows[3].store = store_rows[1] 64 | session.add_all(product_rows) 65 | session.commit() 66 | 67 | 68 | if __name__ == "__main__": 69 | session = create_session() 70 | insert_mock_data(session) 71 | -------------------------------------------------------------------------------- /src/orders_api/routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treebee/orders-api/c7b67b21646b7a6e01a1b5682ce4731663a52d71/src/orders_api/routers/__init__.py -------------------------------------------------------------------------------- /src/orders_api/routers/orders.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from fastapi import Depends 4 | from fastapi.routing import APIRouter 5 | from pydantic.types import UUID4 6 | 7 | from orders_api.db import models 8 | from orders_api.db.schemas import Order, OrderCreate, OrderDetail 9 | from orders_api.services import OrdersService, get_orders_service 10 | 11 | router = APIRouter(prefix="/orders") 12 | 13 | 14 | @router.get("/", response_model=List[Order]) 15 | async def list_orders( 16 | orders_service: OrdersService = Depends(get_orders_service), 17 | ) -> List[models.Order]: 18 | return orders_service.list() 19 | 20 | 21 | @router.get("/{order_id}", response_model=OrderDetail) 22 | async def get_order( 23 | order_id: UUID4, orders_service: OrdersService = Depends(get_orders_service) 24 | ) -> Optional[models.Order]: 25 | return orders_service.get(order_id) 26 | 27 | 28 | @router.post("/", status_code=201, response_model=OrderDetail) 29 | async def create_order( 30 | order: OrderCreate, orders_service: OrdersService = Depends(get_orders_service) 31 | ) -> models.Order: 32 | return orders_service.create(order) 33 | -------------------------------------------------------------------------------- /src/orders_api/routers/products.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from fastapi import Depends 4 | from fastapi.routing import APIRouter 5 | from pydantic.types import UUID4 6 | 7 | from orders_api.db import models 8 | from orders_api.db.schemas import Product, ProductCreate, ProductUpdate 9 | from orders_api.services import ProductsService, get_products_service 10 | 11 | router = APIRouter(prefix="/products") 12 | 13 | 14 | @router.get("/", response_model=List[Product]) 15 | async def list_products( 16 | product_service: ProductsService = Depends(get_products_service), 17 | ) -> List[models.Product]: 18 | return product_service.list() 19 | 20 | 21 | @router.get("/{product_id}", response_model=Product) 22 | async def get_product( 23 | product_id: UUID4, product_service: ProductsService = Depends(get_products_service) 24 | ) -> Optional[models.Product]: 25 | return product_service.get(product_id) 26 | 27 | 28 | @router.post( 29 | "/", 30 | response_model=Product, 31 | status_code=201, 32 | responses={ 33 | 400: {"description": "Invalid store reference"}, 34 | 409: {"description": "Conflict Error"}, 35 | }, 36 | ) 37 | async def create_product( 38 | product: ProductCreate, 39 | product_service: ProductsService = Depends(get_products_service), 40 | ) -> models.Product: 41 | db_product = product_service.create(product) 42 | return db_product 43 | 44 | 45 | @router.patch("/{product_id}", status_code=204) 46 | async def update_product( 47 | product_id: UUID4, 48 | product: ProductUpdate, 49 | product_service: ProductsService = Depends(get_products_service), 50 | ) -> None: 51 | product_service.update(product_id, product) 52 | 53 | 54 | @router.delete("/{product_id}", status_code=204) 55 | async def delete_product( 56 | product_id: UUID4, product_service: ProductsService = Depends(get_products_service) 57 | ) -> None: 58 | product_service.delete(product_id) 59 | -------------------------------------------------------------------------------- /src/orders_api/routers/stores.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from fastapi import Depends 4 | from fastapi.routing import APIRouter 5 | from pydantic.types import UUID4 6 | 7 | from orders_api.db import models 8 | from orders_api.db.schemas import Store, StoreCreate, StoreUpdate 9 | from orders_api.services import StoresService, get_stores_service 10 | 11 | router = APIRouter(prefix="/stores") 12 | 13 | 14 | @router.get("/", response_model=List[Store]) 15 | async def list_stores( 16 | store_service: StoresService = Depends(get_stores_service), 17 | ) -> List[models.Store]: 18 | return store_service.list() 19 | 20 | 21 | @router.get( 22 | "/{store_id}", 23 | response_model=Store, 24 | responses={404: {"description": "Store not found"}}, 25 | ) 26 | async def get_store( 27 | store_id: UUID4, store_service: StoresService = Depends(get_stores_service) 28 | ) -> Optional[models.Store]: 29 | return store_service.get(store_id) 30 | 31 | 32 | @router.post( 33 | "/", 34 | response_model=Store, 35 | status_code=201, 36 | responses={409: {"description": "Conflict Error"}}, 37 | ) 38 | async def create_store( 39 | store: StoreCreate, store_service: StoresService = Depends(get_stores_service) 40 | ) -> models.Store: 41 | return store_service.create(store) 42 | 43 | 44 | @router.patch("/{store_id}", status_code=204) 45 | async def update_store( 46 | store_id: UUID4, 47 | store: StoreUpdate, 48 | store_service: StoresService = Depends(get_stores_service), 49 | ) -> None: 50 | store_service.update(store_id, store) 51 | 52 | 53 | @router.delete("/{store_id}", status_code=204) 54 | async def delete_store( 55 | store_id: UUID4, store_service: StoresService = Depends(get_stores_service) 56 | ) -> None: 57 | store_service.delete(store_id) 58 | -------------------------------------------------------------------------------- /src/orders_api/services/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends 2 | from sqlalchemy.orm import Session 3 | 4 | from orders_api.db.session import get_session 5 | 6 | from .orders import OrdersService 7 | from .products import ProductsService 8 | from .stores import StoresService 9 | 10 | 11 | def get_stores_service(db_session: Session = Depends(get_session)) -> StoresService: 12 | return StoresService(db_session) 13 | 14 | 15 | def get_products_service(db_session: Session = Depends(get_session)) -> ProductsService: 16 | return ProductsService(db_session) 17 | 18 | 19 | def get_orders_service(db_session: Session = Depends(get_session)) -> OrdersService: 20 | return OrdersService(db_session) 21 | 22 | 23 | __all__ = ("get_stores_service", "get_products_service", "get_orders_service") 24 | -------------------------------------------------------------------------------- /src/orders_api/services/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Generic, List, Optional, Type, TypeVar 2 | 3 | import sqlalchemy 4 | from pydantic import BaseModel 5 | from sqlalchemy.orm import Session 6 | from starlette.exceptions import HTTPException 7 | 8 | from orders_api.db.models import Base 9 | 10 | ModelType = TypeVar("ModelType", bound=Base) 11 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) 12 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) 13 | 14 | 15 | class BaseService(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 16 | def __init__(self, model: Type[ModelType], db_session: Session): 17 | self.model = model 18 | self.db_session = db_session 19 | 20 | def get(self, id: Any) -> Optional[ModelType]: 21 | obj: Optional[ModelType] = self.db_session.get(self.model, id) 22 | if obj is None: 23 | raise HTTPException(status_code=404, detail="Not Found") 24 | return obj 25 | 26 | def list(self) -> List[ModelType]: 27 | objs: List[ModelType] = self.db_session.query(self.model).all() 28 | return objs 29 | 30 | def create(self, obj: CreateSchemaType) -> ModelType: 31 | db_obj: ModelType = self.model(**obj.dict()) 32 | self.db_session.add(db_obj) 33 | try: 34 | self.db_session.commit() 35 | except sqlalchemy.exc.IntegrityError as e: 36 | self.db_session.rollback() 37 | if "duplicate key" in str(e): 38 | raise HTTPException(status_code=409, detail="Conflict Error") 39 | else: 40 | raise e 41 | return db_obj 42 | 43 | def update(self, id: Any, obj: UpdateSchemaType) -> Optional[ModelType]: 44 | db_obj = self.get(id) 45 | for column, value in obj.dict(exclude_unset=True).items(): 46 | setattr(db_obj, column, value) 47 | self.db_session.commit() 48 | return db_obj 49 | 50 | def delete(self, id: Any) -> None: 51 | db_obj = self.db_session.get(self.model, id) 52 | self.db_session.delete(db_obj) 53 | self.db_session.commit() 54 | -------------------------------------------------------------------------------- /src/orders_api/services/orders.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from orders_api.db.models import Order, OrderItem 6 | from orders_api.db.schemas import OrderCreate 7 | 8 | from .base import BaseService 9 | 10 | 11 | class OrdersService(BaseService[Order, OrderCreate, Any]): 12 | def __init__(self, db_session: Session): 13 | super(OrdersService, self).__init__(Order, db_session) 14 | 15 | def create(self, obj: OrderCreate) -> Order: 16 | order: Order = Order() 17 | self.db_session.add(order) 18 | self.db_session.flush() 19 | order_items = [ 20 | OrderItem(**item.dict(), order_id=order.order_id) for item in obj.items 21 | ] 22 | self.db_session.add_all(order_items) 23 | self.db_session.commit() 24 | return order 25 | -------------------------------------------------------------------------------- /src/orders_api/services/products.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | from sqlalchemy.orm import Session 3 | 4 | from orders_api.db.models import Product, Store 5 | from orders_api.db.schemas import ProductCreate, ProductUpdate 6 | 7 | from .base import BaseService 8 | 9 | 10 | class ProductsService(BaseService[Product, ProductCreate, ProductUpdate]): 11 | def __init__(self, db_session: Session): 12 | super(ProductsService, self).__init__(Product, db_session) 13 | 14 | def create(self, obj: ProductCreate) -> Product: 15 | store = self.db_session.get(Store, obj.store_id) 16 | if store is None: 17 | raise HTTPException( 18 | status_code=400, 19 | detail=f"Store with storeId = {obj.store_id} not found.", 20 | ) 21 | return super(ProductsService, self).create(obj) 22 | -------------------------------------------------------------------------------- /src/orders_api/services/stores.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from orders_api.db.models import Store 4 | from orders_api.db.schemas import StoreCreate, StoreUpdate 5 | 6 | from .base import BaseService 7 | 8 | 9 | class StoresService(BaseService[Store, StoreCreate, StoreUpdate]): 10 | def __init__(self, db_session: Session): 11 | super(StoresService, self).__init__(Store, db_session) 12 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from typing import Any, Generator 3 | 4 | import pytest 5 | from fastapi.testclient import TestClient 6 | from sqlalchemy.engine import Engine 7 | from sqlalchemy.orm import scoped_session 8 | from sqlalchemy_utils import create_database, database_exists 9 | 10 | from orders_api.app import create_app 11 | from orders_api.db.models import Base, Order, OrderItem, Product, Store 12 | from orders_api.db.session import create_session 13 | 14 | 15 | @pytest.fixture(scope="session") 16 | def db() -> scoped_session: 17 | db_session: scoped_session = create_session() 18 | assert db_session.bind is not None 19 | engine: Engine = db_session.bind.engine 20 | if not database_exists(engine.url): 21 | create_database(engine.url) 22 | Base.metadata.drop_all(bind=engine) 23 | Base.metadata.create_all(bind=engine) 24 | return db_session 25 | 26 | 27 | @pytest.fixture() 28 | def cleanup_db(db: scoped_session) -> None: 29 | for table in reversed(Base.metadata.sorted_tables): 30 | db.execute(table.delete()) 31 | 32 | 33 | @pytest.fixture() 34 | def app_client(cleanup_db: Any) -> Generator[TestClient, None, None]: 35 | app = create_app() 36 | yield TestClient(app) 37 | 38 | 39 | @pytest.fixture() 40 | def create_store(db: scoped_session) -> Generator[Store, None, None]: 41 | store = Store( 42 | name="TechStuff Online", 43 | city="Karlsruhe", 44 | country="Germany", 45 | currency="EUR", 46 | zipcode="76131", 47 | street="Kaiserstr. 42", 48 | ) 49 | db.add(store) 50 | db.commit() 51 | yield store 52 | 53 | 54 | @pytest.fixture() 55 | def create_product( 56 | db: scoped_session, create_store: Store 57 | ) -> Generator[Product, None, None]: 58 | product = Product( 59 | name="Rubik's Cube", price=Decimal("9.99"), store_id=create_store.store_id 60 | ) 61 | db.add(product) 62 | db.commit() 63 | yield product 64 | 65 | 66 | @pytest.fixture() 67 | def create_order( 68 | db: scoped_session, create_product: Product 69 | ) -> Generator[Order, None, None]: 70 | order = Order() 71 | db.add(order) 72 | db.commit() 73 | order_item = OrderItem( 74 | product_id=create_product.product_id, order_id=order.order_id, quantity=2 75 | ) 76 | db.add(order_item) 77 | db.commit() 78 | yield order 79 | -------------------------------------------------------------------------------- /tests/routers/test_orders.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from orders_api.db.models import Order, Product 4 | 5 | 6 | def test_create(app_client: TestClient, create_product: Product) -> None: 7 | payload = {"items": [{"productId": str(create_product.product_id), "quantity": 2}]} 8 | rv = app_client.post("/orders/", json=payload) 9 | assert rv.status_code == 201, rv.text 10 | 11 | 12 | def test_list(app_client: TestClient, create_order: Order) -> None: 13 | rv = app_client.get("/orders") 14 | orders = rv.json() 15 | assert rv.status_code == 200 16 | assert len(orders) == 1 17 | assert orders[0]["orderId"] == str(create_order.order_id) 18 | assert orders[0]["total"] == 9.99 * 2 19 | 20 | 21 | def test_get(app_client: TestClient, create_order: Order) -> None: 22 | rv = app_client.get(f"/orders/{create_order.order_id}") 23 | assert rv.status_code == 200 24 | assert "date" in rv.json() 25 | assert rv.json()["items"][0]["quantity"] == create_order.items[0].quantity 26 | -------------------------------------------------------------------------------- /tests/routers/test_products.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from orders_api.db.models import Product, Store 6 | 7 | 8 | def test_create(app_client: TestClient, create_store: Store) -> None: 9 | payload = { 10 | "name": "Play Station 6", 11 | "price": 499.99, 12 | "storeId": str(create_store.store_id), 13 | } 14 | rv = app_client.post("/products/", json=payload) 15 | assert rv.status_code == 201, rv.json() 16 | 17 | 18 | def test_create_invalid_store(app_client: TestClient) -> None: 19 | payload = { 20 | "name": "Play Station 6", 21 | "price": 499.99, 22 | "storeId": str(uuid.uuid4()), 23 | } 24 | rv = app_client.post("/products/", json=payload) 25 | assert rv.status_code == 400 26 | 27 | 28 | def test_create_duplicate(app_client: TestClient, create_product: Product) -> None: 29 | payload = { 30 | "name": create_product.name, 31 | "price": 499.99, 32 | "storeId": str(create_product.store_id), 33 | } 34 | rv = app_client.post("/products/", json=payload) 35 | assert rv.status_code == 409 36 | 37 | 38 | def test_list(app_client: TestClient, create_product: Product) -> None: 39 | rv = app_client.get("/products") 40 | products = rv.json() 41 | assert rv.status_code == 200 42 | assert len(products) == 1 43 | assert products[0]["name"] == create_product.name 44 | 45 | 46 | def test_get(app_client: TestClient, create_product: Product) -> None: 47 | rv = app_client.get(f"/products/{create_product.product_id}") 48 | products = rv.json() 49 | assert rv.status_code == 200 50 | assert products["name"] == create_product.name 51 | 52 | 53 | def test_delete(app_client: TestClient, create_product: Product) -> None: 54 | rv = app_client.get(f"/products/{create_product.product_id}") 55 | assert rv.status_code == 200 56 | rv = app_client.delete(f"/products/{create_product.product_id}") 57 | assert rv.status_code == 204 58 | rv = app_client.get(f"/products/{create_product.product_id}") 59 | assert rv.status_code == 404 60 | 61 | 62 | def test_update(app_client: TestClient, create_product: Product) -> None: 63 | rv = app_client.patch( 64 | f"/products/{create_product.product_id}", json={"name": "Rubik's Cube V2"} 65 | ) 66 | assert rv.status_code == 204 67 | rv = app_client.get(f"/products/{create_product.product_id}") 68 | assert rv.json()["name"] == "Rubik's Cube V2" 69 | -------------------------------------------------------------------------------- /tests/routers/test_stores.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from orders_api.db.models import Store 4 | 5 | 6 | def test_create(app_client: TestClient) -> None: 7 | payload = { 8 | "name": "Kwik-e Mart", 9 | "city": "Springfield", 10 | "country": "USA", 11 | "currency": "USD", 12 | "zipcode": "1234", 13 | "street": "First Street", 14 | } 15 | rv = app_client.post("/stores/", json=payload) 16 | assert rv.status_code == 201 17 | assert rv.json()["name"] == "Kwik-e Mart" 18 | assert rv.json()["storeId"] is not None 19 | 20 | 21 | def test_list(app_client: TestClient, create_store: Store) -> None: 22 | rv = app_client.get("/stores") 23 | stores = rv.json() 24 | assert rv.status_code == 200 25 | assert len(stores) == 1 26 | assert stores[0]["name"] == create_store.name 27 | 28 | 29 | def test_get(app_client: TestClient, create_store: Store) -> None: 30 | rv = app_client.get(f"/stores/{create_store.store_id}") 31 | stores = rv.json() 32 | assert rv.status_code == 200 33 | assert stores["name"] == "TechStuff Online" 34 | 35 | 36 | def test_delete(app_client: TestClient, create_store: Store) -> None: 37 | rv = app_client.get(f"/stores/{create_store.store_id}") 38 | assert rv.status_code == 200 39 | rv = app_client.delete(f"/stores/{create_store.store_id}") 40 | assert rv.status_code == 204 41 | rv = app_client.get(f"/stores/{create_store.store_id}") 42 | assert rv.status_code == 404 43 | 44 | 45 | def test_update(app_client: TestClient, create_store: Store) -> None: 46 | rv = app_client.patch( 47 | f"/stores/{create_store.store_id}", json={"name": "New Store Name"} 48 | ) 49 | assert rv.status_code == 204 50 | rv = app_client.get(f"/stores/{create_store.store_id}") 51 | assert rv.json()["name"] == "New Store Name" 52 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | 4 | def test_health(app_client: TestClient) -> None: 5 | rv = app_client.get("/health") 6 | assert rv.status_code == 200 7 | --------------------------------------------------------------------------------