Добавлен tomtom

This commit is contained in:
Раис Юсупалиев
2026-04-03 04:19:55 +03:00
parent 44893e01ae
commit db1c74f1ce
16 changed files with 741 additions and 30 deletions
@@ -0,0 +1,13 @@
"""TomTom address suggestion adapter package."""
from app.adapters.address_suggestions.tomtom.client import (
TomTomAddressSuggestionProvider,
TomTomClientError,
TomTomRequestError,
)
__all__ = [
"TomTomAddressSuggestionProvider",
"TomTomClientError",
"TomTomRequestError",
]
@@ -0,0 +1,155 @@
"""TomTom address suggestion adapter."""
from typing import Any
from urllib.parse import quote
import httpx
from app.adapters.address_suggestions.base import (
AddressSuggestionClientError,
AddressSuggestionProvider,
AddressSuggestionRequestError,
)
from app.config import TomTomAddressSuggestionsConfig
from app.schemas.request import AddressSuggestRequest
from app.schemas.response import AddressSuggestion
_TOMTOM_IDX_SET = "PAD,Addr,Str,EPP"
class TomTomClientError(AddressSuggestionClientError):
"""Raised when TomTom Search requests fail."""
class TomTomRequestError(TomTomClientError, AddressSuggestionRequestError):
"""Raised when TomTom rejects request parameters."""
class TomTomAddressSuggestionProvider(AddressSuggestionProvider):
name = "tomtom"
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.rstrip("/")
self._api_key = api_key
self._timeout_seconds = timeout_seconds
@classmethod
def from_config(
cls,
*,
http_client: httpx.AsyncClient,
config: TomTomAddressSuggestionsConfig,
) -> "TomTomAddressSuggestionProvider":
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]:
request_url = self._build_request_url(request)
params = self._build_query_params(request)
try:
response = await self._http_client.get(
request_url,
params=params,
timeout=self._timeout_seconds,
)
except (httpx.TimeoutException, httpx.TransportError) as exc:
raise TomTomClientError("TomTom Search request failed.") from exc
if response.status_code == 400:
raise TomTomRequestError(
"TomTom Search request was rejected with status 400."
)
if response.status_code >= 500 or response.status_code in {403, 429}:
raise TomTomClientError(
"TomTom Search request failed with status "
f"{response.status_code}."
)
if 400 <= response.status_code < 500:
raise TomTomClientError(
"TomTom Search 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 TomTomClientError("TomTom Search returned invalid payload.") from exc
try:
return _map_tomtom_response(raw_payload)
except ValueError as exc:
raise TomTomClientError(
"TomTom Search response payload is invalid."
) from exc
def _build_request_url(self, request: AddressSuggestRequest) -> str:
search_query = f"{request.city.strip()} {request.query.strip()}".strip()
return f"{self._url}/{quote(search_query, safe='')}.json"
def _build_query_params(self, request: AddressSuggestRequest) -> dict[str, Any]:
params: dict[str, Any] = {
"key": self._api_key,
"countrySet": request.country_code.strip().upper(),
"typeahead": "true",
"idxSet": _TOMTOM_IDX_SET,
}
if request.limit is not None:
params["limit"] = request.limit
return params
def _map_tomtom_response(payload: object) -> list[AddressSuggestion]:
if not isinstance(payload, dict):
raise ValueError("TomTom 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("TomTom response must include results list.")
return [_map_tomtom_result(item) for item in raw_results]
def _map_tomtom_result(payload: object) -> AddressSuggestion:
if not isinstance(payload, dict):
raise ValueError("TomTom result entry must be an object.")
raw_address = payload.get("address")
if not isinstance(raw_address, dict):
raise ValueError("TomTom result address must be an object.")
address = _coerce_optional_text(raw_address.get("freeformAddress"))
if address is None:
raise ValueError("TomTom result has no valid freeformAddress.")
return AddressSuggestion(
address=address,
street=_coerce_optional_text(raw_address.get("streetName")),
house=_coerce_optional_text(raw_address.get("streetNumber")),
flat=None,
postal_code=_coerce_optional_text(raw_address.get("postalCode")),
)
def _coerce_optional_text(value: object) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise ValueError("TomTom address field must be a string.")
return value.strip() or None
+9
View File
@@ -60,6 +60,12 @@ class YandexGeosuggestAddressSuggestionsConfig(BaseModel):
timeout_seconds: float = Field(default=10.0, gt=0) timeout_seconds: float = Field(default=10.0, gt=0)
class TomTomAddressSuggestionsConfig(BaseModel):
url: str = "https://api.tomtom.com/search/2/search"
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(
@@ -68,6 +74,9 @@ class AddressSuggestionsConfig(BaseModel):
yandex_geosuggest: YandexGeosuggestAddressSuggestionsConfig = Field( yandex_geosuggest: YandexGeosuggestAddressSuggestionsConfig = Field(
default_factory=YandexGeosuggestAddressSuggestionsConfig default_factory=YandexGeosuggestAddressSuggestionsConfig
) )
tomtom: TomTomAddressSuggestionsConfig = Field(
default_factory=TomTomAddressSuggestionsConfig
)
@field_validator("country_to_provider") @field_validator("country_to_provider")
@classmethod @classmethod
+10 -1
View File
@@ -3,6 +3,7 @@
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.tomtom import TomTomAddressSuggestionProvider
from app.adapters.address_suggestions.yandex_geosuggest import ( from app.adapters.address_suggestions.yandex_geosuggest import (
YandexGeosuggestAddressSuggestionProvider, YandexGeosuggestAddressSuggestionProvider,
) )
@@ -45,13 +46,21 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client=http_client, http_client=http_client,
config=settings.address_suggestions.yandex_geosuggest, config=settings.address_suggestions.yandex_geosuggest,
) )
tomtom_provider = TomTomAddressSuggestionProvider.from_config(
http_client=http_client,
config=settings.address_suggestions.tomtom,
)
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, yandex_geosuggest_provider), address_suggestion_providers=(
dadata_provider,
yandex_geosuggest_provider,
tomtom_provider,
),
address_suggestion_country_to_provider=( address_suggestion_country_to_provider=(
settings.address_suggestions.country_to_provider settings.address_suggestions.country_to_provider
), ),
+34
View File
@@ -35,6 +35,36 @@ address_suggestions:
TJ: "yandex_geosuggest" TJ: "yandex_geosuggest"
TM: "yandex_geosuggest" TM: "yandex_geosuggest"
UZ: "yandex_geosuggest" UZ: "yandex_geosuggest"
AL: "tomtom"
AT: "tomtom"
BE: "tomtom"
BG: "tomtom"
CH: "tomtom"
CZ: "tomtom"
DE: "tomtom"
DK: "tomtom"
EE: "tomtom"
ES: "tomtom"
FI: "tomtom"
FR: "tomtom"
GB: "tomtom"
GR: "tomtom"
HR: "tomtom"
HU: "tomtom"
IE: "tomtom"
IT: "tomtom"
LT: "tomtom"
LV: "tomtom"
NL: "tomtom"
"NO": "tomtom"
PL: "tomtom"
PT: "tomtom"
RO: "tomtom"
RS: "tomtom"
SE: "tomtom"
SI: "tomtom"
SK: "tomtom"
UA: "tomtom"
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"
@@ -43,6 +73,10 @@ address_suggestions:
url: "https://suggest-maps.yandex.ru/v1/suggest" url: "https://suggest-maps.yandex.ru/v1/suggest"
api_key: "test-yandex-geosuggest-api-key" api_key: "test-yandex-geosuggest-api-key"
timeout_seconds: 6.5 timeout_seconds: 6.5
tomtom:
url: "https://api.tomtom.com/search/2/search"
api_key: "test-tomtom-api-key"
timeout_seconds: 5.5
observability: observability:
enabled: false enabled: false
+3 -3
View File
@@ -1,7 +1,6 @@
# 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-29T15:00:18+00:00`
## Tasks ## Tasks
@@ -31,9 +30,10 @@
| 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` | | 023 | DONE | 2026-03-29 | Add Yandex Geosuggest address suggestion adapter and CIS routing | `spec/tasks/023_add_yandex_geosuggest_address_suggestion_adapter.md` |
| 024 | DONE | 2026-03-29 | Add TomTom address suggestion adapter and Europe routing | `spec/tasks/024_add_tomtom_address_suggestion_adapter.md` |
## Summary ## Summary
- Total: **24** - Total: **25**
- TODO: **1** - TODO: **1**
- DONE: **23** - DONE: **24**
+6 -1
View File
@@ -22,6 +22,7 @@
- Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге - Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге
- Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru` - Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru`
- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest - Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest
- Для европейских стран, сопоставленных с provider id `tomtom`, использовать TomTom Search API Fuzzy Search
- Конфигурация и wiring должны допускать отдельные address suggestion providers для других регионов - Конфигурация и wiring должны допускать отдельные address suggestion providers для других регионов
- Опрашивать всех зарегистрированных провайдеров параллельно - Опрашивать всех зарегистрированных провайдеров параллельно
- Возвращать унифицированный список тарифов, отсортированных по цене - Возвращать унифицированный список тарифов, отсортированных по цене
@@ -106,10 +107,12 @@
``` ```
- `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` - `yandex_geosuggest/client.py` — HTTP-клиент Yandex Geosuggest `GET https://suggest-maps.yandex.ru/v1/suggest`
- `tomtom/client.py` — HTTP-клиент TomTom Search API Fuzzy Search `GET https://api.tomtom.com/search/2/search/{query}.json`
- 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 не используется как источник почтового индекса - Для Yandex Geosuggest adapter возвращает `postal_code=None`, так как контракт Geosuggest не используется как источник почтового индекса
- Для TomTom Search adapter использует `request.city` и `request.query` для формирования search query, передаёт `countrySet=request.country_code`, `typeahead=true`, `idxSet=PAD,Addr,Str,EPP`, а `postal_code` берёт из `address.postalCode`, если оно присутствует
--- ---
@@ -228,7 +231,9 @@ app/
│ │ ├── base.py # Интерфейс Adapter │ │ ├── base.py # Интерфейс Adapter
│ │ ├── dadata/ │ │ ├── dadata/
│ │ └── client.py │ │ └── client.py
│ │ ── yandex_geosuggest/ │ │ ── yandex_geosuggest/
│ │ └── client.py
│ │ └── tomtom/
│ │ └── client.py │ │ └── client.py
│ └── delivery_providers/ │ └── delivery_providers/
│ ├── base.py # Интерфейс Adapter │ ├── base.py # Интерфейс Adapter
@@ -20,7 +20,7 @@ created: 2026-03-14
- `from_location.address` и `to_location.address` считаются уже выбранными точными строками адреса; в рамках этой задачи запрещено добавлять address suggestion routing, внешние address lookup вызовы и нормализацию адреса. - `from_location.address` и `to_location.address` считаются уже выбранными точными строками адреса; в рамках этой задачи запрещено добавлять address suggestion routing, внешние address lookup вызовы и нормализацию адреса.
- Контракт входного запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`. - Контракт входного запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`.
- В scope задачи входят только значения `type=2` и `tariff_code=535`; не расширять поддержку на другие типы заказа и тарифы. - В scope задачи входят только значения `type=2` и `tariff_code=535`; не расширять поддержку на другие типы заказа и тарифы.
- Scope задачи не включает `POST /api/v1/delivery/address/suggest`, конфигурацию address suggestion providers, кеширование, агрегацию тарифов, расчёт стоимости, новые провайдеры и расширение order flow за пределы CDEK. - Scope задачи не включает `POST /api/v1/delivery/suggest-address`, конфигурацию address suggestion providers, кеширование, агрегацию тарифов, расчёт стоимости, новые провайдеры и расширение order flow за пределы CDEK.
- Не изменять файлы в `spec/`. - Не изменять файлы в `spec/`.
## Acceptance criteria ## Acceptance criteria
@@ -9,7 +9,7 @@ created: 2026-03-25
Перед реализацией order flow клиенту нужен отдельный endpoint, который возвращает подсказки адреса и позволяет выбрать точное значение для `from_location.address` и `to_location.address`. Выбор provider должен происходить по `country_code` через конфигурационный маппинг стран. Перед реализацией order flow клиенту нужен отдельный endpoint, который возвращает подсказки адреса и позволяет выбрать точное значение для `from_location.address` и `to_location.address`. Выбор provider должен происходить по `country_code` через конфигурационный маппинг стран.
## Goal ## Goal
Добавить `POST /api/v1/delivery/address/suggest` в существующий controller и существующий service с request/response schemas, routing на address suggestion provider по `country_code` и детерминированным HTTP error mapping. Добавить `POST /api/v1/delivery/suggest-address` в существующий controller и существующий service с request/response schemas, routing на address suggestion provider по `country_code` и детерминированным HTTP error mapping.
## Constraints ## Constraints
- Controller отвечает только за DTO validation, routing и mapping service exceptions в HTTP responses. - Controller отвечает только за DTO validation, routing и mapping service exceptions в HTTP responses.
@@ -33,7 +33,7 @@ created: 2026-03-25
## Definition of Done ## Definition of Done
- [ ] Добавлены address suggestion request/response schemas. - [ ] Добавлены address suggestion request/response schemas.
- [ ] Реализован метод `AggregatorService.suggest_addresses()` для provider routing и orchestration. - [ ] Реализован метод `AggregatorService.suggest_addresses()` для provider routing и orchestration.
- [ ] Реализован endpoint `POST /api/v1/delivery/address-address` в существующем controller. - [ ] Реализован endpoint `POST /api/v1/delivery/suggest-address` в существующем controller.
- [ ] Добавлены service и controller/API tests для address suggestion flow. - [ ] Добавлены service и controller/API tests для address suggestion flow.
## Tests ## Tests
@@ -0,0 +1,56 @@
---
id: 024
title: Add TomTom address suggestion adapter and Europe routing
status: DONE
created: 2026-03-29
---
## Context
Текущий flow подсказок адреса поддерживает `dadata` для `RU`, `BY`, `KZ` и `yandex_geosuggest` для части стран СНГ. Следующий этап требует добавить третий provider `TomTom` для европейских стран, сохранив текущий публичный API и routing через YAML-конфиг.
## Goal
Добавить новый adapter `tomtom` для address suggestions на базе TomTom Search API Fuzzy Search, настроить routing европейских стран на provider id `tomtom` через конфиг и покрыть это config, adapter и service tests без изменения публичного 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/tomtom/`.
- Новый adapter должен следовать официальному HTTP-контракту TomTom Search API Fuzzy Search: `GET https://api.tomtom.com/search/2/search/{query}.json`.
- TomTom adapter должен формировать `query` из `request.city` и `request.query`, передавать `countrySet=request.country_code`, `typeahead=true`, а `request.limit` при наличии маппить в query-параметр `limit`.
- Для исключения POI из address suggestion flow adapter должен отправлять address-oriented `idxSet=PAD,Addr,Str,EPP`.
- Routing европейских стран должен определяться только YAML-маппингом `country_code -> provider_id`; запрещено добавлять в Service hardcoded классификацию Европы.
- Unified mapping наружу должен возвращать только `AddressSuggestion` без утечки provider-specific payload.
- Для TomTom Search поля должны маппиться детерминированно: `address.freeformAddress -> address`, `address.streetName -> street`, `address.streetNumber -> house`, `address.postalCode -> postal_code`, `flat=None`.
- Ошибки TomTom `400` должны маппиться в deterministic provider request error; `403`, `429`, transport errors и `5xx` должны маппиться в adapter client error/unavailable path.
- Scope задачи не включает изменение order flow, price flow, новых endpoint'ов, расширение internal model `AddressSuggestion` и изменение контрактов существующих adapters `dadata` и `yandex_geosuggest`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В конфиге address suggestion providers добавлен provider id `tomtom` с параметрами, необходимыми для вызова TomTom Search API Fuzzy Search.
- Country mapping в конфиге маршрутизирует на `tomtom` европейские страны, явно перечисленные в YAML, при этом routing для `RU`, `BY`, `KZ` на `dadata` и для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` на `yandex_geosuggest` сохраняется без изменений.
- Реализован adapter `app/adapters/address_suggestions/tomtom/client.py`, который отправляет запрос в TomTom Search API Fuzzy Search по официальному контракту и возвращает `list[AddressSuggestion]`.
- Adapter объединяет `request.city` и `request.query` в search query, передаёт `countrySet=request.country_code`, `typeahead=true`, `idxSet=PAD,Addr,Str,EPP` и при наличии `request.limit` маппит его в `limit`.
- Поля `address`, `street`, `house` и `postal_code` детерминированно извлекаются из ответа TomTom (`freeformAddress`, `streetName`, `streetNumber`, `postalCode`), а `flat` возвращается как `None`.
- `AggregatorService.suggest_addresses()` по `country_code` выбирает `tomtom` для европейских стран, явно сопоставленных в YAML-конфиге, и сохраняет существующий routing на `dadata` и `yandex_geosuggest` для уже поддержанных стран.
- При ответе TomTom с `400` service flow возвращает deterministic bad-request path, а при `403`, `429`, `5xx` и transport failure — deterministic unavailable path.
## Definition of Done
- [ ] Добавлена конфигурация `tomtom` и обновлён country mapping для европейских стран.
- [ ] Реализован новый TomTom adapter без утечки HTTP-деталей в Service.
- [ ] Обновлён wiring address suggestion providers без изменения публичного endpoint контракта.
- [ ] Добавлены tests для config, adapter mapping/error handling и service routing на `tomtom` с сохранением существующего routing на `dadata` и `yandex_geosuggest`.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции `tomtom` и country mapping, в котором европейские страны маршрутизируются на `tomtom`, а существующие маппинги `RU`, `BY`, `KZ` -> `dadata` и `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` -> `yandex_geosuggest` сохраняются.
- Добавить `tests/adapters/address_suggestions/tomtom/test_client.py` для success case, mapping `limit`, формирования search query из `city` и `query`, передачи `typeahead=true` и `idxSet=PAD,Addr,Str,EPP`, возврата `flat=None`, извлечения `address`/`street`/`house`/`postal_code` и error scenarios `400`, `403`, `429`, `5xx`.
- Обновить `tests/services/test_address_suggestions.py` для проверки routing на `tomtom` по европейским странам из YAML-конфига и сохранения routing на `dadata` и `yandex_geosuggest` для уже поддержанных стран.
- При необходимости обновить `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/tomtom/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 --check`
@@ -0,0 +1,258 @@
import asyncio
from typing import Any
import httpx
import pytest
from app.adapters.address_suggestions.tomtom.client import (
TomTomAddressSuggestionProvider,
TomTomClientError,
TomTomRequestError,
)
from app.config import TomTomAddressSuggestionsConfig
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": "DE",
"city": "Berlin",
"query": "Alexanderplatz 1",
"limit": 5,
}
payload.update(overrides)
return AddressSuggestRequest(**payload)
def test_tomtom_provider_maps_response_to_unified_model() -> None:
response = httpx.Response(
200,
json={
"results": [
{
"address": {
"freeformAddress": "Alexanderplatz 1, 10178 Berlin",
"streetName": "Alexanderplatz",
"streetNumber": "1",
"postalCode": "10178",
}
}
]
},
request=httpx.Request(
"GET",
"https://api.tomtom.test/search/2/search/Berlin%20Alexanderplatz%201.json",
),
)
http_client = SequenceHTTPClient([response])
provider = TomTomAddressSuggestionProvider(
http_client=http_client, # type: ignore[arg-type]
url="https://api.tomtom.test/search/2/search",
api_key="test-api-key",
timeout_seconds=5.5,
)
result = asyncio.run(provider.suggest(_make_request()))
assert result == [
AddressSuggestion(
address="Alexanderplatz 1, 10178 Berlin",
street="Alexanderplatz",
house="1",
flat=None,
postal_code="10178",
)
]
assert http_client.calls == [
{
"method": "GET",
"url": "https://api.tomtom.test/search/2/search/Berlin%20Alexanderplatz%201.json",
"params": {
"key": "test-api-key",
"countrySet": "DE",
"typeahead": "true",
"idxSet": "PAD,Addr,Str,EPP",
"limit": 5,
},
"timeout": 5.5,
}
]
def test_tomtom_provider_uses_config_factory_and_omits_limit_when_missing() -> None:
response = httpx.Response(
200,
json={
"results": [
{
"address": {
"freeformAddress": "Dam Square, 1012 JS Amsterdam",
"streetName": "Dam Square",
"postalCode": "1012 JS",
}
}
]
},
request=httpx.Request(
"GET",
"https://api.tomtom.test/search/2/search/Amsterdam%20Dam%20Square.json",
),
)
http_client = SequenceHTTPClient([response])
provider = TomTomAddressSuggestionProvider.from_config(
http_client=http_client, # type: ignore[arg-type]
config=TomTomAddressSuggestionsConfig(
url="https://api.tomtom.test/search/2/search",
api_key="config-api-key",
timeout_seconds=4.25,
),
)
result = asyncio.run(
provider.suggest(
_make_request(
country_code="NL",
city="Amsterdam",
query="Dam Square",
limit=None,
)
)
)
assert result == [
AddressSuggestion(
address="Dam Square, 1012 JS Amsterdam",
street="Dam Square",
house=None,
flat=None,
postal_code="1012 JS",
)
]
assert http_client.calls == [
{
"method": "GET",
"url": "https://api.tomtom.test/search/2/search/Amsterdam%20Dam%20Square.json",
"params": {
"key": "config-api-key",
"countrySet": "NL",
"typeahead": "true",
"idxSet": "PAD,Addr,Str,EPP",
},
"timeout": 4.25,
}
]
def test_tomtom_provider_maps_400_to_request_error() -> None:
response = httpx.Response(
400,
json={"errorText": "bad request"},
request=httpx.Request(
"GET",
"https://api.tomtom.test/search/2/search/Berlin%20Alexanderplatz%201.json",
),
)
http_client = SequenceHTTPClient([response])
provider = TomTomAddressSuggestionProvider(
http_client=http_client, # type: ignore[arg-type]
url="https://api.tomtom.test/search/2/search",
api_key="test-api-key",
)
with pytest.raises(TomTomRequestError, match="status 400"):
asyncio.run(provider.suggest(_make_request()))
@pytest.mark.parametrize("status_code", [403, 429, 503])
def test_tomtom_provider_maps_unavailable_statuses_to_client_error(
status_code: int,
) -> None:
response = httpx.Response(
status_code,
json={"errorText": "provider error"},
request=httpx.Request(
"GET",
"https://api.tomtom.test/search/2/search/Berlin%20Alexanderplatz%201.json",
),
)
http_client = SequenceHTTPClient([response])
provider = TomTomAddressSuggestionProvider(
http_client=http_client, # type: ignore[arg-type]
url="https://api.tomtom.test/search/2/search",
api_key="test-api-key",
)
with pytest.raises(TomTomClientError, match=f"status {status_code}"):
asyncio.run(provider.suggest(_make_request()))
def test_tomtom_provider_maps_transport_error_to_client_error() -> None:
transport_error = httpx.ReadTimeout(
"read timeout",
request=httpx.Request(
"GET",
"https://api.tomtom.test/search/2/search/Berlin%20Alexanderplatz%201.json",
),
)
http_client = SequenceHTTPClient([transport_error])
provider = TomTomAddressSuggestionProvider(
http_client=http_client, # type: ignore[arg-type]
url="https://api.tomtom.test/search/2/search",
api_key="test-api-key",
)
with pytest.raises(TomTomClientError, match="request failed"):
asyncio.run(provider.suggest(_make_request()))
def test_tomtom_provider_raises_on_invalid_payload_shape() -> None:
response = httpx.Response(
200,
json={"results": [{"address": {"freeformAddress": 1}}]},
request=httpx.Request(
"GET",
"https://api.tomtom.test/search/2/search/Berlin%20Alexanderplatz%201.json",
),
)
http_client = SequenceHTTPClient([response])
provider = TomTomAddressSuggestionProvider(
http_client=http_client, # type: ignore[arg-type]
url="https://api.tomtom.test/search/2/search",
api_key="test-api-key",
)
with pytest.raises(
TomTomClientError,
match="response payload is invalid",
):
asyncio.run(provider.suggest(_make_request()))
@@ -19,6 +19,7 @@ address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
AM: "yandex_geosuggest" AM: "yandex_geosuggest"
DE: "tomtom"
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"
@@ -26,6 +27,10 @@ address_suggestions:
url: "https://suggest-maps.yandex.ru/v1/suggest" url: "https://suggest-maps.yandex.ru/v1/suggest"
api_key: "default-yandex-key" api_key: "default-yandex-key"
timeout_seconds: 4.0 timeout_seconds: 4.0
tomtom:
url: "https://api.tomtom.com/search/2/search"
api_key: "default-tomtom-key"
timeout_seconds: 4.25
observability: observability:
enabled: false enabled: false
@@ -19,6 +19,7 @@ address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
AM: "yandex_geosuggest" AM: "yandex_geosuggest"
DE: "tomtom"
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"
@@ -27,6 +28,10 @@ address_suggestions:
url: "https://suggest-maps.yandex.ru/v1/suggest" url: "https://suggest-maps.yandex.ru/v1/suggest"
api_key: "override-yandex-key" api_key: "override-yandex-key"
timeout_seconds: 4.5 timeout_seconds: 4.5
tomtom:
url: "https://api.tomtom.com/search/2/search"
api_key: "override-tomtom-key"
timeout_seconds: 5.25
observability: observability:
enabled: true enabled: true
+52 -12
View File
@@ -41,6 +41,51 @@ MISSING_OBSERVABILITY_ENDPOINT_CONFIG_FILE = (
/ "fixtures" / "fixtures"
/ "config.missing_observability_endpoint.yaml" / "config.missing_observability_endpoint.yaml"
) )
_DADATA_COUNTRIES = ("RU", "BY", "KZ")
_YANDEX_COUNTRIES = ("AM", "AZ", "KG", "MD", "TJ", "TM", "UZ")
_TOMTOM_COUNTRIES = (
"AL",
"AT",
"BE",
"BG",
"CH",
"CZ",
"DE",
"DK",
"EE",
"ES",
"FI",
"FR",
"GB",
"GR",
"HR",
"HU",
"IE",
"IT",
"LT",
"LV",
"NL",
"NO",
"PL",
"PT",
"RO",
"RS",
"SE",
"SI",
"SK",
"UA",
)
def _expected_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
},
**{country_code: "tomtom" for country_code in _TOMTOM_COUNTRIES},
}
def _use_runtime_config_files( def _use_runtime_config_files(
@@ -81,18 +126,7 @@ def test_configuration_sections_are_loaded_from_yaml_file(
assert settings.adapter.cdek_retry_backoff_seconds == 0.2 assert settings.adapter.cdek_retry_backoff_seconds == 0.2
assert settings.adapter.cdek_timeout_seconds == 10.0 assert settings.adapter.cdek_timeout_seconds == 10.0
assert settings.adapter.cdek_cache_ttl_seconds == 900 assert settings.adapter.cdek_cache_ttl_seconds == 900
assert settings.address_suggestions.country_to_provider == { assert settings.address_suggestions.country_to_provider == _expected_country_mapping()
"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 ( assert (
settings.address_suggestions.dadata.url settings.address_suggestions.dadata.url
== "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" == "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
@@ -108,6 +142,9 @@ def test_configuration_sections_are_loaded_from_yaml_file(
== "test-yandex-geosuggest-api-key" == "test-yandex-geosuggest-api-key"
) )
assert settings.address_suggestions.yandex_geosuggest.timeout_seconds == 6.5 assert settings.address_suggestions.yandex_geosuggest.timeout_seconds == 6.5
assert settings.address_suggestions.tomtom.url == "https://api.tomtom.com/search/2/search"
assert settings.address_suggestions.tomtom.api_key == "test-tomtom-api-key"
assert settings.address_suggestions.tomtom.timeout_seconds == 5.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"
@@ -178,11 +215,14 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment(
assert settings.address_suggestions.country_to_provider == { assert settings.address_suggestions.country_to_provider == {
"RU": "dadata", "RU": "dadata",
"AM": "yandex_geosuggest", "AM": "yandex_geosuggest",
"DE": "tomtom",
} }
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.api_key == "override-yandex-key"
assert settings.address_suggestions.yandex_geosuggest.timeout_seconds == 4.5 assert settings.address_suggestions.yandex_geosuggest.timeout_seconds == 4.5
assert settings.address_suggestions.tomtom.api_key == "override-tomtom-key"
assert settings.address_suggestions.tomtom.timeout_seconds == 5.25
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"
@@ -99,6 +99,18 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency(
_ = config _ = config
return stub_yandex_provider return stub_yandex_provider
class StubTomTomAddressSuggestionProvider:
@classmethod
def from_config(
cls,
*,
http_client,
config,
) -> StubAddressProvider:
assert http_client is stub_http_client
_ = config
return stub_tomtom_provider
class StubCache: class StubCache:
async def get(self, key: str) -> object | None: async def get(self, key: str) -> object | None:
_ = key _ = key
@@ -138,6 +150,17 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency(
) )
], ],
) )
stub_tomtom_provider = StubAddressProvider(
"tomtom",
[
AddressSuggestion(
address="Alexanderplatz 1, 10178 Berlin",
street="Alexanderplatz",
house="1",
postal_code="10178",
)
],
)
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:
@@ -160,13 +183,18 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency(
"YandexGeosuggestAddressSuggestionProvider", "YandexGeosuggestAddressSuggestionProvider",
StubYandexGeosuggestAddressSuggestionProvider, StubYandexGeosuggestAddressSuggestionProvider,
) )
monkeypatch.setattr(
delivery_controller,
"TomTomAddressSuggestionProvider",
StubTomTomAddressSuggestionProvider,
)
monkeypatch.setattr(delivery_controller, "PriceCache", StubPriceCache) monkeypatch.setattr(delivery_controller, "PriceCache", StubPriceCache)
app = create_app() app = create_app()
request_payload = _valid_payload( request_payload = _valid_payload(
country_code="AM", country_code="DE",
city="Yerevan", city="Berlin",
query="Tumanyan 12", query="Alexanderplatz 1",
) )
async def run_request() -> httpx.Response: async def run_request() -> httpx.Response:
@@ -185,16 +213,17 @@ 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": "Tumanyan Street, 12, Yerevan", "address": "Alexanderplatz 1, 10178 Berlin",
"street": "Tumanyan Street", "street": "Alexanderplatz",
"house": "12", "house": "1",
"flat": None, "flat": None,
"postal_code": None, "postal_code": "10178",
} }
] ]
assert http_client_timeouts == [10.0] assert http_client_timeouts == [10.0]
assert stub_dadata_provider.calls == [] assert stub_dadata_provider.calls == []
assert stub_yandex_provider.calls == [AddressSuggestRequest(**request_payload)] assert stub_yandex_provider.calls == []
assert stub_tomtom_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:
+95 -2
View File
@@ -17,6 +17,38 @@ from app.services.aggregator import (
_DADATA_COUNTRIES = ("RU", "BY", "KZ") _DADATA_COUNTRIES = ("RU", "BY", "KZ")
_YANDEX_COUNTRIES = ("AM", "AZ", "KG", "MD", "TJ", "TM", "UZ") _YANDEX_COUNTRIES = ("AM", "AZ", "KG", "MD", "TJ", "TM", "UZ")
_TOMTOM_COUNTRIES = (
"AL",
"AT",
"BE",
"BG",
"CH",
"CZ",
"DE",
"DK",
"EE",
"ES",
"FI",
"FR",
"GB",
"GR",
"HR",
"HU",
"IE",
"IT",
"LT",
"LV",
"NL",
"NO",
"PL",
"PT",
"RO",
"RS",
"SE",
"SI",
"SK",
"UA",
)
class StubAddressSuggestionProvider: class StubAddressSuggestionProvider:
@@ -74,6 +106,7 @@ def _make_country_mapping() -> dict[str, str]:
country_code: "yandex_geosuggest" country_code: "yandex_geosuggest"
for country_code in _YANDEX_COUNTRIES for country_code in _YANDEX_COUNTRIES
}, },
**{country_code: "tomtom" for country_code in _TOMTOM_COUNTRIES},
} }
@@ -97,9 +130,13 @@ def test_suggest_addresses_routes_dadata_countries_to_dadata(
"yandex_geosuggest", "yandex_geosuggest",
response=[_make_suggestion("Yerevan, Tumanyan 1")], response=[_make_suggestion("Yerevan, Tumanyan 1")],
) )
tomtom = StubAddressSuggestionProvider(
"tomtom",
response=[_make_suggestion("Alexanderplatz 1, 10178 Berlin")],
)
service = AggregatorService( service = AggregatorService(
providers=[], providers=[],
address_suggestion_providers=[dadata, yandex_geosuggest], address_suggestion_providers=[dadata, yandex_geosuggest, tomtom],
address_suggestion_country_to_provider=_make_country_mapping(), address_suggestion_country_to_provider=_make_country_mapping(),
) )
request = _make_request( request = _make_request(
@@ -121,6 +158,7 @@ def test_suggest_addresses_routes_dadata_countries_to_dadata(
] ]
assert dadata.calls == [request] assert dadata.calls == [request]
assert yandex_geosuggest.calls == [] assert yandex_geosuggest.calls == []
assert tomtom.calls == []
@pytest.mark.parametrize("country_code", _YANDEX_COUNTRIES) @pytest.mark.parametrize("country_code", _YANDEX_COUNTRIES)
@@ -135,9 +173,13 @@ def test_suggest_addresses_routes_cis_countries_to_yandex_geosuggest(
"yandex_geosuggest", "yandex_geosuggest",
response=[_make_suggestion("Yerevan, Tumanyan 1")], response=[_make_suggestion("Yerevan, Tumanyan 1")],
) )
tomtom = StubAddressSuggestionProvider(
"tomtom",
response=[_make_suggestion("Alexanderplatz 1, 10178 Berlin")],
)
service = AggregatorService( service = AggregatorService(
providers=[], providers=[],
address_suggestion_providers=[dadata, yandex_geosuggest], address_suggestion_providers=[dadata, yandex_geosuggest, tomtom],
address_suggestion_country_to_provider=_make_country_mapping(), address_suggestion_country_to_provider=_make_country_mapping(),
) )
request = _make_request(country_code=country_code, city="Yerevan", query="Tumanyan") request = _make_request(country_code=country_code, city="Yerevan", query="Tumanyan")
@@ -147,6 +189,57 @@ def test_suggest_addresses_routes_cis_countries_to_yandex_geosuggest(
assert result == [AddressSuggestion(address="Yerevan, Tumanyan 1")] assert result == [AddressSuggestion(address="Yerevan, Tumanyan 1")]
assert dadata.calls == [] assert dadata.calls == []
assert yandex_geosuggest.calls == [request] assert yandex_geosuggest.calls == [request]
assert tomtom.calls == []
@pytest.mark.parametrize("country_code", _TOMTOM_COUNTRIES)
def test_suggest_addresses_routes_european_countries_to_tomtom(
country_code: str,
) -> None:
dadata = StubAddressSuggestionProvider(
"dadata",
response=[_make_suggestion("Moscow, Lenina 1")],
)
yandex_geosuggest = StubAddressSuggestionProvider(
"yandex_geosuggest",
response=[_make_suggestion("Yerevan, Tumanyan 1")],
)
tomtom = StubAddressSuggestionProvider(
"tomtom",
response=[
_make_suggestion(
"Alexanderplatz 1, 10178 Berlin",
street="Alexanderplatz",
house="1",
postal_code="10178",
)
],
)
service = AggregatorService(
providers=[],
address_suggestion_providers=[dadata, yandex_geosuggest, tomtom],
address_suggestion_country_to_provider=_make_country_mapping(),
)
request = _make_request(
country_code=country_code,
city="Berlin",
query="Alexanderplatz 1",
)
result = asyncio.run(service.suggest_addresses(request))
assert result == [
AddressSuggestion(
address="Alexanderplatz 1, 10178 Berlin",
street="Alexanderplatz",
house="1",
flat=None,
postal_code="10178",
)
]
assert dadata.calls == []
assert yandex_geosuggest.calls == []
assert tomtom.calls == [request]
def test_suggest_addresses_raises_for_unsupported_country() -> None: def test_suggest_addresses_raises_for_unsupported_country() -> None: