Добавлен yandex geosuggest

This commit is contained in:
Раис Юсупалиев
2026-03-29 13:51:56 +03:00
parent 4a3737999f
commit 44893e01ae
16 changed files with 750 additions and 49 deletions
@@ -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",
]
@@ -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
+9
View File
@@ -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
+8 -1
View File
@@ -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
),
+21
View File
@@ -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"
+11
View File
@@ -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
-3
View File
@@ -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("")
+4 -3
View File
@@ -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**
+8 -2
View File
@@ -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
@@ -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`
@@ -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()))
@@ -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
@@ -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
+19 -1
View File
@@ -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"
@@ -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:
+38 -15
View File
@@ -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):