Добавлен yandex geosuggest

This commit is contained in:
Раис Юсупалиев
2026-03-29 13:51:56 +03:00
parent 4a3737999f
commit 44893e01ae
16 changed files with 750 additions and 49 deletions
@@ -0,0 +1,13 @@
"""Yandex Geosuggest address suggestion adapter package."""
from app.adapters.address_suggestions.yandex_geosuggest.client import (
YandexGeosuggestAddressSuggestionProvider,
YandexGeosuggestClientError,
YandexGeosuggestRequestError,
)
__all__ = [
"YandexGeosuggestAddressSuggestionProvider",
"YandexGeosuggestClientError",
"YandexGeosuggestRequestError",
]
@@ -0,0 +1,227 @@
"""Yandex Geosuggest address suggestion adapter."""
from typing import Any
import httpx
from app.adapters.address_suggestions.base import (
AddressSuggestionClientError,
AddressSuggestionProvider,
AddressSuggestionRequestError,
)
from app.config import YandexGeosuggestAddressSuggestionsConfig
from app.schemas.request import AddressSuggestRequest
from app.schemas.response import AddressSuggestion
_STREET_KINDS = frozenset({"street"})
_HOUSE_KINDS = frozenset({"house"})
_FLAT_KINDS = frozenset({"apartment", "flat"})
class YandexGeosuggestClientError(AddressSuggestionClientError):
"""Raised when Yandex Geosuggest requests fail."""
class YandexGeosuggestRequestError(
YandexGeosuggestClientError,
AddressSuggestionRequestError,
):
"""Raised when Yandex Geosuggest rejects request parameters."""
class YandexGeosuggestAddressSuggestionProvider(AddressSuggestionProvider):
name = "yandex_geosuggest"
def __init__(
self,
http_client: httpx.AsyncClient,
*,
url: str,
api_key: str,
timeout_seconds: float = 10.0,
) -> None:
self._http_client = http_client
self._url = url
self._api_key = api_key
self._timeout_seconds = timeout_seconds
@classmethod
def from_config(
cls,
*,
http_client: httpx.AsyncClient,
config: YandexGeosuggestAddressSuggestionsConfig,
) -> "YandexGeosuggestAddressSuggestionProvider":
return cls(
http_client=http_client,
url=config.url,
api_key=config.api_key,
timeout_seconds=config.timeout_seconds,
)
async def suggest(self, request: AddressSuggestRequest) -> list[AddressSuggestion]:
params = self._build_query_params(request)
try:
response = await self._http_client.get(
self._url,
params=params,
timeout=self._timeout_seconds,
)
except (httpx.TimeoutException, httpx.TransportError) as exc:
raise YandexGeosuggestClientError(
"Yandex Geosuggest request failed."
) from exc
if response.status_code == 400:
raise YandexGeosuggestRequestError(
"Yandex Geosuggest request was rejected with status 400."
)
if response.status_code == 403 or response.status_code == 429:
raise YandexGeosuggestClientError(
"Yandex Geosuggest request failed with status "
f"{response.status_code}."
)
if response.status_code >= 500 or 400 <= response.status_code < 500:
raise YandexGeosuggestClientError(
"Yandex Geosuggest request failed with status "
f"{response.status_code}."
)
try:
response.raise_for_status()
raw_payload = response.json()
except (httpx.HTTPError, TypeError, ValueError) as exc:
raise YandexGeosuggestClientError(
"Yandex Geosuggest returned invalid payload."
) from exc
try:
return _map_yandex_geosuggest_response(raw_payload)
except ValueError as exc:
raise YandexGeosuggestClientError(
"Yandex Geosuggest response payload is invalid."
) from exc
def _build_query_params(self, request: AddressSuggestRequest) -> dict[str, Any]:
params: dict[str, Any] = {
"apikey": self._api_key,
"text": f"{request.city.strip()} {request.query.strip()}".strip(),
"print_address": 1,
}
if request.limit is not None:
params["results"] = request.limit
return params
def _map_yandex_geosuggest_response(payload: object) -> list[AddressSuggestion]:
if not isinstance(payload, dict):
raise ValueError("Yandex Geosuggest response must be a JSON object.")
raw_results = payload.get("results")
if raw_results is None:
return []
if not isinstance(raw_results, list):
raise ValueError("Yandex Geosuggest response must include results list.")
return [_map_yandex_geosuggest_result(item) for item in raw_results]
def _map_yandex_geosuggest_result(payload: object) -> AddressSuggestion:
if not isinstance(payload, dict):
raise ValueError("Yandex Geosuggest result entry must be an object.")
raw_address_payload = payload.get("address")
if raw_address_payload is not None and not isinstance(raw_address_payload, dict):
raise ValueError("Yandex Geosuggest address payload must be an object.")
address_payload = raw_address_payload if isinstance(raw_address_payload, dict) else {}
raw_components = address_payload.get("component")
if raw_components is None:
components: list[object] = []
elif isinstance(raw_components, list):
components = raw_components
else:
raise ValueError("Yandex Geosuggest address components must be a list.")
address = _extract_formatted_address(address_payload) or _extract_title_text(payload)
if address is None:
raise ValueError("Yandex Geosuggest result has no valid address value.")
return AddressSuggestion(
address=address,
street=_extract_component_value(components, _STREET_KINDS),
house=_extract_component_value(components, _HOUSE_KINDS),
flat=_extract_component_value(components, _FLAT_KINDS),
postal_code=None,
)
def _extract_formatted_address(payload: dict[str, Any]) -> str | None:
value = payload.get("formatted_address")
return _coerce_optional_text(value)
def _extract_title_text(payload: dict[str, Any]) -> str | None:
raw_title = payload.get("title")
if raw_title is None:
return None
if not isinstance(raw_title, dict):
raise ValueError("Yandex Geosuggest title payload must be an object.")
return _coerce_optional_text(raw_title.get("text"))
def _extract_component_value(
components: list[object],
expected_kinds: frozenset[str],
) -> str | None:
for component in components:
if not isinstance(component, dict):
raise ValueError(
"Yandex Geosuggest address component entry must be an object."
)
if not _component_has_kind(component, expected_kinds):
continue
raw_name = component.get("name")
if raw_name is None:
return None
if not isinstance(raw_name, str):
raise ValueError(
"Yandex Geosuggest address component name must be a string."
)
return raw_name.strip() or None
return None
def _component_has_kind(component: dict[str, Any], expected_kinds: frozenset[str]) -> bool:
raw_kind = component.get("kind")
if isinstance(raw_kind, str):
kinds = [raw_kind]
elif isinstance(raw_kind, list):
kinds = raw_kind
else:
raise ValueError("Yandex Geosuggest address component kind must be provided.")
normalized_kinds: set[str] = set()
for kind in kinds:
if not isinstance(kind, str):
raise ValueError(
"Yandex Geosuggest address component kind must be a string."
)
normalized_kind = kind.strip().lower()
if normalized_kind:
normalized_kinds.add(normalized_kind)
return not expected_kinds.isdisjoint(normalized_kinds)
def _coerce_optional_text(value: object) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise ValueError("Yandex Geosuggest text field must be a string.")
return value.strip() or None
+9
View File
@@ -54,11 +54,20 @@ class DadataAddressSuggestionsConfig(BaseModel):
timeout_seconds: float = Field(default=10.0, gt=0)
class YandexGeosuggestAddressSuggestionsConfig(BaseModel):
url: str = "https://suggest-maps.yandex.ru/v1/suggest"
api_key: str = ""
timeout_seconds: float = Field(default=10.0, gt=0)
class AddressSuggestionsConfig(BaseModel):
country_to_provider: dict[str, str] = Field(default_factory=dict)
dadata: DadataAddressSuggestionsConfig = Field(
default_factory=DadataAddressSuggestionsConfig
)
yandex_geosuggest: YandexGeosuggestAddressSuggestionsConfig = Field(
default_factory=YandexGeosuggestAddressSuggestionsConfig
)
@field_validator("country_to_provider")
@classmethod
+8 -1
View File
@@ -3,6 +3,9 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.adapters.address_suggestions.dadata import DadataAddressSuggestionProvider
from app.adapters.address_suggestions.yandex_geosuggest import (
YandexGeosuggestAddressSuggestionProvider,
)
from app.adapters.delivery_providers.cdek import CDEKProvider
from app.config import Settings
from app.controllers.http_client import build_controller_http_client
@@ -38,13 +41,17 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client=http_client,
config=settings.address_suggestions.dadata,
)
yandex_geosuggest_provider = YandexGeosuggestAddressSuggestionProvider.from_config(
http_client=http_client,
config=settings.address_suggestions.yandex_geosuggest,
)
providers = (cdek_provider,)
cache = PriceCache.from_repository_config(settings.repository)
service = AggregatorService(
providers=providers,
cache=cache,
order_adapter=cdek_provider,
address_suggestion_providers=(dadata_provider,),
address_suggestion_providers=(dadata_provider, yandex_geosuggest_provider),
address_suggestion_country_to_provider=(
settings.address_suggestions.country_to_provider
),