add tests and docker infra

This commit is contained in:
ivankirpichnikov
2025-10-17 02:21:46 +03:00
parent 31d06fc0b4
commit 45d7926af1
24 changed files with 806 additions and 24 deletions
@@ -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
@@ -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:
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+86
View File
@@ -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()
@@ -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"}
@@ -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:
@@ -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__":
@@ -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,
+9
View File
@@ -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)
+6 -1
View File
@@ -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,
@@ -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)