diff --git a/.gitignore b/.gitignore index 1f3ed39..fd9eb53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea *.iml /config.yaml +__pycache__ diff --git a/agents/implementer.md b/agents/implementer.md index fe73ce1..084e1e8 100644 --- a/agents/implementer.md +++ b/agents/implementer.md @@ -1,6 +1,6 @@ You are Implementer. -You implement EXACTLY ONE task from spec/tasks/. +You implement EXACTLY ONE next uncompleted task from spec/tasks/. Before starting, you MUST read: - spec/overview.md diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e389371 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""G2S Aggregator application package.""" diff --git a/app/adapters/__init__.py b/app/adapters/__init__.py new file mode 100644 index 0000000..65c037b --- /dev/null +++ b/app/adapters/__init__.py @@ -0,0 +1 @@ +"""External adapters.""" diff --git a/app/adapters/delivery_providers/__init__.py b/app/adapters/delivery_providers/__init__.py new file mode 100644 index 0000000..84b1a00 --- /dev/null +++ b/app/adapters/delivery_providers/__init__.py @@ -0,0 +1 @@ +"""Delivery provider adapters.""" diff --git a/app/adapters/delivery_providers/base.py b/app/adapters/delivery_providers/base.py new file mode 100644 index 0000000..2e783f0 --- /dev/null +++ b/app/adapters/delivery_providers/base.py @@ -0,0 +1,16 @@ +"""Base interface for delivery providers.""" + +from abc import ABC, abstractmethod + +from app.schemas.request import DeliveryRequest +from app.schemas.response import DeliveryPrice + + +class DeliveryProvider(ABC): + name: str + + @abstractmethod + async def get_price(self, request: DeliveryRequest) -> DeliveryPrice: + """Fetch one quote from an external provider.""" + + raise NotImplementedError diff --git a/app/adapters/delivery_providers/cdek/__init__.py b/app/adapters/delivery_providers/cdek/__init__.py new file mode 100644 index 0000000..79972df --- /dev/null +++ b/app/adapters/delivery_providers/cdek/__init__.py @@ -0,0 +1 @@ +"""CDEK adapter package.""" diff --git a/app/adapters/delivery_providers/cdek/auth.py b/app/adapters/delivery_providers/cdek/auth.py new file mode 100644 index 0000000..88ea341 --- /dev/null +++ b/app/adapters/delivery_providers/cdek/auth.py @@ -0,0 +1,6 @@ +"""CDEK auth adapter skeleton.""" + + +class CDEKAuthClient: + async def get_access_token(self) -> str: + raise NotImplementedError("CDEK auth implementation is added in task 003.") diff --git a/app/adapters/delivery_providers/cdek/client.py b/app/adapters/delivery_providers/cdek/client.py new file mode 100644 index 0000000..c212ab3 --- /dev/null +++ b/app/adapters/delivery_providers/cdek/client.py @@ -0,0 +1,9 @@ +"""CDEK HTTP client adapter skeleton.""" + +from app.schemas.request import DeliveryRequest + + +class CDEKClient: + async def get_raw_price(self, request: DeliveryRequest) -> dict: + _ = request + raise NotImplementedError("CDEK client implementation is added in task 003.") diff --git a/app/adapters/delivery_providers/cdek/mapper.py b/app/adapters/delivery_providers/cdek/mapper.py new file mode 100644 index 0000000..8542b90 --- /dev/null +++ b/app/adapters/delivery_providers/cdek/mapper.py @@ -0,0 +1,8 @@ +"""CDEK response mapper skeleton.""" + +from app.schemas.response import DeliveryPrice + + +def map_cdek_response(payload: dict) -> DeliveryPrice: + _ = payload + raise NotImplementedError("CDEK mapper implementation is added in task 003.") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..b54daee --- /dev/null +++ b/app/config.py @@ -0,0 +1,93 @@ +"""Application configuration split by architecture component.""" + +import os +from functools import lru_cache + +from pydantic import BaseModel, Field +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + SettingsConfigDict, + YamlConfigSettingsSource, +) + +DEFAULT_CONFIG_FILE = "config.yaml" +CONFIG_FILE_ENV = "G2S_CONFIG_FILE" + + +class ControllerConfig(BaseModel): + api_prefix: str = "/api/v1" + request_id_header: str = "X-Request-ID" + + +class ServiceConfig(BaseModel): + provider_timeout_seconds: float = 10.0 + max_parallel_providers: int = 8 + + +class BusinessLogicConfig(BaseModel): + weight_round_scale: int = 2 + + +class RepositoryConfig(BaseModel): + redis_dsn: str = "redis://localhost:6379/0" + price_cache_ttl_seconds: int = 900 + + +class AdapterConfig(BaseModel): + cdek_base_url: str = "https://api.cdek.ru/v2" + cdek_timeout_seconds: float = 10.0 + + +class ObservabilityConfig(BaseModel): + service_name: str = "g2s-aggregator" + otlp_endpoint: str = "http://localhost:4317" + log_level: str = "INFO" + + +class AlertsConfig(BaseModel): + telegram_enabled: bool = False + telegram_bot_token: str | None = None + telegram_chat_id: str | None = None + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="G2S_", + env_nested_delimiter="__", + extra="ignore", + ) + + controller: ControllerConfig = Field(default_factory=ControllerConfig) + service: ServiceConfig = Field(default_factory=ServiceConfig) + business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig) + repository: RepositoryConfig = Field(default_factory=RepositoryConfig) + adapter: AdapterConfig = Field(default_factory=AdapterConfig) + observability: ObservabilityConfig = Field(default_factory=ObservabilityConfig) + alerts: AlertsConfig = Field(default_factory=AlertsConfig) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + yaml_source = YamlConfigSettingsSource( + settings_cls, + yaml_file=os.getenv(CONFIG_FILE_ENV, DEFAULT_CONFIG_FILE), + ) + return ( + init_settings, + env_settings, + dotenv_settings, + yaml_source, + file_secret_settings, + ) + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() diff --git a/app/controllers/__init__.py b/app/controllers/__init__.py new file mode 100644 index 0000000..4d266b5 --- /dev/null +++ b/app/controllers/__init__.py @@ -0,0 +1 @@ +"""HTTP layer components.""" diff --git a/app/controllers/http_client.py b/app/controllers/http_client.py new file mode 100644 index 0000000..4846ce6 --- /dev/null +++ b/app/controllers/http_client.py @@ -0,0 +1,7 @@ +"""Controller-level HTTP client factory.""" + +from httpx import AsyncClient + + +def build_controller_http_client(timeout_seconds: float) -> AsyncClient: + return AsyncClient(timeout=timeout_seconds) diff --git a/app/controllers/middleware.py b/app/controllers/middleware.py new file mode 100644 index 0000000..baade96 --- /dev/null +++ b/app/controllers/middleware.py @@ -0,0 +1,10 @@ +"""HTTP middleware registration.""" + +from fastapi import FastAPI + + +def install_middleware(app: FastAPI) -> None: + """Register middleware components for the API.""" + + # Middleware stack is introduced in later tasks. + _ = app diff --git a/app/controllers/v1/__init__.py b/app/controllers/v1/__init__.py new file mode 100644 index 0000000..8ab25ad --- /dev/null +++ b/app/controllers/v1/__init__.py @@ -0,0 +1 @@ +"""Versioned API controllers.""" diff --git a/app/controllers/v1/delivery.py b/app/controllers/v1/delivery.py new file mode 100644 index 0000000..6635b7f --- /dev/null +++ b/app/controllers/v1/delivery.py @@ -0,0 +1,5 @@ +"""Delivery API controller skeleton.""" + +from fastapi import APIRouter + +router = APIRouter(prefix="/delivery", tags=["delivery"]) diff --git a/app/domain/__init__.py b/app/domain/__init__.py new file mode 100644 index 0000000..4f67731 --- /dev/null +++ b/app/domain/__init__.py @@ -0,0 +1 @@ +"""Pure business logic layer.""" diff --git a/app/domain/quotes.py b/app/domain/quotes.py new file mode 100644 index 0000000..db5791f --- /dev/null +++ b/app/domain/quotes.py @@ -0,0 +1,4 @@ +"""Business rules for delivery quotes. + +Rules are implemented in task 002. +""" diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f823766 --- /dev/null +++ b/app/main.py @@ -0,0 +1,21 @@ +"""FastAPI application entrypoint.""" + +from fastapi import FastAPI + +from app.config import Settings, get_settings +from app.controllers.middleware import install_middleware +from app.controllers.v1.delivery import router as delivery_router + + +def create_app(settings: Settings | None = None) -> FastAPI: + resolved_settings = settings or get_settings() + + application = FastAPI(title="G2S Aggregator", version="0.1.0") + application.state.settings = resolved_settings + + install_middleware(application) + application.include_router(delivery_router, prefix=resolved_settings.controller.api_prefix) + return application + + +app = create_app() diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..4e2fa5e --- /dev/null +++ b/app/repositories/__init__.py @@ -0,0 +1 @@ +"""Repository layer.""" diff --git a/app/repositories/cache/__init__.py b/app/repositories/cache/__init__.py new file mode 100644 index 0000000..b67fe1b --- /dev/null +++ b/app/repositories/cache/__init__.py @@ -0,0 +1 @@ +"""Cache repositories.""" diff --git a/app/repositories/cache/redis_cache.py b/app/repositories/cache/redis_cache.py new file mode 100644 index 0000000..b7d8907 --- /dev/null +++ b/app/repositories/cache/redis_cache.py @@ -0,0 +1,17 @@ +"""Redis cache repository skeleton.""" + +from typing import Any + + +class PriceCache: + async def get(self, key: str) -> Any | None: + _ = key + raise NotImplementedError("Redis repository implementation is added in task 004.") + + async def set(self, key: str, value: Any, ttl: int) -> None: + _ = (key, value, ttl) + raise NotImplementedError("Redis repository implementation is added in task 004.") + + async def invalidate(self, key: str) -> None: + _ = key + raise NotImplementedError("Redis repository implementation is added in task 004.") diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..9730217 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Shared API and domain schemas.""" diff --git a/app/schemas/request.py b/app/schemas/request.py new file mode 100644 index 0000000..193b6c3 --- /dev/null +++ b/app/schemas/request.py @@ -0,0 +1,20 @@ +"""Input schemas for price calculation.""" + +from enum import StrEnum + +from pydantic import BaseModel, Field + + +class DeliveryEntity(StrEnum): + INDIVIDUAL = "individual" + LEGAL = "legal" + + +class DeliveryRequest(BaseModel): + entity: DeliveryEntity + from_city: str = Field(min_length=1) + to_city: str = Field(min_length=1) + weight_kg: float = Field(gt=0) + length_cm: float = Field(gt=0) + width_cm: float = Field(gt=0) + height_cm: float = Field(gt=0) diff --git a/app/schemas/response.py b/app/schemas/response.py new file mode 100644 index 0000000..6b4b8f4 --- /dev/null +++ b/app/schemas/response.py @@ -0,0 +1,14 @@ +"""Output schemas for aggregated prices.""" + +from decimal import Decimal + +from pydantic import BaseModel, Field + + +class DeliveryPrice(BaseModel): + provider: str = Field(min_length=1) + service_name: str = Field(min_length=1) + price: Decimal = Field(gt=0) + currency: str = Field(min_length=3, max_length=3) + delivery_days_min: int = Field(ge=0) + delivery_days_max: int = Field(ge=0) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..9415f6b --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Application service layer.""" diff --git a/app/services/aggregator.py b/app/services/aggregator.py new file mode 100644 index 0000000..2a309f2 --- /dev/null +++ b/app/services/aggregator.py @@ -0,0 +1,10 @@ +"""Aggregator service orchestrator skeleton.""" + +from app.schemas.request import DeliveryRequest +from app.schemas.response import DeliveryPrice + + +class AggregatorService: + async def get_all_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]: + _ = request + raise NotImplementedError("Aggregator workflow is implemented in task 005.") diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..91536fa --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,28 @@ +controller: + api_prefix: "/api/v1" + request_id_header: "X-Request-ID" + +service: + provider_timeout_seconds: 10.0 + max_parallel_providers: 8 + +business_logic: + weight_round_scale: 2 + +repository: + redis_dsn: "redis://localhost:6379/0" + price_cache_ttl_seconds: 900 + +adapter: + cdek_base_url: "https://api.cdek.ru/v2" + cdek_timeout_seconds: 10.0 + +observability: + service_name: "g2s-aggregator" + otlp_endpoint: "http://localhost:4317" + log_level: "INFO" + +alerts: + telegram_enabled: false + telegram_bot_token: null + telegram_chat_id: null diff --git a/spec/index.md b/spec/index.md index d8c67d8..f9c9f22 100644 --- a/spec/index.md +++ b/spec/index.md @@ -5,18 +5,18 @@ ## Tasks -| ID | Status | Created | Title | File | -|---:|:------:|:-------:|:------|:-----| -| 000 | DONE | 2026-02-01 | Task format reference | `spec/tasks/000_template.md` | +| ID | Status | Created | Title | File | +|---:|:------:|:-------:|:------------------------------------------------|:-----| +| 000 | DONE | 2026-02-01 | Task format reference | `spec/tasks/000_template.md` | | 001 | TODO | 2026-03-07 | Create app skeleton and component configuration | `spec/tasks/001_create_app_skeleton_and_config.md` | -| 002 | TODO | 2026-03-07 | Implement pure domain quote rules | `spec/tasks/002_implement_pure_domain_quote_rules.md` | -| 003 | TODO | 2026-03-07 | Add CDEK provider adapter | `spec/tasks/003_add_cdek_provider_adapter.md` | -| 004 | TODO | 2026-03-07 | Add Redis price cache repository | `spec/tasks/004_add_redis_price_cache_repository.md` | -| 005 | TODO | 2026-03-07 | Implement aggregator service workflow | `spec/tasks/005_implement_aggregator_service_workflow.md` | -| 006 | TODO | 2026-03-07 | Add delivery price controller endpoint | `spec/tasks/006_add_delivery_price_controller.md` | -| 007 | TODO | 2026-03-07 | Add observability correlation and telemetry | `spec/tasks/007_add_observability_correlation.md` | -| 008 | TODO | 2026-03-07 | Add SigNoz to Telegram alerting configuration | `spec/tasks/008_add_signoz_telegram_alerting.md` | -| 009 | TODO | 2026-03-07 | Add local infrastructure stack | `spec/tasks/009_add_local_infra_stack.md` | +| 002 | TODO | 2026-03-07 | Implement pure domain price rules | `spec/tasks/002_implement_pure_domain_quote_rules.md` | +| 003 | TODO | 2026-03-07 | Add CDEK provider adapter | `spec/tasks/003_add_cdek_provider_adapter.md` | +| 004 | TODO | 2026-03-07 | Add Redis price cache repository | `spec/tasks/004_add_redis_price_cache_repository.md` | +| 005 | TODO | 2026-03-07 | Implement aggregator service workflow | `spec/tasks/005_implement_aggregator_service_workflow.md` | +| 006 | TODO | 2026-03-07 | Add delivery price controller endpoint | `spec/tasks/006_add_delivery_price_controller.md` | +| 007 | TODO | 2026-03-07 | Add observability correlation and telemetry | `spec/tasks/007_add_observability_correlation.md` | +| 008 | TODO | 2026-03-07 | Add SigNoz to Telegram alerting configuration | `spec/tasks/008_add_signoz_telegram_alerting.md` | +| 009 | TODO | 2026-03-07 | Add local infrastructure stack | `spec/tasks/009_add_local_infra_stack.md` | ## Summary diff --git a/spec/overview.md b/spec/overview.md index aebb166..42b8728 100644 --- a/spec/overview.md +++ b/spec/overview.md @@ -149,7 +149,7 @@ app/ ├── services/ │ └── aggregator.py # Service ├── domain/ -│ └── quotes.py # Business Logic +│ └── price.py # Business Logic ├── adapters/ │ └── delivery_providers/ │ ├── base.py # Интерфейс Adapter diff --git a/spec/tasks/002_implement_pure_domain_quote_rules.md b/spec/tasks/002_implement_pure_domain_quote_rules.md index a890f03..ea98125 100644 --- a/spec/tasks/002_implement_pure_domain_quote_rules.md +++ b/spec/tasks/002_implement_pure_domain_quote_rules.md @@ -1,6 +1,6 @@ --- id: 002 -title: Implement pure domain quote rules +title: Implement pure domain price rules status: TODO created: 2026-03-07 --- @@ -9,7 +9,7 @@ created: 2026-03-07 `spec/overview.md` требует business rules для фильтрации тарифов, сравнения цен, сортировки и нормализации входных данных в чистом слое Business Logic. ## Goal -Реализовать детерминированные, dependency-free domain functions в `app/domain/quotes.py` для нормализации запроса и фильтрации/упорядочивания котировок. +Реализовать детерминированные, dependency-free domain functions в `app/domain/price.py` для нормализации запроса и фильтрации/упорядочивания котировок. ## Constraints - Business Logic должна быть pure и dependency-free. @@ -30,8 +30,8 @@ created: 2026-03-07 - [ ] Unit tests (без mocks) покрывают стандартные и edge cases. ## Tests -- Добавить `tests/domain/test_quotes.py` только с unit tests. -- Покрыть edge cases: пустой input, невалидные quotes, одинаковые цены, границы нормализации. +- Добавить `tests/domain/test_price.py` только с unit tests. +- Покрыть edge cases: пустой input, невалидные price, одинаковые цены, границы нормализации. ## Commands -- `poetry run pytest tests/domain/test_quotes.py -q` +- `poetry run pytest tests/domain/test_price.py -q` diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py new file mode 100644 index 0000000..e8dc6af --- /dev/null +++ b/tests/config/test_config_sections.py @@ -0,0 +1,68 @@ +from app.config import Settings, get_settings + + +def test_configuration_sections_are_loaded_from_env(monkeypatch) -> None: + monkeypatch.setenv("G2S_CONTROLLER__API_PREFIX", "/internal/v1") + monkeypatch.setenv("G2S_SERVICE__MAX_PARALLEL_PROVIDERS", "12") + monkeypatch.setenv("G2S_BUSINESS_LOGIC__WEIGHT_ROUND_SCALE", "3") + monkeypatch.setenv("G2S_REPOSITORY__PRICE_CACHE_TTL_SECONDS", "1200") + monkeypatch.setenv("G2S_ADAPTER__CDEK_BASE_URL", "https://sandbox.cdek.test") + monkeypatch.setenv("G2S_OBSERVABILITY__SERVICE_NAME", "g2s-test") + monkeypatch.setenv("G2S_ALERTS__TELEGRAM_ENABLED", "true") + + settings = Settings() + + assert settings.controller.api_prefix == "/internal/v1" + assert settings.service.max_parallel_providers == 12 + assert settings.business_logic.weight_round_scale == 3 + assert settings.repository.price_cache_ttl_seconds == 1200 + assert settings.adapter.cdek_base_url == "https://sandbox.cdek.test" + assert settings.observability.service_name == "g2s-test" + assert settings.alerts.telegram_enabled is True + + +def test_configuration_sections_are_loaded_from_yaml_file(tmp_path, monkeypatch) -> None: + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ +controller: + api_prefix: "/yaml/v1" +service: + max_parallel_providers: 11 +business_logic: + weight_round_scale: 4 +repository: + price_cache_ttl_seconds: 600 +adapter: + cdek_base_url: "https://yaml.cdek.test" +observability: + service_name: "g2s-yaml" +alerts: + telegram_enabled: true +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("G2S_CONFIG_FILE", str(config_file)) + + settings = Settings() + + assert settings.controller.api_prefix == "/yaml/v1" + assert settings.service.max_parallel_providers == 11 + assert settings.business_logic.weight_round_scale == 4 + assert settings.repository.price_cache_ttl_seconds == 600 + assert settings.adapter.cdek_base_url == "https://yaml.cdek.test" + assert settings.observability.service_name == "g2s-yaml" + assert settings.alerts.telegram_enabled is True + + +def test_get_settings_returns_cached_instance(monkeypatch) -> None: + get_settings.cache_clear() + monkeypatch.setenv("G2S_SERVICE__PROVIDER_TIMEOUT_SECONDS", "22") + + first = get_settings() + second = get_settings() + + assert first is second + assert first.service.provider_timeout_seconds == 22 + + get_settings.cache_clear() diff --git a/tests/smoke/test_app_import.py b/tests/smoke/test_app_import.py new file mode 100644 index 0000000..5c62753 --- /dev/null +++ b/tests/smoke/test_app_import.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI + +from app.config import Settings +from app.main import app, create_app + + +def test_app_import_smoke() -> None: + assert isinstance(app, FastAPI) + assert isinstance(app.state.settings, Settings) + + +def test_app_created_with_provided_settings() -> None: + settings = Settings() + instance = create_app(settings=settings) + + assert instance.state.settings is settings