From 44893e01aee1024668ecf5a69b1b45cba2b4438d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=B0=D0=B8=D1=81=20=D0=AE=D1=81=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=B2?= Date: Sun, 29 Mar 2026 13:51:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20yandex=20geosuggest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yandex_geosuggest/__init__.py | 13 + .../yandex_geosuggest/client.py | 227 +++++++++++++++ app/config.py | 9 + app/controllers/v1/delivery.py | 9 +- config.example.yaml | 21 ++ config.test.yaml | 11 + spec/gen_spec_index.py | 3 - spec/index.md | 7 +- spec/overview.md | 10 +- ...x_geosuggest_address_suggestion_adapter.md | 55 ++++ .../yandex_geosuggest/test_client.py | 266 ++++++++++++++++++ tests/config/fixtures/config.default.yaml | 5 + .../config/fixtures/config.test.override.yaml | 6 +- tests/config/test_config_sections.py | 20 +- .../v1/test_address_suggestions.py | 84 ++++-- tests/services/test_address_suggestions.py | 53 +++- 16 files changed, 750 insertions(+), 49 deletions(-) create mode 100644 app/adapters/address_suggestions/yandex_geosuggest/__init__.py create mode 100644 app/adapters/address_suggestions/yandex_geosuggest/client.py create mode 100644 spec/tasks/023_add_yandex_geosuggest_address_suggestion_adapter.md create mode 100644 tests/adapters/address_suggestions/yandex_geosuggest/test_client.py diff --git a/app/adapters/address_suggestions/yandex_geosuggest/__init__.py b/app/adapters/address_suggestions/yandex_geosuggest/__init__.py new file mode 100644 index 0000000..a0c24cf --- /dev/null +++ b/app/adapters/address_suggestions/yandex_geosuggest/__init__.py @@ -0,0 +1,13 @@ +"""Yandex Geosuggest address suggestion adapter package.""" + +from app.adapters.address_suggestions.yandex_geosuggest.client import ( + YandexGeosuggestAddressSuggestionProvider, + YandexGeosuggestClientError, + YandexGeosuggestRequestError, +) + +__all__ = [ + "YandexGeosuggestAddressSuggestionProvider", + "YandexGeosuggestClientError", + "YandexGeosuggestRequestError", +] diff --git a/app/adapters/address_suggestions/yandex_geosuggest/client.py b/app/adapters/address_suggestions/yandex_geosuggest/client.py new file mode 100644 index 0000000..dc0065b --- /dev/null +++ b/app/adapters/address_suggestions/yandex_geosuggest/client.py @@ -0,0 +1,227 @@ +"""Yandex Geosuggest address suggestion adapter.""" + +from typing import Any + +import httpx + +from app.adapters.address_suggestions.base import ( + AddressSuggestionClientError, + AddressSuggestionProvider, + AddressSuggestionRequestError, +) +from app.config import YandexGeosuggestAddressSuggestionsConfig +from app.schemas.request import AddressSuggestRequest +from app.schemas.response import AddressSuggestion + +_STREET_KINDS = frozenset({"street"}) +_HOUSE_KINDS = frozenset({"house"}) +_FLAT_KINDS = frozenset({"apartment", "flat"}) + + +class YandexGeosuggestClientError(AddressSuggestionClientError): + """Raised when Yandex Geosuggest requests fail.""" + + +class YandexGeosuggestRequestError( + YandexGeosuggestClientError, + AddressSuggestionRequestError, +): + """Raised when Yandex Geosuggest rejects request parameters.""" + + +class YandexGeosuggestAddressSuggestionProvider(AddressSuggestionProvider): + name = "yandex_geosuggest" + + def __init__( + self, + http_client: httpx.AsyncClient, + *, + url: str, + api_key: str, + timeout_seconds: float = 10.0, + ) -> None: + self._http_client = http_client + self._url = url + self._api_key = api_key + self._timeout_seconds = timeout_seconds + + @classmethod + def from_config( + cls, + *, + http_client: httpx.AsyncClient, + config: YandexGeosuggestAddressSuggestionsConfig, + ) -> "YandexGeosuggestAddressSuggestionProvider": + return cls( + http_client=http_client, + url=config.url, + api_key=config.api_key, + timeout_seconds=config.timeout_seconds, + ) + + async def suggest(self, request: AddressSuggestRequest) -> list[AddressSuggestion]: + params = self._build_query_params(request) + try: + response = await self._http_client.get( + self._url, + params=params, + timeout=self._timeout_seconds, + ) + except (httpx.TimeoutException, httpx.TransportError) as exc: + raise YandexGeosuggestClientError( + "Yandex Geosuggest request failed." + ) from exc + + if response.status_code == 400: + raise YandexGeosuggestRequestError( + "Yandex Geosuggest request was rejected with status 400." + ) + + if response.status_code == 403 or response.status_code == 429: + raise YandexGeosuggestClientError( + "Yandex Geosuggest request failed with status " + f"{response.status_code}." + ) + + if response.status_code >= 500 or 400 <= response.status_code < 500: + raise YandexGeosuggestClientError( + "Yandex Geosuggest request failed with status " + f"{response.status_code}." + ) + + try: + response.raise_for_status() + raw_payload = response.json() + except (httpx.HTTPError, TypeError, ValueError) as exc: + raise YandexGeosuggestClientError( + "Yandex Geosuggest returned invalid payload." + ) from exc + + try: + return _map_yandex_geosuggest_response(raw_payload) + except ValueError as exc: + raise YandexGeosuggestClientError( + "Yandex Geosuggest response payload is invalid." + ) from exc + + def _build_query_params(self, request: AddressSuggestRequest) -> dict[str, Any]: + params: dict[str, Any] = { + "apikey": self._api_key, + "text": f"{request.city.strip()} {request.query.strip()}".strip(), + "print_address": 1, + } + if request.limit is not None: + params["results"] = request.limit + return params + + +def _map_yandex_geosuggest_response(payload: object) -> list[AddressSuggestion]: + if not isinstance(payload, dict): + raise ValueError("Yandex Geosuggest response must be a JSON object.") + + raw_results = payload.get("results") + if raw_results is None: + return [] + if not isinstance(raw_results, list): + raise ValueError("Yandex Geosuggest response must include results list.") + + return [_map_yandex_geosuggest_result(item) for item in raw_results] + + +def _map_yandex_geosuggest_result(payload: object) -> AddressSuggestion: + if not isinstance(payload, dict): + raise ValueError("Yandex Geosuggest result entry must be an object.") + + raw_address_payload = payload.get("address") + if raw_address_payload is not None and not isinstance(raw_address_payload, dict): + raise ValueError("Yandex Geosuggest address payload must be an object.") + + address_payload = raw_address_payload if isinstance(raw_address_payload, dict) else {} + raw_components = address_payload.get("component") + if raw_components is None: + components: list[object] = [] + elif isinstance(raw_components, list): + components = raw_components + else: + raise ValueError("Yandex Geosuggest address components must be a list.") + + address = _extract_formatted_address(address_payload) or _extract_title_text(payload) + if address is None: + raise ValueError("Yandex Geosuggest result has no valid address value.") + + return AddressSuggestion( + address=address, + street=_extract_component_value(components, _STREET_KINDS), + house=_extract_component_value(components, _HOUSE_KINDS), + flat=_extract_component_value(components, _FLAT_KINDS), + postal_code=None, + ) + + +def _extract_formatted_address(payload: dict[str, Any]) -> str | None: + value = payload.get("formatted_address") + return _coerce_optional_text(value) + + +def _extract_title_text(payload: dict[str, Any]) -> str | None: + raw_title = payload.get("title") + if raw_title is None: + return None + if not isinstance(raw_title, dict): + raise ValueError("Yandex Geosuggest title payload must be an object.") + return _coerce_optional_text(raw_title.get("text")) + + +def _extract_component_value( + components: list[object], + expected_kinds: frozenset[str], +) -> str | None: + for component in components: + if not isinstance(component, dict): + raise ValueError( + "Yandex Geosuggest address component entry must be an object." + ) + + if not _component_has_kind(component, expected_kinds): + continue + + raw_name = component.get("name") + if raw_name is None: + return None + if not isinstance(raw_name, str): + raise ValueError( + "Yandex Geosuggest address component name must be a string." + ) + return raw_name.strip() or None + + return None + + +def _component_has_kind(component: dict[str, Any], expected_kinds: frozenset[str]) -> bool: + raw_kind = component.get("kind") + if isinstance(raw_kind, str): + kinds = [raw_kind] + elif isinstance(raw_kind, list): + kinds = raw_kind + else: + raise ValueError("Yandex Geosuggest address component kind must be provided.") + + normalized_kinds: set[str] = set() + for kind in kinds: + if not isinstance(kind, str): + raise ValueError( + "Yandex Geosuggest address component kind must be a string." + ) + normalized_kind = kind.strip().lower() + if normalized_kind: + normalized_kinds.add(normalized_kind) + + return not expected_kinds.isdisjoint(normalized_kinds) + + +def _coerce_optional_text(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise ValueError("Yandex Geosuggest text field must be a string.") + return value.strip() or None diff --git a/app/config.py b/app/config.py index 122115f..a8b115b 100644 --- a/app/config.py +++ b/app/config.py @@ -54,11 +54,20 @@ class DadataAddressSuggestionsConfig(BaseModel): timeout_seconds: float = Field(default=10.0, gt=0) +class YandexGeosuggestAddressSuggestionsConfig(BaseModel): + url: str = "https://suggest-maps.yandex.ru/v1/suggest" + api_key: str = "" + timeout_seconds: float = Field(default=10.0, gt=0) + + class AddressSuggestionsConfig(BaseModel): country_to_provider: dict[str, str] = Field(default_factory=dict) dadata: DadataAddressSuggestionsConfig = Field( default_factory=DadataAddressSuggestionsConfig ) + yandex_geosuggest: YandexGeosuggestAddressSuggestionsConfig = Field( + default_factory=YandexGeosuggestAddressSuggestionsConfig + ) @field_validator("country_to_provider") @classmethod diff --git a/app/controllers/v1/delivery.py b/app/controllers/v1/delivery.py index c8c5611..524ff09 100644 --- a/app/controllers/v1/delivery.py +++ b/app/controllers/v1/delivery.py @@ -3,6 +3,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from app.adapters.address_suggestions.dadata import DadataAddressSuggestionProvider +from app.adapters.address_suggestions.yandex_geosuggest import ( + YandexGeosuggestAddressSuggestionProvider, +) from app.adapters.delivery_providers.cdek import CDEKProvider from app.config import Settings from app.controllers.http_client import build_controller_http_client @@ -38,13 +41,17 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService: http_client=http_client, config=settings.address_suggestions.dadata, ) + yandex_geosuggest_provider = YandexGeosuggestAddressSuggestionProvider.from_config( + http_client=http_client, + config=settings.address_suggestions.yandex_geosuggest, + ) providers = (cdek_provider,) cache = PriceCache.from_repository_config(settings.repository) service = AggregatorService( providers=providers, cache=cache, order_adapter=cdek_provider, - address_suggestion_providers=(dadata_provider,), + address_suggestion_providers=(dadata_provider, yandex_geosuggest_provider), address_suggestion_country_to_provider=( settings.address_suggestions.country_to_provider ), diff --git a/config.example.yaml b/config.example.yaml index 6da1b00..6e286d4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -23,6 +23,27 @@ adapter: cdek_timeout_seconds: 10.0 cdek_cache_ttl_seconds: 900 +address_suggestions: + country_to_provider: + RU: "dadata" + BY: "dadata" + KZ: "dadata" + AM: "yandex_geosuggest" + AZ: "yandex_geosuggest" + KG: "yandex_geosuggest" + MD: "yandex_geosuggest" + TJ: "yandex_geosuggest" + TM: "yandex_geosuggest" + UZ: "yandex_geosuggest" + dadata: + url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + api_key: "" + timeout_seconds: 10.0 + yandex_geosuggest: + url: "https://suggest-maps.yandex.ru/v1/suggest" + api_key: "" + timeout_seconds: 10.0 + observability: enabled: false service_name: "g2s-aggregator" diff --git a/config.test.yaml b/config.test.yaml index b347ce3..59c8d92 100644 --- a/config.test.yaml +++ b/config.test.yaml @@ -28,10 +28,21 @@ address_suggestions: RU: "dadata" BY: "dadata" KZ: "dadata" + AM: "yandex_geosuggest" + AZ: "yandex_geosuggest" + KG: "yandex_geosuggest" + MD: "yandex_geosuggest" + TJ: "yandex_geosuggest" + TM: "yandex_geosuggest" + UZ: "yandex_geosuggest" dadata: url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" api_key: "test-dadata-api-key" timeout_seconds: 7.5 + yandex_geosuggest: + url: "https://suggest-maps.yandex.ru/v1/suggest" + api_key: "test-yandex-geosuggest-api-key" + timeout_seconds: 6.5 observability: enabled: false diff --git a/spec/gen_spec_index.py b/spec/gen_spec_index.py index f505b90..08d1c90 100644 --- a/spec/gen_spec_index.py +++ b/spec/gen_spec_index.py @@ -176,13 +176,10 @@ def load_tasks() -> List[TaskMeta]: def render_index(tasks: List[TaskMeta]) -> str: - generated_at = dt.datetime.now(tz=dt.timezone.utc).replace(microsecond=0).isoformat() - lines: List[str] = [] lines.append("# Spec Tasks Index") lines.append("") lines.append("> ⚠️ This file is generated. Do not edit manually.") - lines.append(f"> Generated at (UTC): `{generated_at}`") lines.append("") lines.append("## Tasks") lines.append("") diff --git a/spec/index.md b/spec/index.md index 8038146..0e43a27 100644 --- a/spec/index.md +++ b/spec/index.md @@ -1,7 +1,7 @@ # Spec Tasks Index > ⚠️ This file is generated. Do not edit manually. -> Generated at (UTC): `2026-03-28T21:02:29+00:00` +> Generated at (UTC): `2026-03-29T15:00:18+00:00` ## Tasks @@ -30,9 +30,10 @@ | 020 | DONE | 2026-03-21 | Rename price request model and use cities_map for CDEK codes | `spec/tasks/020_rename_price_request_model_and_use_cities_map_for_cdek_codes.md` | | 021 | DONE | 2026-03-25 | Add address suggestion adapter and country provider mapping | `spec/tasks/021_add_address_suggestion_adapter_and_country_mapping.md` | | 022 | DONE | 2026-03-25 | Add address suggestion endpoint | `spec/tasks/022_add_address_suggestion_endpoint.md` | +| 023 | DONE | 2026-03-29 | Add Yandex Geosuggest address suggestion adapter and CIS routing | `spec/tasks/023_add_yandex_geosuggest_address_suggestion_adapter.md` | ## Summary -- Total: **23** +- Total: **24** - TODO: **1** -- DONE: **22** +- DONE: **23** diff --git a/spec/overview.md b/spec/overview.md index e75262f..404a608 100644 --- a/spec/overview.md +++ b/spec/overview.md @@ -20,7 +20,9 @@ - Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед созданием заказа - Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери" - Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге -- Для стран, сопоставленных с provider id `dadata`, использовать `dadata.ru`; конфигурация и wiring должны допускать отдельный address suggestion provider для европейских стран +- Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru` +- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest +- Конфигурация и wiring должны допускать отдельные address suggestion providers для других регионов - Опрашивать всех зарегистрированных провайдеров параллельно - Возвращать унифицированный список тарифов, отсортированных по цене - Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком @@ -103,9 +105,11 @@ async def suggest(self, request: AddressSuggestRequest) -> list[AddressSuggestion]: ... ``` - `dadata/client.py` — HTTP-клиент `dadata.ru` для address suggestions +- `yandex_geosuggest/client.py` — HTTP-клиент Yandex Geosuggest `GET https://suggest-maps.yandex.ru/v1/suggest` - provider-specific модули address suggestion adapters инкапсулируют внешние API-контракты, auth, serialization и error handling - В конфиге adapter layer хранится маппинг `country_code -> provider_id` для выбора address suggestion provider - Address suggestion adapters возвращают только унифицированные internal models без утечки provider-specific payload в Service +- Для Yandex Geosuggest adapter возвращает `postal_code=None`, так как контракт Geosuggest не используется как источник почтового индекса --- @@ -222,7 +226,9 @@ app/ ├── adapters/ │ ├── address_suggestions/ │ │ ├── base.py # Интерфейс Adapter -│ │ └── dadata/ +│ │ ├── dadata/ +│ │ └── client.py +│ │ └── yandex_geosuggest/ │ │ └── client.py │ └── delivery_providers/ │ ├── base.py # Интерфейс Adapter diff --git a/spec/tasks/023_add_yandex_geosuggest_address_suggestion_adapter.md b/spec/tasks/023_add_yandex_geosuggest_address_suggestion_adapter.md new file mode 100644 index 0000000..a71d454 --- /dev/null +++ b/spec/tasks/023_add_yandex_geosuggest_address_suggestion_adapter.md @@ -0,0 +1,55 @@ +--- +id: 023 +title: Add Yandex Geosuggest address suggestion adapter and CIS routing +status: DONE +created: 2026-03-29 +--- + +## Context +Текущий flow подсказок адреса поддерживает только `dadata` и уже использует routing по `country_code` через YAML-конфиг. Появилось новое требование: для части стран СНГ использовать отдельный provider `Yandex Geosuggest`, чтобы разгрузить `dadata` и зафиксировать routing по странам на уровне конфигурации. + +## Goal +Добавить новый adapter `yandex_geosuggest` для address suggestions, настроить routing по `country_code` так, чтобы `RU`, `BY` и `KZ` оставались на `dadata`, а `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ` шли через `Yandex Geosuggest`, и покрыть это тестами без изменения публичного API endpoint. + +## Constraints +- Scope задачи ограничен flow подсказок адреса: YAML-конфиг, wiring зависимостей, service routing, новый adapter и тесты. +- Controller path и публичный контракт `POST /api/v1/delivery/suggest-address` не изменять. +- Service остаётся orchestration layer: выбирает provider по injected config mapping и вызывает ровно один adapter без provider-specific HTTP-логики. +- Внешний IO должен оставаться только внутри нового adapter `app/adapters/address_suggestions/yandex_geosuggest/`. +- Для `RU`, `BY` и `KZ` existing mapping на `dadata` должен сохраниться без изменения контракта `dadata` adapter. +- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ` конфиг должен выбирать provider id `yandex_geosuggest`. +- Новый adapter должен следовать официальному HTTP-контракту Yandex Geosuggest: `GET https://suggest-maps.yandex.ru/v1/suggest`, обязательные query-параметры `apikey` и `text`; для полного адреса использовать `print_address=1`; если в internal request передан `limit`, он должен маппиться в query-параметр `results`. +- Unified mapping наружу должен по-прежнему возвращать только `AddressSuggestion` без утечки provider-specific payload. +- Для Yandex Geosuggest поле `postal_code` не извлекается из provider response и должно возвращаться как `None`. +- Ошибки Yandex Geosuggest `400` должны маппиться в deterministic provider request error; `403`, `429`, transport errors и `5xx` должны маппиться в adapter client error/unavailable path. +- Scope задачи не включает добавление европейского provider, изменение order flow, price flow, новых endpoint'ов и расширение internal model `AddressSuggestion`. +- Не изменять файлы в `spec/`. + +## Acceptance criteria +- В конфиге address suggestion providers добавлен provider id `yandex_geosuggest` с параметрами, необходимыми для вызова Yandex Geosuggest. +- Country mapping в конфиге маршрутизирует `RU`, `BY`, `KZ` на `dadata`, а `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` на `yandex_geosuggest`. +- Реализован adapter `app/adapters/address_suggestions/yandex_geosuggest/client.py`, который отправляет запрос в Yandex Geosuggest по официальному контракту и возвращает `list[AddressSuggestion]`. +- Adapter использует `request.city` и `request.query` как значение `text`, передаёт `print_address=1`, а `request.limit` при наличии маппит в `results`. +- Поля `address`, `street`, `house` и `flat` детерминированно извлекаются из ответа Yandex Geosuggest без утечки внешнего payload за пределы adapter contract, а `postal_code` возвращается как `None`. +- `AggregatorService.suggest_addresses()` по `country_code` выбирает `yandex_geosuggest` для стран `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` и сохраняет routing на `dadata` для `RU`, `BY`, `KZ`. +- При ответе Yandex Geosuggest с `400` service flow возвращает deterministic bad-request path, а при `403`, `429`, `5xx` и transport failure — deterministic unavailable path. + +## Definition of Done +- [ ] Добавлена конфигурация `yandex_geosuggest` и обновлён mapping стран для address suggestions. +- [ ] Реализован новый Yandex Geosuggest adapter без утечки HTTP-деталей в Service. +- [ ] Обновлён wiring address suggestion providers без изменения публичного endpoint контракта. +- [ ] Добавлены tests для config, adapter mapping/error handling и service routing по странам СНГ, включая `postal_code=None` для Yandex Geosuggest. +- [ ] Пройдены все команды из раздела Commands. + +## Tests +- Обновить `tests/config/test_config_sections.py` для проверки секции `yandex_geosuggest` и явного country mapping: `RU`, `BY`, `KZ` -> `dadata`; `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` -> `yandex_geosuggest`. +- Добавить `tests/adapters/address_suggestions/yandex_geosuggest/test_client.py` для success case, mapping `limit -> results`, извлечения `address`/`street`/`house`/`flat`, возврата `postal_code=None` и error scenarios `400`, `403`, `429`, `5xx`. +- Обновить `tests/services/test_address_suggestions.py` для проверки routing на `yandex_geosuggest` по странам `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` и сохранения routing на `dadata` для `RU`, `BY`, `KZ`. +- При необходимости обновить `tests/controllers/v1/test_address_suggestions.py` только для совместимости существующего endpoint с новым provider routing без изменения публичного API. + +## Commands +- `poetry run pytest tests/config/test_config_sections.py -q` +- `poetry run pytest tests/adapters/address_suggestions/yandex_geosuggest/test_client.py -q` +- `poetry run pytest tests/services/test_address_suggestions.py -q` +- `poetry run pytest tests/controllers/v1/test_address_suggestions.py -q` +- `python3 spec/gen_spec_index.py` diff --git a/tests/adapters/address_suggestions/yandex_geosuggest/test_client.py b/tests/adapters/address_suggestions/yandex_geosuggest/test_client.py new file mode 100644 index 0000000..1350b97 --- /dev/null +++ b/tests/adapters/address_suggestions/yandex_geosuggest/test_client.py @@ -0,0 +1,266 @@ +import asyncio +from typing import Any + +import httpx +import pytest + +from app.adapters.address_suggestions.yandex_geosuggest.client import ( + YandexGeosuggestAddressSuggestionProvider, + YandexGeosuggestClientError, + YandexGeosuggestRequestError, +) +from app.config import YandexGeosuggestAddressSuggestionsConfig +from app.schemas.request import AddressSuggestRequest +from app.schemas.response import AddressSuggestion + + +class SequenceHTTPClient: + def __init__(self, results: list[Any]) -> None: + self._results = results + self.calls: list[dict[str, Any]] = [] + + def _next_result(self) -> Any: + return self._results[len(self.calls) - 1] + + async def get( + self, + url: str, + *, + params: dict[str, Any] | None = None, + timeout: float | None = None, + ) -> httpx.Response: + self.calls.append( + { + "method": "GET", + "url": url, + "params": params, + "timeout": timeout, + } + ) + result = self._next_result() + if isinstance(result, Exception): + raise result + return result + + +def _make_request(**overrides: object) -> AddressSuggestRequest: + payload: dict[str, object] = { + "country_code": "AM", + "city": "Yerevan", + "query": "Tumanyan 12", + "limit": 5, + } + payload.update(overrides) + return AddressSuggestRequest(**payload) + + +def test_yandex_geosuggest_provider_maps_response_to_unified_model() -> None: + response = httpx.Response( + 200, + json={ + "results": [ + { + "title": {"text": "Tumanyan Street, 12, Yerevan"}, + "address": { + "formatted_address": "Tumanyan Street, 12, apt 34, Yerevan", + "component": [ + {"name": "Tumanyan Street", "kind": ["STREET"]}, + {"name": "12", "kind": ["HOUSE"]}, + {"name": "34", "kind": ["APARTMENT"]}, + {"name": "0001", "kind": ["POSTAL_CODE"]}, + ], + }, + } + ] + }, + request=httpx.Request( + "GET", + "https://suggest-maps.yandex.test/v1/suggest", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = YandexGeosuggestAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://suggest-maps.yandex.test/v1/suggest", + api_key="test-api-key", + timeout_seconds=6.5, + ) + + result = asyncio.run(provider.suggest(_make_request())) + + assert result == [ + AddressSuggestion( + address="Tumanyan Street, 12, apt 34, Yerevan", + street="Tumanyan Street", + house="12", + flat="34", + postal_code=None, + ) + ] + assert http_client.calls == [ + { + "method": "GET", + "url": "https://suggest-maps.yandex.test/v1/suggest", + "params": { + "apikey": "test-api-key", + "text": "Yerevan Tumanyan 12", + "print_address": 1, + "results": 5, + }, + "timeout": 6.5, + } + ] + + +def test_yandex_geosuggest_provider_uses_config_factory_and_omits_results_without_limit() -> None: + response = httpx.Response( + 200, + json={ + "results": [ + { + "title": {"text": "Republic Square, Yerevan"}, + "address": { + "formatted_address": "Republic Square, Yerevan", + "component": [ + {"name": "Republic Square", "kind": ["street"]}, + ], + }, + } + ] + }, + request=httpx.Request( + "GET", + "https://suggest-maps.yandex.test/v1/suggest", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = YandexGeosuggestAddressSuggestionProvider.from_config( + http_client=http_client, # type: ignore[arg-type] + config=YandexGeosuggestAddressSuggestionsConfig( + url="https://suggest-maps.yandex.test/v1/suggest", + api_key="config-api-key", + timeout_seconds=4.5, + ), + ) + + result = asyncio.run( + provider.suggest( + _make_request( + query="Republic Square", + limit=None, + ) + ) + ) + + assert result == [ + AddressSuggestion( + address="Republic Square, Yerevan", + street="Republic Square", + ) + ] + assert http_client.calls == [ + { + "method": "GET", + "url": "https://suggest-maps.yandex.test/v1/suggest", + "params": { + "apikey": "config-api-key", + "text": "Yerevan Republic Square", + "print_address": 1, + }, + "timeout": 4.5, + } + ] + + +def test_yandex_geosuggest_provider_maps_400_to_request_error() -> None: + response = httpx.Response( + 400, + json={"error": "bad request"}, + request=httpx.Request( + "GET", + "https://suggest-maps.yandex.test/v1/suggest", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = YandexGeosuggestAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://suggest-maps.yandex.test/v1/suggest", + api_key="test-api-key", + ) + + with pytest.raises(YandexGeosuggestRequestError, match="status 400"): + asyncio.run(provider.suggest(_make_request())) + + +@pytest.mark.parametrize("status_code", [403, 429, 503]) +def test_yandex_geosuggest_provider_maps_unavailable_statuses_to_client_error( + status_code: int, +) -> None: + response = httpx.Response( + status_code, + json={"error": "provider error"}, + request=httpx.Request( + "GET", + "https://suggest-maps.yandex.test/v1/suggest", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = YandexGeosuggestAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://suggest-maps.yandex.test/v1/suggest", + api_key="test-api-key", + ) + + with pytest.raises(YandexGeosuggestClientError, match=f"status {status_code}"): + asyncio.run(provider.suggest(_make_request())) + + +def test_yandex_geosuggest_provider_maps_transport_error_to_client_error() -> None: + transport_error = httpx.ReadTimeout( + "read timeout", + request=httpx.Request( + "GET", + "https://suggest-maps.yandex.test/v1/suggest", + ), + ) + http_client = SequenceHTTPClient([transport_error]) + provider = YandexGeosuggestAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://suggest-maps.yandex.test/v1/suggest", + api_key="test-api-key", + ) + + with pytest.raises(YandexGeosuggestClientError, match="request failed"): + asyncio.run(provider.suggest(_make_request())) + + +def test_yandex_geosuggest_provider_raises_on_invalid_payload_shape() -> None: + response = httpx.Response( + 200, + json={ + "results": [ + { + "address": { + "formatted_address": "Tumanyan Street, 12, Yerevan", + "component": [{"name": 12, "kind": ["HOUSE"]}], + } + } + ] + }, + request=httpx.Request( + "GET", + "https://suggest-maps.yandex.test/v1/suggest", + ), + ) + http_client = SequenceHTTPClient([response]) + provider = YandexGeosuggestAddressSuggestionProvider( + http_client=http_client, # type: ignore[arg-type] + url="https://suggest-maps.yandex.test/v1/suggest", + api_key="test-api-key", + ) + + with pytest.raises( + YandexGeosuggestClientError, + match="response payload is invalid", + ): + asyncio.run(provider.suggest(_make_request())) diff --git a/tests/config/fixtures/config.default.yaml b/tests/config/fixtures/config.default.yaml index ea9ee3b..51f85e3 100644 --- a/tests/config/fixtures/config.default.yaml +++ b/tests/config/fixtures/config.default.yaml @@ -18,9 +18,14 @@ adapter: address_suggestions: country_to_provider: RU: "dadata" + AM: "yandex_geosuggest" dadata: url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" api_key: "default-dadata-key" + yandex_geosuggest: + url: "https://suggest-maps.yandex.ru/v1/suggest" + api_key: "default-yandex-key" + timeout_seconds: 4.0 observability: enabled: false diff --git a/tests/config/fixtures/config.test.override.yaml b/tests/config/fixtures/config.test.override.yaml index 1e1f0cc..7f22409 100644 --- a/tests/config/fixtures/config.test.override.yaml +++ b/tests/config/fixtures/config.test.override.yaml @@ -18,11 +18,15 @@ adapter: address_suggestions: country_to_provider: RU: "dadata" - AM: "europe" + AM: "yandex_geosuggest" dadata: url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" api_key: "override-dadata-key" timeout_seconds: 3.0 + yandex_geosuggest: + url: "https://suggest-maps.yandex.ru/v1/suggest" + api_key: "override-yandex-key" + timeout_seconds: 4.5 observability: enabled: true diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py index 50bac8f..e55fd71 100644 --- a/tests/config/test_config_sections.py +++ b/tests/config/test_config_sections.py @@ -85,6 +85,13 @@ def test_configuration_sections_are_loaded_from_yaml_file( "RU": "dadata", "BY": "dadata", "KZ": "dadata", + "AM": "yandex_geosuggest", + "AZ": "yandex_geosuggest", + "KG": "yandex_geosuggest", + "MD": "yandex_geosuggest", + "TJ": "yandex_geosuggest", + "TM": "yandex_geosuggest", + "UZ": "yandex_geosuggest", } assert ( settings.address_suggestions.dadata.url @@ -92,6 +99,15 @@ def test_configuration_sections_are_loaded_from_yaml_file( ) assert settings.address_suggestions.dadata.api_key == "test-dadata-api-key" assert settings.address_suggestions.dadata.timeout_seconds == 7.5 + assert ( + settings.address_suggestions.yandex_geosuggest.url + == "https://suggest-maps.yandex.ru/v1/suggest" + ) + assert ( + settings.address_suggestions.yandex_geosuggest.api_key + == "test-yandex-geosuggest-api-key" + ) + assert settings.address_suggestions.yandex_geosuggest.timeout_seconds == 6.5 assert settings.observability.enabled is False assert settings.observability.service_name == "g2s-aggregator-test" assert settings.observability.otlp_endpoint == "http://localhost:4317" @@ -161,10 +177,12 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment( assert settings.adapter.cdek_client_id == "test-id" assert settings.address_suggestions.country_to_provider == { "RU": "dadata", - "AM": "europe", + "AM": "yandex_geosuggest", } assert settings.address_suggestions.dadata.api_key == "override-dadata-key" assert settings.address_suggestions.dadata.timeout_seconds == 3.0 + assert settings.address_suggestions.yandex_geosuggest.api_key == "override-yandex-key" + assert settings.address_suggestions.yandex_geosuggest.timeout_seconds == 4.5 assert settings.observability.enabled is True assert settings.observability.service_name == "override-config-service" assert settings.observability.otlp_endpoint == "http://override-collector:4317" diff --git a/tests/controllers/v1/test_address_suggestions.py b/tests/controllers/v1/test_address_suggestions.py index 1523748..8296ade 100644 --- a/tests/controllers/v1/test_address_suggestions.py +++ b/tests/controllers/v1/test_address_suggestions.py @@ -35,13 +35,15 @@ def _install_service_override(app, service: StubAggregatorService) -> None: app.dependency_overrides[get_aggregator_service] = override_service -def _valid_payload() -> dict[str, object]: - return { +def _valid_payload(**overrides: object) -> dict[str, object]: + payload = { "country_code": "RU", "city": "Moscow", "query": "Lenina", "limit": 5, } + payload.update(overrides) + return payload def test_post_address_suggest_uses_registered_provider_in_default_dependency( @@ -62,24 +64,16 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency( return cls() class StubAddressProvider: - name = "dadata" - - def __init__(self) -> None: + def __init__(self, name: str, response: list[AddressSuggestion]) -> None: + self.name = name + self._response = response self.calls: list[AddressSuggestRequest] = [] async def suggest( self, request: AddressSuggestRequest ) -> list[AddressSuggestion]: self.calls.append(request) - return [ - AddressSuggestion( - address="107241, Moscow, Khabarovskaya 1", - street="Khabarovskaya", - house="1", - flat="25", - postal_code="107241", - ) - ] + return self._response class StubDadataAddressSuggestionProvider: @classmethod @@ -91,7 +85,19 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency( ) -> StubAddressProvider: assert http_client is stub_http_client _ = config - return stub_address_provider + return stub_dadata_provider + + class StubYandexGeosuggestAddressSuggestionProvider: + @classmethod + def from_config( + cls, + *, + http_client, + config, + ) -> StubAddressProvider: + assert http_client is stub_http_client + _ = config + return stub_yandex_provider class StubCache: async def get(self, key: str) -> object | None: @@ -110,7 +116,28 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency( return StubCache() stub_http_client = StubHttpClient() - stub_address_provider = StubAddressProvider() + stub_dadata_provider = StubAddressProvider( + "dadata", + [ + AddressSuggestion( + address="107241, Moscow, Khabarovskaya 1", + street="Khabarovskaya", + house="1", + flat="25", + postal_code="107241", + ) + ], + ) + stub_yandex_provider = StubAddressProvider( + "yandex_geosuggest", + [ + AddressSuggestion( + address="Tumanyan Street, 12, Yerevan", + street="Tumanyan Street", + house="12", + ) + ], + ) http_client_timeouts: list[float] = [] def fake_build_controller_http_client(timeout_seconds: float) -> StubHttpClient: @@ -128,9 +155,19 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency( "DadataAddressSuggestionProvider", StubDadataAddressSuggestionProvider, ) + monkeypatch.setattr( + delivery_controller, + "YandexGeosuggestAddressSuggestionProvider", + StubYandexGeosuggestAddressSuggestionProvider, + ) monkeypatch.setattr(delivery_controller, "PriceCache", StubPriceCache) app = create_app() + request_payload = _valid_payload( + country_code="AM", + city="Yerevan", + query="Tumanyan 12", + ) async def run_request() -> httpx.Response: transport = httpx.ASGITransport(app=app) @@ -140,7 +177,7 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency( ) as client: return await client.post( "/api/v1/delivery/suggest-address", - json=_valid_payload(), + json=request_payload, ) response = asyncio.run(run_request()) @@ -148,15 +185,16 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency( assert response.status_code == 200 assert response.json() == [ { - "address": "107241, Moscow, Khabarovskaya 1", - "street": "Khabarovskaya", - "house": "1", - "flat": "25", - "postal_code": "107241", + "address": "Tumanyan Street, 12, Yerevan", + "street": "Tumanyan Street", + "house": "12", + "flat": None, + "postal_code": None, } ] assert http_client_timeouts == [10.0] - assert stub_address_provider.calls == [AddressSuggestRequest(**_valid_payload())] + assert stub_dadata_provider.calls == [] + assert stub_yandex_provider.calls == [AddressSuggestRequest(**request_payload)] def test_post_address_suggest_returns_response_and_delegates_to_service() -> None: diff --git a/tests/services/test_address_suggestions.py b/tests/services/test_address_suggestions.py index 36ff21c..153e5ac 100644 --- a/tests/services/test_address_suggestions.py +++ b/tests/services/test_address_suggestions.py @@ -15,6 +15,9 @@ from app.services.aggregator import ( UnsupportedAddressSuggestionCountryError, ) +_DADATA_COUNTRIES = ("RU", "BY", "KZ") +_YANDEX_COUNTRIES = ("AM", "AZ", "KG", "MD", "TJ", "TM", "UZ") + class StubAddressSuggestionProvider: def __init__( @@ -64,7 +67,20 @@ def _make_suggestion( ) -def test_suggest_addresses_routes_ru_to_dadata() -> None: +def _make_country_mapping() -> dict[str, str]: + return { + **{country_code: "dadata" for country_code in _DADATA_COUNTRIES}, + **{ + country_code: "yandex_geosuggest" + for country_code in _YANDEX_COUNTRIES + }, + } + + +@pytest.mark.parametrize("country_code", _DADATA_COUNTRIES) +def test_suggest_addresses_routes_dadata_countries_to_dadata( + country_code: str, +) -> None: dadata = StubAddressSuggestionProvider( "dadata", response=[ @@ -77,16 +93,20 @@ def test_suggest_addresses_routes_ru_to_dadata() -> None: ) ], ) - europe = StubAddressSuggestionProvider( - "europe", + yandex_geosuggest = StubAddressSuggestionProvider( + "yandex_geosuggest", response=[_make_suggestion("Yerevan, Tumanyan 1")], ) service = AggregatorService( providers=[], - address_suggestion_providers=[dadata, europe], - address_suggestion_country_to_provider={"RU": "dadata", "AM": "europe"}, + address_suggestion_providers=[dadata, yandex_geosuggest], + address_suggestion_country_to_provider=_make_country_mapping(), + ) + request = _make_request( + country_code=country_code, + city="Moscow", + query="Khabarovskaya", ) - request = _make_request(country_code="RU", city="Moscow", query="Khabarovskaya") result = asyncio.run(service.suggest_addresses(request)) @@ -100,30 +120,33 @@ def test_suggest_addresses_routes_ru_to_dadata() -> None: ) ] assert dadata.calls == [request] - assert europe.calls == [] + assert yandex_geosuggest.calls == [] -def test_suggest_addresses_routes_second_provider_by_country_mapping() -> None: +@pytest.mark.parametrize("country_code", _YANDEX_COUNTRIES) +def test_suggest_addresses_routes_cis_countries_to_yandex_geosuggest( + country_code: str, +) -> None: dadata = StubAddressSuggestionProvider( "dadata", response=[_make_suggestion("Moscow, Lenina 1")], ) - europe = StubAddressSuggestionProvider( - "europe", + yandex_geosuggest = StubAddressSuggestionProvider( + "yandex_geosuggest", response=[_make_suggestion("Yerevan, Tumanyan 1")], ) service = AggregatorService( providers=[], - address_suggestion_providers=[dadata, europe], - address_suggestion_country_to_provider={"RU": "dadata", "AM": "europe"}, + address_suggestion_providers=[dadata, yandex_geosuggest], + address_suggestion_country_to_provider=_make_country_mapping(), ) - request = _make_request(country_code="AM", city="Yerevan", query="Tumanyan") + request = _make_request(country_code=country_code, city="Yerevan", query="Tumanyan") result = asyncio.run(service.suggest_addresses(request)) assert result == [AddressSuggestion(address="Yerevan, Tumanyan 1")] assert dadata.calls == [] - assert europe.calls == [request] + assert yandex_geosuggest.calls == [request] def test_suggest_addresses_raises_for_unsupported_country() -> None: @@ -155,7 +178,7 @@ def test_suggest_addresses_raises_for_unregistered_provider_mapping() -> None: response=[_make_suggestion("Moscow, Lenina 1")], ) ], - address_suggestion_country_to_provider={"AM": "europe"}, + address_suggestion_country_to_provider={"AM": "yandex_geosuggest"}, ) with pytest.raises(UnsupportedAddressSuggestionCountryError):