From 45d7926af140d39b87cac082b31260706920b405 Mon Sep 17 00:00:00 2001 From: ivankirpichnikov Date: Fri, 17 Oct 2025 02:21:46 +0300 Subject: [PATCH] add tests and docker infra --- .coverage | Bin 0 -> 53248 bytes .dockerignore | 6 + .justfile | 85 ++++++++++ Dockerfile | 69 +++++++- alembic.ini | 147 +++++++++++++++++ docker-compose.example.yml | 60 +++++++ pyproject.toml | 149 +++++++++++++++++- .../adapters/data_gateways/tables.py | 2 +- .../application/access_token/errors.py | 8 +- src/template_project/migrations/README | 1 + src/template_project/migrations/env.py | 86 ++++++++++ .../migrations/script.py.mako | 28 ++++ src/template_project/web_api/configuration.py | 4 + .../{entiry_point.py => entry_point.py} | 22 ++- .../web_api/ioc/connection.py | 6 +- src/template_project/web_api/ioc/idp.py | 9 ++ src/template_project/web_api/ioc/make.py | 7 +- .../web_api/routes/healthcheck.py | 14 ++ tests/__init__.py | 0 tests/web_api/__init__.py | 0 tests/web_api/conftest.py | 17 ++ tests/web_api/e2e/__init__.py | 0 tests/web_api/e2e/test_healthcheck.py | 14 ++ tests/web_api/ioc.py | 96 +++++++++++ 24 files changed, 806 insertions(+), 24 deletions(-) create mode 100644 .coverage create mode 100644 .dockerignore create mode 100644 .justfile create mode 100644 alembic.ini create mode 100644 docker-compose.example.yml create mode 100644 src/template_project/migrations/README create mode 100644 src/template_project/migrations/env.py create mode 100644 src/template_project/migrations/script.py.mako rename src/template_project/web_api/{entiry_point.py => entry_point.py} (81%) create mode 100644 src/template_project/web_api/ioc/idp.py create mode 100644 src/template_project/web_api/routes/healthcheck.py create mode 100644 tests/__init__.py create mode 100644 tests/web_api/__init__.py create mode 100644 tests/web_api/conftest.py create mode 100644 tests/web_api/e2e/__init__.py create mode 100644 tests/web_api/e2e/test_healthcheck.py create mode 100644 tests/web_api/ioc.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..39290934e854c6976fcd84bf39639a6aeac56f1c GIT binary patch literal 53248 zcmeI)O>Y}T7zgm(r1i#5A_s-YigL(Yz_Dt(SR4>=fB-pER4Rpt3li>lJx&(9yUxDk z<$#b|q)3(c28i#(7wL@?7oKNl)@vtm)mx?Qe-%6XGBZ2#n`dV1ZrdN8KK2qNMq%X3 zM0{**S+;F`DTHNNYxLTrSH3y4b1y%j-}c=8vfVYS`||zG`j6H|=A634Kl+)C6svjyM!icp)Y#5+iS{gcl4wS0-L?B9c>;r%q!vw81Wm8cUcViI}2jIPA5>Z+Y3Fn3GTWui||A<8=mIE6Sc*;^t~BNeH@Rk5zcfj4Z` zS}%UDIgRaY`&wmfl95Y4>kF-+6Gc-Vji?|ujD}qMK_mnBRK;x}XDxI?s^?{*TN{P` z!z2`AFUXo1#9rcsfl!yqO%pY|vw>|!%QwQCZw;08j2rY!=QQ`4(GM`!rb(C`_o?&yJ$l>t{X~Q5v{BRAO#}8=k93o~W+5s;7mw z9Y_>9y=tX#w7)ELW+Bk;&30?k-HOxL+q0invoJEfWqvseA(Z#z9n)xj$yYK?qkJNH z-6WakC}zogjF>CboQ_jz9PTY^j!D<=nVq$%bI)n)?ATL1B6wcn*ZqY?@R?!=+*k)lM>r%9h*s|!`;GewoZRac#J zIi}fUBE5))EX~1s1rVHYC@HZVglUq+FCS*%$!kwhUtK_PoZC~XR+gNG_jh3&d$g?b zvK4=ppB2qh8S^X@H^m?f$4UlzZJ->FV!Edni>wZ}SEVnCGU6*P=iDP)5q5rDjTBv^wY1e-eO(xEZ}ofp&fk*x+p{&@ zx1Gj=2lg~IgHg{j{g@VYU*^>|hbs#~a^qqVFHdN~rB6Haal&?U9DJznz>}Nq!GTuD zQ<={?wHANDx9k5|^n(oo5P$##AOHafKmY;|fB*y_0D Docker +# ========= + +[no-cd] +[group("Docker")] +[doc("Билд основного контейнера")] +docker-build-main: + docker build -t template_project_deploy . + +[no-cd] +[group("Docker")] +[doc("Билд тестового контейнера")] +docker-build-tests: + docker build -t template_project_tests . + +[no-cd] +[group("Docker")] +[doc("Билд миграционного контейнера")] +docker-build-migrations: + docker build -t template_project_migrations . + +[no-cd] +[group("Docker")] +[doc("Билд всех контейнеров")] +docker-build-all: + just docker-build-main + just docker-build-tests + just docker-build-migrations + +[no-cd] +[group("Docker")] +[doc("Запуск композа")] +docker-up: + just docker-build-all + + docker compose up web_api -d + +# ========= +# > Tests +# ========= + +[no-cd] +[group("Tests")] +[doc("Запуск тестов")] +tests-run: + just docker-up + + docker compose up tests --abort-on-container-exit --remove-orphans + coverage report + +# ========= +# > Lints +# ========= + +[no-cd] +[group("Lints")] +[doc("Запуск всех линтов")] +lints-run: + ruff check + mypy + codespell src tests + bandit src tests + +# ========= +# > Migrations +# ========= + +[no-cd] +[group("Migrations")] +[doc("Запуск миграции")] +migrations-run tag="head": + docker compose run --remove-orphans migrations alembic upgrade {{tag}} + docker compose down postgresql + +[no-cd] +[group("Migrations")] +[doc('Создание миграции')] +migrations-make message: + docker compose run migrations alembic revision --autogenerate -m "{{message}}" + docker compose down postgresql diff --git a/Dockerfile b/Dockerfile index de25917..5a497a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,70 @@ -FROM python:3.13-alpine +# ===== +# > Python-Base +# ===== -WORKDIR /app +FROM python:3.13-slim-bookworm AS python-base -RUN pip install uv +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONOPTIMIZE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_DEFAULT_TIMEOUT=100 \ + APP_PATH="/app" \ + UV_VERSION="0.8.19" +ENV \ + VIRTUAL_ENV="$APP_PATH/.venv" \ + PATH="$VIRTUAL_ENV/bin:$PATH" \ + PROJECT_PATH="$APP_PATH/src/template_project" + +WORKDIR $APP_PATH + +# ===== +# > Builder +# ===== + +FROM python-base AS template_project_builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc git \ + && rm -rf /var/lib/apt/lists + +RUN pip install --no-cache-dir "uv==$UV_VERSION" RUN mkdir -p ./src/ -COPY pyproject.toml /app/pyproject.toml +COPY pyproject.toml $APP_PATH/pyproject.toml +RUN uv venv -p 3.13 && uv pip install -e . +COPY ./src/template_project $PROJECT_PATH -RUN uv pip install -e . --system --no-cache +# ===== +# > Deploy +# ===== -COPY ./src/template_project /app/src/template_project +FROM template_project_builder AS template_project_deploy + +COPY --from=template_project_builder $VIRTUAL_ENV $VIRTUAL_ENV + +# ===== +# > Tests +# ===== + +FROM template_project_builder AS template_project_tests + +COPY --from=template_project_builder $VIRTUAL_ENV $VIRTUAL_ENV + +RUN uv pip install --group tests + +COPY ./tests $APP_PATH/tests + +# ===== +# > Migrations +# ===== + +FROM template_project_builder AS template_project_migrations + +COPY --from=template_project_builder $VIRTUAL_ENV $VIRTUAL_ENV + +VOLUME $PROJECT_PATH/migrations + +RUN uv pip install --group migrations diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..cab51d3 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/src/template_project/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..d76f615 --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,60 @@ +services: + web_api: + image: template_project_deploy + build: . + restart: unless-stopped + volumes: + - ./config.toml:/app/config.toml:ro + command: web_api_cli /app/config.toml + depends_on: + - postgresql + expose: + - "8080" + ports: + - "8080:8080" + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8080/healthcheck/" ] + interval: 2s + + tests: + image: template_project_tests + volumes: + - ./cov/:/app/cov + - ./tests:/app/tests:ro + - ./config.toml:/app/config.toml:ro + environment: + CONFIGURATION_PATH: /app/config.toml + command: coverage run --source src -m pytest && coverage report && coverage xml -o ./cov/coverage.xml + depends_on: + web_api: + condition: service_healthy + + migrations: + image: template_project_migrations + environment: + CONFIGURATION_PATH: /app/config.toml + volumes: + - ./alembic.ini:/app/alembic.ini:ro + - ./config.toml:/app/config.toml:ro + - ./src/template_project/migrations:/app/src/template_project/migrations + depends_on: + - postgresql + + postgresql: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_PASSWORD: template_project_password + POSTGRES_USER: template_project_user + POSTGRES_DB: template_project_db + PGDATA: /var/lib/postgresql/data/ + expose: + - "5432" + ports: + - "127.0.0.1:5432:5432" + volumes: + - postgresql_volume:/var/lib/postgresql/data + +volumes: + redis_volume: + postgresql_volume: diff --git a/pyproject.toml b/pyproject.toml index 4f0d02d..753ad44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,156 @@ requires-python = ">=3.13" description = "template project" version = "1.0.0" dependencies = [ - "uuid-utils==0.11.1", + "uuid_utils==0.11.1", "adaptix==3.0.0b11", "fastapi==0.119.0", "uvicorn==0.37.0", + "sqlalchemy==2.0.44", "dishka==1.7.2", - "argon2-cffi==23.1.0", + "argon2_cffi==23.1.0", "cryptography==46.0.3", + "httpx==0.28.1", ] + +[dependency-groups] +types = [ + "types-cachetools==6.2.0.20250827", +] +migrations = [ + "alembic==1.17.0", +] +linters = [ + "mypy==1.17.1", + "ruff==0.12.11", + "codespell==2.4.1", + "bandit==1.8.6", + { include-group = "types" }, +] +tests = [ + "pytest==8.4.0", + "coverage==7.11.0", + "pytest_asyncio==1.2.0", +] +dev = [ + { include-group = "tests" }, + { include-group = "linters" }, + { include-group = "migrations"}, +] + +[project.scripts] +web_api_cli = "template_project.web_api.entry_point:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +filterwarnings = [ + "ignore::UserWarning", + 'ignore:function ham\(\) is deprecated:DeprecationWarning', +] + +[tool.mypy] +strict = true +strict_bytes = true +local_partial_types = true +warn_unreachable = true +files = ["src", "tests"] +exclude = [ + "src/template_project/migrations", +] +enable_error_code = [ + "truthy-bool", + "truthy-iterable", + "redundant-expr", + "unused-awaitable", + "ignore-without-code", + "possibly-undefined", + "redundant-self", + "explicit-override", + "mutable-override", + "unimported-reveal", + "deprecated", +] + +[tool.ruff] +fix = true +preview = true +target-version = "py313" +line-length = 120 +indent-width = 4 +exclude = [ + "src/template_project/migrations/**.py", +] + +[tool.ruff.lint] +select = [ + "A", # flake8-builtins + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "C90", # maccabe + "COM", # flake8-commas + "D", # pydocstyle + "DTZ", # flake8-datetimez + "E", # pycodestyle + "ERA", # flake8-eradicate + "EXE", # flake8-executable + "F", # pyflakes + "FA", # flake8-future-annotations + "FBT", # flake8-boolean-trap + "FLY", # pyflint + "FURB", # refurb + "G", # flake8-logging-format + "I", # isort + "ICN", # flake8-import-conventions + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "N", # pep8-naming + "PERF", # perflint + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-use-pathlib + "Q", # flake8-quotes + "RET", # flake8-return + "RSE", # flake8-raise + "RUF", # ruff + "S", # flake8-bandit + "SIM", # flake8-simpify + "SLF", # flake8-self + "SLOT", # flake8-slots + "T100", # flake8-debugger + "TRY", # tryceratops + "UP", # pyupgrade + "W", # pycodestyle + "YTT", # flake8-2020 +] +ignore = [ + "A005", # allow to shadow stdlib and builtin module names + "COM812", # trailing comma, conflicts with `ruff format` + # Different doc rules that we don't really care about: + "D", + "ISC001", # implicit string concat conflicts with `ruff format` + "ISC003", # prefer explicit string concat over implicit concat + "PLR09", # we have our own complexity rules + "PLR2004", # do not report magic numbers + "PLR6301", # do not require classmethod / staticmethod when self not used + "TRY003", # long exception messages from `tryceratops` +] +external = [ "WPS" ] + +[tool.ruff.lint.per-file-ignores] +"tests/*.py" = [ + "S101", # asserts +] + +[tool.ruff.lint.isort] +case-sensitive = true +combine-as-imports = true + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "lf" + +# This is only required because we have invalid on-purpose code in docstrings: +docstring-code-format = false diff --git a/src/template_project/adapters/data_gateways/tables.py b/src/template_project/adapters/data_gateways/tables.py index c1a7798..324543c 100644 --- a/src/template_project/adapters/data_gateways/tables.py +++ b/src/template_project/adapters/data_gateways/tables.py @@ -1,7 +1,7 @@ __all__ = ( + "access_token_table", "meta_data", "user_table", - "access_token_table", ) from sqlalchemy import UUID, Boolean, Column, DateTime, ForeignKey, MetaData, String, Table diff --git a/src/template_project/application/access_token/errors.py b/src/template_project/application/access_token/errors.py index 4267a13..24abfbe 100644 --- a/src/template_project/application/access_token/errors.py +++ b/src/template_project/application/access_token/errors.py @@ -1,12 +1,14 @@ -from typing import override +from typing import TYPE_CHECKING, override -from template_project.application.access_token.entity import AccessTokenId from template_project.application.common.errors import ApplicationError, to_error +if TYPE_CHECKING: + from template_project.application.access_token.entity import AccessTokenId + @to_error class AccessTokenExpiredError(ApplicationError): - id_: AccessTokenId + id_: "AccessTokenId" @override def __str__(self) -> str: diff --git a/src/template_project/migrations/README b/src/template_project/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/src/template_project/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/src/template_project/migrations/env.py b/src/template_project/migrations/env.py new file mode 100644 index 0000000..c6d28a1 --- /dev/null +++ b/src/template_project/migrations/env.py @@ -0,0 +1,86 @@ +from logging.config import fileConfig +import os +from pathlib import Path + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +from template_project.web_api.configuration import load_configuration + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from template_project.adapters.data_gateways.tables import meta_data +target_metadata = meta_data + +configuration = load_configuration(Path(os.environ["CONFIGURATION_PATH"])) +config.set_main_option("sqlalchemy.url", configuration.database.url.get_value()) + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/template_project/migrations/script.py.mako b/src/template_project/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/src/template_project/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/src/template_project/web_api/configuration.py b/src/template_project/web_api/configuration.py index 087a9a5..fb8d959 100644 --- a/src/template_project/web_api/configuration.py +++ b/src/template_project/web_api/configuration.py @@ -31,6 +31,10 @@ class ServerConfiguration: port: int access_log: bool + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}" + @to_configuration class Configuration: diff --git a/src/template_project/web_api/entiry_point.py b/src/template_project/web_api/entry_point.py similarity index 81% rename from src/template_project/web_api/entiry_point.py rename to src/template_project/web_api/entry_point.py index 00642a1..2643132 100644 --- a/src/template_project/web_api/entiry_point.py +++ b/src/template_project/web_api/entry_point.py @@ -1,5 +1,6 @@ import argparse import asyncio +import os import sys from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -14,6 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware from template_project.web_api.configuration import load_configuration from template_project.web_api.ioc.make import make_ioc +from template_project.web_api.routes import healthcheck, user LOG_CONFIG: Final = { "version": 1, @@ -53,15 +55,15 @@ def make_asgi_application( version="1.0.0", openapi_url="/openapi.json", ) - origins = ["*"] - app.add_middleware( CORSMiddleware, - allow_origins=origins, + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) + app.include_router(user.router) + app.include_router(healthcheck.router) setup_dishka(container=ioc, app=app) @@ -91,13 +93,17 @@ def main() -> None: asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy()) arg_parser = argparse.ArgumentParser() - subparsers = arg_parser.add_subparsers() - - web_api_parser = subparsers.add_parser("web_api") - web_api_parser.add_argument("configuration", dest="configuration", type=Path) + arg_parser.add_argument("configuration", default=None) args = arg_parser.parse_args() - _main(args.configuration) + configuration_path = args.configuration or os.getenv("CONFIGURATION_PATH") + + if configuration_path is None: + raise RuntimeError( + "pass the path to the config or specify it in the environment variables `CONFIGURATION_PATH`", + ) + + _main(Path(configuration_path)) if __name__ == "__main__": diff --git a/src/template_project/web_api/ioc/connection.py b/src/template_project/web_api/ioc/connection.py index d7cf620..fb66eae 100644 --- a/src/template_project/web_api/ioc/connection.py +++ b/src/template_project/web_api/ioc/connection.py @@ -8,13 +8,13 @@ from template_project.web_api.configuration import DatabaseConfiguration class ConnectionProvider(Provider): @provide(scope=Scope.APP) - async def make_engine(self, configuration: DatabaseConfiguration) -> AsyncIterable[AsyncEngine]: + async def engine(self, configuration: DatabaseConfiguration) -> AsyncIterable[AsyncEngine]: engine = create_async_engine(configuration.url.get_value()) yield engine await engine.dispose() - @provide() - async def make_connection(self, engine: AsyncEngine) -> AsyncIterable[AsyncSession]: + @provide(scope=Scope.REQUEST) + async def async_session(self, engine: AsyncEngine) -> AsyncIterable[AsyncSession]: session = AsyncSession( bind=engine, expire_on_commit=True, diff --git a/src/template_project/web_api/ioc/idp.py b/src/template_project/web_api/ioc/idp.py new file mode 100644 index 0000000..c4cb989 --- /dev/null +++ b/src/template_project/web_api/ioc/idp.py @@ -0,0 +1,9 @@ +from dishka import BaseScope, Provider, Scope, provide + +from template_project.web_api.identity_provider import WebApiIdentityProvider + + +class IdPProvider(Provider): + scope: BaseScope | None = Scope.REQUEST + + web_api = provide(WebApiIdentityProvider) diff --git a/src/template_project/web_api/ioc/make.py b/src/template_project/web_api/ioc/make.py index f905ea0..8f74dd9 100644 --- a/src/template_project/web_api/ioc/make.py +++ b/src/template_project/web_api/ioc/make.py @@ -1,4 +1,4 @@ -from dishka import AsyncContainer, make_async_container +from dishka import STRICT_VALIDATION, AsyncContainer, make_async_container from dishka.integrations.fastapi import FastapiProvider from template_project.web_api.configuration import ( @@ -7,19 +7,24 @@ from template_project.web_api.configuration import ( DatabaseConfiguration, ServerConfiguration, ) +from template_project.web_api.ioc.connection import ConnectionProvider from template_project.web_api.ioc.cryptographer import CryptographerProvider from template_project.web_api.ioc.data_gateway import DataGatewayProvider from template_project.web_api.ioc.factory import FactoryProvider +from template_project.web_api.ioc.idp import IdPProvider from template_project.web_api.ioc.interactor import InteractorProvider def make_ioc(configuration: Configuration) -> AsyncContainer: return make_async_container( + IdPProvider(), FactoryProvider(), FastapiProvider(), + ConnectionProvider(), InteractorProvider(), DataGatewayProvider(), CryptographerProvider(), + validation_settings=STRICT_VALIDATION, context={ ServerConfiguration: configuration.server, DatabaseConfiguration: configuration.database, diff --git a/src/template_project/web_api/routes/healthcheck.py b/src/template_project/web_api/routes/healthcheck.py new file mode 100644 index 0000000..fd07ee3 --- /dev/null +++ b/src/template_project/web_api/routes/healthcheck.py @@ -0,0 +1,14 @@ +from dishka.integrations.fastapi import DishkaRoute +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter(route_class=DishkaRoute) + + +class HealthcheckResponse(BaseModel): + ok: bool + + +@router.get("/healthcheck") +async def healthcheck() -> HealthcheckResponse: + return HealthcheckResponse(ok=True) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/web_api/__init__.py b/tests/web_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/web_api/conftest.py b/tests/web_api/conftest.py new file mode 100644 index 0000000..dac2211 --- /dev/null +++ b/tests/web_api/conftest.py @@ -0,0 +1,17 @@ +from collections.abc import AsyncIterable +from pathlib import Path + +import pytest +from dishka import AsyncContainer + +from template_project.web_api.configuration import load_configuration +from tests.web_api.ioc import make_ioc + + +@pytest.fixture +async def dishka_container() -> AsyncIterable[AsyncContainer]: + path = Path("config.toml") + configuration = load_configuration(path) + ioc = make_ioc(configuration) + yield ioc + await ioc.close() diff --git a/tests/web_api/e2e/__init__.py b/tests/web_api/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/web_api/e2e/test_healthcheck.py b/tests/web_api/e2e/test_healthcheck.py new file mode 100644 index 0000000..a0ddc13 --- /dev/null +++ b/tests/web_api/e2e/test_healthcheck.py @@ -0,0 +1,14 @@ +from dishka import FromDishka +from httpx import AsyncClient + +from tests.web_api.ioc import inject + + +@inject +async def test_healthcheck( + http_client: FromDishka[AsyncClient], +) -> None: + response = await http_client.get("/healthcheck") + response_json = response.json() + + assert response_json["ok"] diff --git a/tests/web_api/ioc.py b/tests/web_api/ioc.py new file mode 100644 index 0000000..08a43ed --- /dev/null +++ b/tests/web_api/ioc.py @@ -0,0 +1,96 @@ +from collections.abc import Callable +from inspect import Parameter +from typing import Final + +from dishka import STRICT_VALIDATION, AsyncContainer, BaseScope, Provider, Scope, make_async_container, provide +from dishka.integrations.base import wrap_injection +from dishka.integrations.fastapi import FastapiProvider +from httpx import AsyncClient +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from template_project.web_api.configuration import ( + AccessTokenConfiguration, + Configuration, + DatabaseConfiguration, + ServerConfiguration, +) +from template_project.web_api.ioc.connection import ConnectionProvider +from template_project.web_api.ioc.cryptographer import CryptographerProvider +from template_project.web_api.ioc.data_gateway import DataGatewayProvider +from template_project.web_api.ioc.factory import FactoryProvider +from template_project.web_api.ioc.idp import IdPProvider +from template_project.web_api.ioc.interactor import InteractorProvider + + +class DatabaseClearer: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def clear(self) -> None: + await self._session.execute( + text(""" + DO $$ + DECLARE + tb text; + BEGIN + FOR tb IN ( + SELECT tablename + FROM pg_catalog.pg_tables + WHERE schemaname = 'public' + AND tablename != 'alembic_version' + ) + LOOP + EXECUTE 'TRUNCATE TABLE ' || tb || ' CASCADE'; + END LOOP; + END $$; + """), + ) + + +class TestProvider(Provider): + scope: BaseScope | None = Scope.REQUEST + + database_clearer = provide(DatabaseClearer) + + @provide + def http_client(self) -> AsyncClient: + return AsyncClient(base_url="http://web_api:8080") + + +def make_ioc(configuration: Configuration) -> AsyncContainer: + return make_async_container( + IdPProvider(), + FactoryProvider(), + FastapiProvider(), + ConnectionProvider(), + InteractorProvider(), + DataGatewayProvider(), + CryptographerProvider(), + TestProvider(), + validation_settings=STRICT_VALIDATION, + context={ + ServerConfiguration: configuration.server, + DatabaseConfiguration: configuration.database, + AccessTokenConfiguration: configuration.access_token, + }, + ) + + +CONTAINER_PARAM: Final = "dishka_container" + + +def inject[ReturnT, **FuncParams](func: Callable[FuncParams, ReturnT]) -> Callable[FuncParams, ReturnT]: + return wrap_injection( + func=func, + is_async=True, + manage_scope=True, + container_getter=lambda args, kwargs: kwargs[CONTAINER_PARAM], + additional_params=[ + Parameter( + name=CONTAINER_PARAM, + annotation=AsyncContainer, + kind=Parameter.KEYWORD_ONLY, + ), + ], + )