228 lines
7.5 KiB
Python
228 lines
7.5 KiB
Python
"""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
|