Добавлен yandex geosuggest
This commit is contained in:
@@ -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
|
||||||
@@ -54,11 +54,20 @@ class DadataAddressSuggestionsConfig(BaseModel):
|
|||||||
timeout_seconds: float = Field(default=10.0, gt=0)
|
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):
|
class AddressSuggestionsConfig(BaseModel):
|
||||||
country_to_provider: dict[str, str] = Field(default_factory=dict)
|
country_to_provider: dict[str, str] = Field(default_factory=dict)
|
||||||
dadata: DadataAddressSuggestionsConfig = Field(
|
dadata: DadataAddressSuggestionsConfig = Field(
|
||||||
default_factory=DadataAddressSuggestionsConfig
|
default_factory=DadataAddressSuggestionsConfig
|
||||||
)
|
)
|
||||||
|
yandex_geosuggest: YandexGeosuggestAddressSuggestionsConfig = Field(
|
||||||
|
default_factory=YandexGeosuggestAddressSuggestionsConfig
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("country_to_provider")
|
@field_validator("country_to_provider")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
|
||||||
from app.adapters.address_suggestions.dadata import DadataAddressSuggestionProvider
|
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.adapters.delivery_providers.cdek import CDEKProvider
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.controllers.http_client import build_controller_http_client
|
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,
|
http_client=http_client,
|
||||||
config=settings.address_suggestions.dadata,
|
config=settings.address_suggestions.dadata,
|
||||||
)
|
)
|
||||||
|
yandex_geosuggest_provider = YandexGeosuggestAddressSuggestionProvider.from_config(
|
||||||
|
http_client=http_client,
|
||||||
|
config=settings.address_suggestions.yandex_geosuggest,
|
||||||
|
)
|
||||||
providers = (cdek_provider,)
|
providers = (cdek_provider,)
|
||||||
cache = PriceCache.from_repository_config(settings.repository)
|
cache = PriceCache.from_repository_config(settings.repository)
|
||||||
service = AggregatorService(
|
service = AggregatorService(
|
||||||
providers=providers,
|
providers=providers,
|
||||||
cache=cache,
|
cache=cache,
|
||||||
order_adapter=cdek_provider,
|
order_adapter=cdek_provider,
|
||||||
address_suggestion_providers=(dadata_provider,),
|
address_suggestion_providers=(dadata_provider, yandex_geosuggest_provider),
|
||||||
address_suggestion_country_to_provider=(
|
address_suggestion_country_to_provider=(
|
||||||
settings.address_suggestions.country_to_provider
|
settings.address_suggestions.country_to_provider
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -23,6 +23,27 @@ adapter:
|
|||||||
cdek_timeout_seconds: 10.0
|
cdek_timeout_seconds: 10.0
|
||||||
cdek_cache_ttl_seconds: 900
|
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:
|
observability:
|
||||||
enabled: false
|
enabled: false
|
||||||
service_name: "g2s-aggregator"
|
service_name: "g2s-aggregator"
|
||||||
|
|||||||
@@ -28,10 +28,21 @@ address_suggestions:
|
|||||||
RU: "dadata"
|
RU: "dadata"
|
||||||
BY: "dadata"
|
BY: "dadata"
|
||||||
KZ: "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:
|
dadata:
|
||||||
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||||
api_key: "test-dadata-api-key"
|
api_key: "test-dadata-api-key"
|
||||||
timeout_seconds: 7.5
|
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:
|
observability:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -176,13 +176,10 @@ def load_tasks() -> List[TaskMeta]:
|
|||||||
|
|
||||||
|
|
||||||
def render_index(tasks: List[TaskMeta]) -> str:
|
def render_index(tasks: List[TaskMeta]) -> str:
|
||||||
generated_at = dt.datetime.now(tz=dt.timezone.utc).replace(microsecond=0).isoformat()
|
|
||||||
|
|
||||||
lines: List[str] = []
|
lines: List[str] = []
|
||||||
lines.append("# Spec Tasks Index")
|
lines.append("# Spec Tasks Index")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("> ⚠️ This file is generated. Do not edit manually.")
|
lines.append("> ⚠️ This file is generated. Do not edit manually.")
|
||||||
lines.append(f"> Generated at (UTC): `{generated_at}`")
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("## Tasks")
|
lines.append("## Tasks")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|||||||
+4
-3
@@ -1,7 +1,7 @@
|
|||||||
# Spec Tasks Index
|
# Spec Tasks Index
|
||||||
|
|
||||||
> ⚠️ This file is generated. Do not edit manually.
|
> ⚠️ 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
|
## 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` |
|
| 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` |
|
| 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` |
|
| 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
|
## Summary
|
||||||
|
|
||||||
- Total: **23**
|
- Total: **24**
|
||||||
- TODO: **1**
|
- TODO: **1**
|
||||||
- DONE: **22**
|
- DONE: **23**
|
||||||
|
|||||||
+8
-2
@@ -20,7 +20,9 @@
|
|||||||
- Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед созданием заказа
|
- Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед созданием заказа
|
||||||
- Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери"
|
- Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери"
|
||||||
- Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге
|
- Выбирать сервис подсказок адреса по `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]: ...
|
async def suggest(self, request: AddressSuggestRequest) -> list[AddressSuggestion]: ...
|
||||||
```
|
```
|
||||||
- `dadata/client.py` — HTTP-клиент `dadata.ru` для address suggestions
|
- `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
|
- provider-specific модули address suggestion adapters инкапсулируют внешние API-контракты, auth, serialization и error handling
|
||||||
- В конфиге adapter layer хранится маппинг `country_code -> provider_id` для выбора address suggestion provider
|
- В конфиге adapter layer хранится маппинг `country_code -> provider_id` для выбора address suggestion provider
|
||||||
- Address suggestion adapters возвращают только унифицированные internal models без утечки provider-specific payload в Service
|
- Address suggestion adapters возвращают только унифицированные internal models без утечки provider-specific payload в Service
|
||||||
|
- Для Yandex Geosuggest adapter возвращает `postal_code=None`, так как контракт Geosuggest не используется как источник почтового индекса
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -222,7 +226,9 @@ app/
|
|||||||
├── adapters/
|
├── adapters/
|
||||||
│ ├── address_suggestions/
|
│ ├── address_suggestions/
|
||||||
│ │ ├── base.py # Интерфейс Adapter
|
│ │ ├── base.py # Интерфейс Adapter
|
||||||
│ │ └── dadata/
|
│ │ ├── dadata/
|
||||||
|
│ │ └── client.py
|
||||||
|
│ │ └── yandex_geosuggest/
|
||||||
│ │ └── client.py
|
│ │ └── client.py
|
||||||
│ └── delivery_providers/
|
│ └── delivery_providers/
|
||||||
│ ├── base.py # Интерфейс Adapter
|
│ ├── 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:
|
address_suggestions:
|
||||||
country_to_provider:
|
country_to_provider:
|
||||||
RU: "dadata"
|
RU: "dadata"
|
||||||
|
AM: "yandex_geosuggest"
|
||||||
dadata:
|
dadata:
|
||||||
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||||
api_key: "default-dadata-key"
|
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:
|
observability:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -18,11 +18,15 @@ adapter:
|
|||||||
address_suggestions:
|
address_suggestions:
|
||||||
country_to_provider:
|
country_to_provider:
|
||||||
RU: "dadata"
|
RU: "dadata"
|
||||||
AM: "europe"
|
AM: "yandex_geosuggest"
|
||||||
dadata:
|
dadata:
|
||||||
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||||
api_key: "override-dadata-key"
|
api_key: "override-dadata-key"
|
||||||
timeout_seconds: 3.0
|
timeout_seconds: 3.0
|
||||||
|
yandex_geosuggest:
|
||||||
|
url: "https://suggest-maps.yandex.ru/v1/suggest"
|
||||||
|
api_key: "override-yandex-key"
|
||||||
|
timeout_seconds: 4.5
|
||||||
|
|
||||||
observability:
|
observability:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ def test_configuration_sections_are_loaded_from_yaml_file(
|
|||||||
"RU": "dadata",
|
"RU": "dadata",
|
||||||
"BY": "dadata",
|
"BY": "dadata",
|
||||||
"KZ": "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 (
|
assert (
|
||||||
settings.address_suggestions.dadata.url
|
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.api_key == "test-dadata-api-key"
|
||||||
assert settings.address_suggestions.dadata.timeout_seconds == 7.5
|
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.enabled is False
|
||||||
assert settings.observability.service_name == "g2s-aggregator-test"
|
assert settings.observability.service_name == "g2s-aggregator-test"
|
||||||
assert settings.observability.otlp_endpoint == "http://localhost:4317"
|
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.adapter.cdek_client_id == "test-id"
|
||||||
assert settings.address_suggestions.country_to_provider == {
|
assert settings.address_suggestions.country_to_provider == {
|
||||||
"RU": "dadata",
|
"RU": "dadata",
|
||||||
"AM": "europe",
|
"AM": "yandex_geosuggest",
|
||||||
}
|
}
|
||||||
assert settings.address_suggestions.dadata.api_key == "override-dadata-key"
|
assert settings.address_suggestions.dadata.api_key == "override-dadata-key"
|
||||||
assert settings.address_suggestions.dadata.timeout_seconds == 3.0
|
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.enabled is True
|
||||||
assert settings.observability.service_name == "override-config-service"
|
assert settings.observability.service_name == "override-config-service"
|
||||||
assert settings.observability.otlp_endpoint == "http://override-collector:4317"
|
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
|
app.dependency_overrides[get_aggregator_service] = override_service
|
||||||
|
|
||||||
|
|
||||||
def _valid_payload() -> dict[str, object]:
|
def _valid_payload(**overrides: object) -> dict[str, object]:
|
||||||
return {
|
payload = {
|
||||||
"country_code": "RU",
|
"country_code": "RU",
|
||||||
"city": "Moscow",
|
"city": "Moscow",
|
||||||
"query": "Lenina",
|
"query": "Lenina",
|
||||||
"limit": 5,
|
"limit": 5,
|
||||||
}
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def test_post_address_suggest_uses_registered_provider_in_default_dependency(
|
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()
|
return cls()
|
||||||
|
|
||||||
class StubAddressProvider:
|
class StubAddressProvider:
|
||||||
name = "dadata"
|
def __init__(self, name: str, response: list[AddressSuggestion]) -> None:
|
||||||
|
self.name = name
|
||||||
def __init__(self) -> None:
|
self._response = response
|
||||||
self.calls: list[AddressSuggestRequest] = []
|
self.calls: list[AddressSuggestRequest] = []
|
||||||
|
|
||||||
async def suggest(
|
async def suggest(
|
||||||
self, request: AddressSuggestRequest
|
self, request: AddressSuggestRequest
|
||||||
) -> list[AddressSuggestion]:
|
) -> list[AddressSuggestion]:
|
||||||
self.calls.append(request)
|
self.calls.append(request)
|
||||||
return [
|
return self._response
|
||||||
AddressSuggestion(
|
|
||||||
address="107241, Moscow, Khabarovskaya 1",
|
|
||||||
street="Khabarovskaya",
|
|
||||||
house="1",
|
|
||||||
flat="25",
|
|
||||||
postal_code="107241",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
class StubDadataAddressSuggestionProvider:
|
class StubDadataAddressSuggestionProvider:
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -91,7 +85,19 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency(
|
|||||||
) -> StubAddressProvider:
|
) -> StubAddressProvider:
|
||||||
assert http_client is stub_http_client
|
assert http_client is stub_http_client
|
||||||
_ = config
|
_ = 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:
|
class StubCache:
|
||||||
async def get(self, key: str) -> object | None:
|
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()
|
return StubCache()
|
||||||
|
|
||||||
stub_http_client = StubHttpClient()
|
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] = []
|
http_client_timeouts: list[float] = []
|
||||||
|
|
||||||
def fake_build_controller_http_client(timeout_seconds: float) -> StubHttpClient:
|
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",
|
"DadataAddressSuggestionProvider",
|
||||||
StubDadataAddressSuggestionProvider,
|
StubDadataAddressSuggestionProvider,
|
||||||
)
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
delivery_controller,
|
||||||
|
"YandexGeosuggestAddressSuggestionProvider",
|
||||||
|
StubYandexGeosuggestAddressSuggestionProvider,
|
||||||
|
)
|
||||||
monkeypatch.setattr(delivery_controller, "PriceCache", StubPriceCache)
|
monkeypatch.setattr(delivery_controller, "PriceCache", StubPriceCache)
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
request_payload = _valid_payload(
|
||||||
|
country_code="AM",
|
||||||
|
city="Yerevan",
|
||||||
|
query="Tumanyan 12",
|
||||||
|
)
|
||||||
|
|
||||||
async def run_request() -> httpx.Response:
|
async def run_request() -> httpx.Response:
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
@@ -140,7 +177,7 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency(
|
|||||||
) as client:
|
) as client:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/delivery/suggest-address",
|
"/api/v1/delivery/suggest-address",
|
||||||
json=_valid_payload(),
|
json=request_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = asyncio.run(run_request())
|
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.status_code == 200
|
||||||
assert response.json() == [
|
assert response.json() == [
|
||||||
{
|
{
|
||||||
"address": "107241, Moscow, Khabarovskaya 1",
|
"address": "Tumanyan Street, 12, Yerevan",
|
||||||
"street": "Khabarovskaya",
|
"street": "Tumanyan Street",
|
||||||
"house": "1",
|
"house": "12",
|
||||||
"flat": "25",
|
"flat": None,
|
||||||
"postal_code": "107241",
|
"postal_code": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert http_client_timeouts == [10.0]
|
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:
|
def test_post_address_suggest_returns_response_and_delegates_to_service() -> None:
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ from app.services.aggregator import (
|
|||||||
UnsupportedAddressSuggestionCountryError,
|
UnsupportedAddressSuggestionCountryError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_DADATA_COUNTRIES = ("RU", "BY", "KZ")
|
||||||
|
_YANDEX_COUNTRIES = ("AM", "AZ", "KG", "MD", "TJ", "TM", "UZ")
|
||||||
|
|
||||||
|
|
||||||
class StubAddressSuggestionProvider:
|
class StubAddressSuggestionProvider:
|
||||||
def __init__(
|
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 = StubAddressSuggestionProvider(
|
||||||
"dadata",
|
"dadata",
|
||||||
response=[
|
response=[
|
||||||
@@ -77,16 +93,20 @@ def test_suggest_addresses_routes_ru_to_dadata() -> None:
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
europe = StubAddressSuggestionProvider(
|
yandex_geosuggest = StubAddressSuggestionProvider(
|
||||||
"europe",
|
"yandex_geosuggest",
|
||||||
response=[_make_suggestion("Yerevan, Tumanyan 1")],
|
response=[_make_suggestion("Yerevan, Tumanyan 1")],
|
||||||
)
|
)
|
||||||
service = AggregatorService(
|
service = AggregatorService(
|
||||||
providers=[],
|
providers=[],
|
||||||
address_suggestion_providers=[dadata, europe],
|
address_suggestion_providers=[dadata, yandex_geosuggest],
|
||||||
address_suggestion_country_to_provider={"RU": "dadata", "AM": "europe"},
|
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))
|
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 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 = StubAddressSuggestionProvider(
|
||||||
"dadata",
|
"dadata",
|
||||||
response=[_make_suggestion("Moscow, Lenina 1")],
|
response=[_make_suggestion("Moscow, Lenina 1")],
|
||||||
)
|
)
|
||||||
europe = StubAddressSuggestionProvider(
|
yandex_geosuggest = StubAddressSuggestionProvider(
|
||||||
"europe",
|
"yandex_geosuggest",
|
||||||
response=[_make_suggestion("Yerevan, Tumanyan 1")],
|
response=[_make_suggestion("Yerevan, Tumanyan 1")],
|
||||||
)
|
)
|
||||||
service = AggregatorService(
|
service = AggregatorService(
|
||||||
providers=[],
|
providers=[],
|
||||||
address_suggestion_providers=[dadata, europe],
|
address_suggestion_providers=[dadata, yandex_geosuggest],
|
||||||
address_suggestion_country_to_provider={"RU": "dadata", "AM": "europe"},
|
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))
|
result = asyncio.run(service.suggest_addresses(request))
|
||||||
|
|
||||||
assert result == [AddressSuggestion(address="Yerevan, Tumanyan 1")]
|
assert result == [AddressSuggestion(address="Yerevan, Tumanyan 1")]
|
||||||
assert dadata.calls == []
|
assert dadata.calls == []
|
||||||
assert europe.calls == [request]
|
assert yandex_geosuggest.calls == [request]
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_addresses_raises_for_unsupported_country() -> None:
|
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")],
|
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):
|
with pytest.raises(UnsupportedAddressSuggestionCountryError):
|
||||||
|
|||||||
Reference in New Issue
Block a user