Добавлена dadata
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""Address suggestion adapters."""
|
||||
|
||||
from app.adapters.address_suggestions.base import (
|
||||
AddressSuggestionClientError,
|
||||
AddressSuggestionProvider,
|
||||
AddressSuggestionRequestError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AddressSuggestionClientError",
|
||||
"AddressSuggestionProvider",
|
||||
"AddressSuggestionRequestError",
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Base interface for address suggestion providers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.schemas.request import AddressSuggestRequest
|
||||
from app.schemas.response import AddressSuggestion
|
||||
|
||||
|
||||
class AddressSuggestionProvider(ABC):
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
async def suggest(self, request: AddressSuggestRequest) -> list[AddressSuggestion]:
|
||||
"""Fetch address suggestions for a validated internal request."""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class AddressSuggestionClientError(RuntimeError):
|
||||
"""Raised when an address suggestion adapter call fails."""
|
||||
|
||||
|
||||
class AddressSuggestionRequestError(AddressSuggestionClientError):
|
||||
"""Raised when a provider rejects an address suggestion request."""
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Dadata address suggestion adapter package."""
|
||||
|
||||
from app.adapters.address_suggestions.dadata.client import (
|
||||
DadataAddressSuggestionProvider,
|
||||
DadataClientError,
|
||||
DadataRequestError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DadataAddressSuggestionProvider",
|
||||
"DadataClientError",
|
||||
"DadataRequestError",
|
||||
]
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Dadata address suggestion adapter."""
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.address_suggestions.base import (
|
||||
AddressSuggestionClientError,
|
||||
AddressSuggestionProvider,
|
||||
AddressSuggestionRequestError,
|
||||
)
|
||||
from app.config import DadataAddressSuggestionsConfig
|
||||
from app.schemas.request import AddressSuggestRequest
|
||||
from app.schemas.response import AddressSuggestion
|
||||
|
||||
|
||||
class DadataClientError(AddressSuggestionClientError):
|
||||
"""Raised when Dadata address suggestions fail."""
|
||||
|
||||
|
||||
class DadataRequestError(DadataClientError, AddressSuggestionRequestError):
|
||||
"""Raised when Dadata rejects a suggestion request."""
|
||||
|
||||
|
||||
class DadataAddressSuggestionProvider(AddressSuggestionProvider):
|
||||
name = "dadata"
|
||||
|
||||
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: DadataAddressSuggestionsConfig,
|
||||
) -> "DadataAddressSuggestionProvider":
|
||||
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]:
|
||||
payload = self._build_payload(request)
|
||||
try:
|
||||
response = await self._http_client.post(
|
||||
self._url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Token {self._api_key}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||
raise DadataClientError(
|
||||
"Dadata address suggestion request failed."
|
||||
) from exc
|
||||
|
||||
if 400 <= response.status_code < 500:
|
||||
raise DadataRequestError(
|
||||
"Dadata address suggestion request was rejected with status "
|
||||
f"{response.status_code}."
|
||||
)
|
||||
|
||||
if response.status_code >= 500:
|
||||
raise DadataClientError(
|
||||
"Dadata address suggestion 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 DadataClientError(
|
||||
"Dadata address suggestion returned invalid payload."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
return _map_dadata_response(raw_payload)
|
||||
except ValueError as exc:
|
||||
raise DadataClientError(
|
||||
"Dadata address suggestion response payload is invalid."
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _build_payload(request: AddressSuggestRequest) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"query": f"{request.city.strip()} {request.query.strip()}".strip(),
|
||||
}
|
||||
if request.limit is not None:
|
||||
payload["count"] = request.limit
|
||||
if request.country_code.strip().upper() != "RU":
|
||||
payload["locations"] = [{"country": "*"}]
|
||||
return payload
|
||||
|
||||
|
||||
def _map_dadata_response(payload: object) -> list[AddressSuggestion]:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Dadata response must be a JSON object.")
|
||||
|
||||
raw_suggestions = payload.get("suggestions")
|
||||
if not isinstance(raw_suggestions, list):
|
||||
raise ValueError("Dadata response must include suggestions list.")
|
||||
|
||||
return [_map_dadata_suggestion(item) for item in raw_suggestions]
|
||||
|
||||
|
||||
def _map_dadata_suggestion(payload: object) -> AddressSuggestion:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Dadata suggestion entry must be an object.")
|
||||
|
||||
raw_address = payload.get("unrestricted_value") or payload.get("value")
|
||||
if not isinstance(raw_address, str) or not raw_address.strip():
|
||||
raise ValueError("Dadata suggestion has no valid address value.")
|
||||
|
||||
raw_data = payload.get("data")
|
||||
if raw_data is not None and not isinstance(raw_data, dict):
|
||||
raise ValueError("Dadata suggestion data must be an object when provided.")
|
||||
|
||||
postal_code = None
|
||||
if isinstance(raw_data, dict):
|
||||
raw_postal_code = raw_data.get("postal_code")
|
||||
if raw_postal_code is not None:
|
||||
postal_code = str(raw_postal_code).strip() or None
|
||||
|
||||
return AddressSuggestion(
|
||||
provider="dadata",
|
||||
address=raw_address.strip(),
|
||||
postal_code=postal_code,
|
||||
)
|
||||
+29
-1
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic_settings import (
|
||||
BaseSettings,
|
||||
PydanticBaseSettingsSource,
|
||||
@@ -48,6 +48,30 @@ class AdapterConfig(BaseModel):
|
||||
cdek_cache_ttl_seconds: int = Field(default=900, gt=0)
|
||||
|
||||
|
||||
class DadataAddressSuggestionsConfig(BaseModel):
|
||||
url: str = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||
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
|
||||
)
|
||||
|
||||
@field_validator("country_to_provider")
|
||||
@classmethod
|
||||
def _normalize_country_to_provider(
|
||||
cls,
|
||||
value: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
str(country_code).strip().upper(): str(provider_id).strip().lower()
|
||||
for country_code, provider_id in value.items()
|
||||
}
|
||||
|
||||
|
||||
class ObservabilityConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
service_name: str = Field(..., min_length=1)
|
||||
@@ -65,6 +89,9 @@ class Settings(BaseSettings):
|
||||
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
|
||||
repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
|
||||
adapter: AdapterConfig = Field(default_factory=AdapterConfig)
|
||||
address_suggestions: AddressSuggestionsConfig = Field(
|
||||
default_factory=AddressSuggestionsConfig
|
||||
)
|
||||
observability: ObservabilityConfig
|
||||
|
||||
@classmethod
|
||||
@@ -93,6 +120,7 @@ class _RequiredYamlSections(BaseModel):
|
||||
business_logic: dict[str, Any]
|
||||
repository: dict[str, Any]
|
||||
adapter: dict[str, Any]
|
||||
address_suggestions: dict[str, Any]
|
||||
observability: dict[str, Any]
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Input schemas for price calculation."""
|
||||
"""Input schemas for delivery-related API requests."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
@@ -24,3 +24,10 @@ class DeliveryCalculationRequest(BaseModel):
|
||||
width_cm: float = Field(gt=0)
|
||||
height_cm: float = Field(gt=0)
|
||||
parcel_type: ParcelType | None = None
|
||||
|
||||
|
||||
class AddressSuggestRequest(BaseModel):
|
||||
country_code: str = Field(min_length=2, max_length=2)
|
||||
city: str = Field(min_length=1)
|
||||
query: str = Field(min_length=1)
|
||||
limit: int | None = Field(default=None, gt=0)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Output schemas for aggregated prices."""
|
||||
"""Output schemas for delivery-related API responses."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -12,3 +12,9 @@ class DeliveryPrice(BaseModel):
|
||||
currency: str = Field(min_length=3, max_length=3)
|
||||
delivery_days_min: int = Field(ge=0)
|
||||
delivery_days_max: int = Field(ge=0)
|
||||
|
||||
|
||||
class AddressSuggestion(BaseModel):
|
||||
provider: str = Field(min_length=1)
|
||||
address: str = Field(min_length=1)
|
||||
postal_code: str | None = None
|
||||
|
||||
Reference in New Issue
Block a user