315 lines
8.9 KiB
Python
315 lines
8.9 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
|
|
from local_places.schemas import (
|
|
LatLng,
|
|
LocationResolveRequest,
|
|
LocationResolveResponse,
|
|
PlaceDetails,
|
|
PlaceSummary,
|
|
ResolvedLocation,
|
|
SearchRequest,
|
|
SearchResponse,
|
|
)
|
|
|
|
GOOGLE_PLACES_BASE_URL = os.getenv(
|
|
"GOOGLE_PLACES_BASE_URL", "https://places.googleapis.com/v1"
|
|
)
|
|
logger = logging.getLogger("local_places.google_places")
|
|
|
|
_PRICE_LEVEL_TO_ENUM = {
|
|
0: "PRICE_LEVEL_FREE",
|
|
1: "PRICE_LEVEL_INEXPENSIVE",
|
|
2: "PRICE_LEVEL_MODERATE",
|
|
3: "PRICE_LEVEL_EXPENSIVE",
|
|
4: "PRICE_LEVEL_VERY_EXPENSIVE",
|
|
}
|
|
_ENUM_TO_PRICE_LEVEL = {value: key for key, value in _PRICE_LEVEL_TO_ENUM.items()}
|
|
|
|
_SEARCH_FIELD_MASK = (
|
|
"places.id,"
|
|
"places.displayName,"
|
|
"places.formattedAddress,"
|
|
"places.location,"
|
|
"places.rating,"
|
|
"places.priceLevel,"
|
|
"places.types,"
|
|
"places.currentOpeningHours,"
|
|
"nextPageToken"
|
|
)
|
|
|
|
_DETAILS_FIELD_MASK = (
|
|
"id,"
|
|
"displayName,"
|
|
"formattedAddress,"
|
|
"location,"
|
|
"rating,"
|
|
"priceLevel,"
|
|
"types,"
|
|
"regularOpeningHours,"
|
|
"currentOpeningHours,"
|
|
"nationalPhoneNumber,"
|
|
"websiteUri"
|
|
)
|
|
|
|
_RESOLVE_FIELD_MASK = (
|
|
"places.id,"
|
|
"places.displayName,"
|
|
"places.formattedAddress,"
|
|
"places.location,"
|
|
"places.types"
|
|
)
|
|
|
|
|
|
class _GoogleResponse:
|
|
def __init__(self, response: httpx.Response):
|
|
self.status_code = response.status_code
|
|
self._response = response
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
return self._response.json()
|
|
|
|
@property
|
|
def text(self) -> str:
|
|
return self._response.text
|
|
|
|
|
|
def _api_headers(field_mask: str) -> dict[str, str]:
|
|
api_key = os.getenv("GOOGLE_PLACES_API_KEY")
|
|
if not api_key:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="GOOGLE_PLACES_API_KEY is not set.",
|
|
)
|
|
return {
|
|
"Content-Type": "application/json",
|
|
"X-Goog-Api-Key": api_key,
|
|
"X-Goog-FieldMask": field_mask,
|
|
}
|
|
|
|
|
|
def _request(
|
|
method: str, url: str, payload: dict[str, Any] | None, field_mask: str
|
|
) -> _GoogleResponse:
|
|
try:
|
|
with httpx.Client(timeout=10.0) as client:
|
|
response = client.request(
|
|
method=method,
|
|
url=url,
|
|
headers=_api_headers(field_mask),
|
|
json=payload,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(status_code=502, detail="Google Places API unavailable.") from exc
|
|
|
|
return _GoogleResponse(response)
|
|
|
|
|
|
def _build_text_query(request: SearchRequest) -> str:
|
|
keyword = request.filters.keyword if request.filters else None
|
|
if keyword:
|
|
return f"{request.query} {keyword}".strip()
|
|
return request.query
|
|
|
|
|
|
def _build_search_body(request: SearchRequest) -> dict[str, Any]:
|
|
body: dict[str, Any] = {
|
|
"textQuery": _build_text_query(request),
|
|
"pageSize": request.limit,
|
|
}
|
|
|
|
if request.page_token:
|
|
body["pageToken"] = request.page_token
|
|
|
|
if request.location_bias:
|
|
body["locationBias"] = {
|
|
"circle": {
|
|
"center": {
|
|
"latitude": request.location_bias.lat,
|
|
"longitude": request.location_bias.lng,
|
|
},
|
|
"radius": request.location_bias.radius_m,
|
|
}
|
|
}
|
|
|
|
if request.filters:
|
|
filters = request.filters
|
|
if filters.types:
|
|
body["includedType"] = filters.types[0]
|
|
if filters.open_now is not None:
|
|
body["openNow"] = filters.open_now
|
|
if filters.min_rating is not None:
|
|
body["minRating"] = filters.min_rating
|
|
if filters.price_levels:
|
|
body["priceLevels"] = [
|
|
_PRICE_LEVEL_TO_ENUM[level] for level in filters.price_levels
|
|
]
|
|
|
|
return body
|
|
|
|
|
|
def _parse_lat_lng(raw: dict[str, Any] | None) -> LatLng | None:
|
|
if not raw:
|
|
return None
|
|
latitude = raw.get("latitude")
|
|
longitude = raw.get("longitude")
|
|
if latitude is None or longitude is None:
|
|
return None
|
|
return LatLng(lat=latitude, lng=longitude)
|
|
|
|
|
|
def _parse_display_name(raw: dict[str, Any] | None) -> str | None:
|
|
if not raw:
|
|
return None
|
|
return raw.get("text")
|
|
|
|
|
|
def _parse_open_now(raw: dict[str, Any] | None) -> bool | None:
|
|
if not raw:
|
|
return None
|
|
return raw.get("openNow")
|
|
|
|
|
|
def _parse_hours(raw: dict[str, Any] | None) -> list[str] | None:
|
|
if not raw:
|
|
return None
|
|
return raw.get("weekdayDescriptions")
|
|
|
|
|
|
def _parse_price_level(raw: str | None) -> int | None:
|
|
if not raw:
|
|
return None
|
|
return _ENUM_TO_PRICE_LEVEL.get(raw)
|
|
|
|
|
|
def search_places(request: SearchRequest) -> SearchResponse:
|
|
url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText"
|
|
response = _request("POST", url, _build_search_body(request), _SEARCH_FIELD_MASK)
|
|
|
|
if response.status_code >= 400:
|
|
logger.error(
|
|
"Google Places API error %s. response=%s",
|
|
response.status_code,
|
|
response.text,
|
|
)
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Google Places API error ({response.status_code}).",
|
|
)
|
|
|
|
try:
|
|
payload = response.json()
|
|
except ValueError as exc:
|
|
logger.error(
|
|
"Google Places API returned invalid JSON. response=%s",
|
|
response.text,
|
|
)
|
|
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
|
|
|
|
places = payload.get("places", [])
|
|
results = []
|
|
for place in places:
|
|
results.append(
|
|
PlaceSummary(
|
|
place_id=place.get("id", ""),
|
|
name=_parse_display_name(place.get("displayName")),
|
|
address=place.get("formattedAddress"),
|
|
location=_parse_lat_lng(place.get("location")),
|
|
rating=place.get("rating"),
|
|
price_level=_parse_price_level(place.get("priceLevel")),
|
|
types=place.get("types"),
|
|
open_now=_parse_open_now(place.get("currentOpeningHours")),
|
|
)
|
|
)
|
|
|
|
return SearchResponse(
|
|
results=results,
|
|
next_page_token=payload.get("nextPageToken"),
|
|
)
|
|
|
|
|
|
def get_place_details(place_id: str) -> PlaceDetails:
|
|
url = f"{GOOGLE_PLACES_BASE_URL}/places/{place_id}"
|
|
response = _request("GET", url, None, _DETAILS_FIELD_MASK)
|
|
|
|
if response.status_code >= 400:
|
|
logger.error(
|
|
"Google Places API error %s. response=%s",
|
|
response.status_code,
|
|
response.text,
|
|
)
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Google Places API error ({response.status_code}).",
|
|
)
|
|
|
|
try:
|
|
payload = response.json()
|
|
except ValueError as exc:
|
|
logger.error(
|
|
"Google Places API returned invalid JSON. response=%s",
|
|
response.text,
|
|
)
|
|
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
|
|
|
|
return PlaceDetails(
|
|
place_id=payload.get("id", place_id),
|
|
name=_parse_display_name(payload.get("displayName")),
|
|
address=payload.get("formattedAddress"),
|
|
location=_parse_lat_lng(payload.get("location")),
|
|
rating=payload.get("rating"),
|
|
price_level=_parse_price_level(payload.get("priceLevel")),
|
|
types=payload.get("types"),
|
|
phone=payload.get("nationalPhoneNumber"),
|
|
website=payload.get("websiteUri"),
|
|
hours=_parse_hours(payload.get("regularOpeningHours")),
|
|
open_now=_parse_open_now(payload.get("currentOpeningHours")),
|
|
)
|
|
|
|
|
|
def resolve_locations(request: LocationResolveRequest) -> LocationResolveResponse:
|
|
url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText"
|
|
body = {"textQuery": request.location_text, "pageSize": request.limit}
|
|
response = _request("POST", url, body, _RESOLVE_FIELD_MASK)
|
|
|
|
if response.status_code >= 400:
|
|
logger.error(
|
|
"Google Places API error %s. response=%s",
|
|
response.status_code,
|
|
response.text,
|
|
)
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Google Places API error ({response.status_code}).",
|
|
)
|
|
|
|
try:
|
|
payload = response.json()
|
|
except ValueError as exc:
|
|
logger.error(
|
|
"Google Places API returned invalid JSON. response=%s",
|
|
response.text,
|
|
)
|
|
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
|
|
|
|
places = payload.get("places", [])
|
|
results = []
|
|
for place in places:
|
|
results.append(
|
|
ResolvedLocation(
|
|
place_id=place.get("id", ""),
|
|
name=_parse_display_name(place.get("displayName")),
|
|
address=place.get("formattedAddress"),
|
|
location=_parse_lat_lng(place.get("location")),
|
|
types=place.get("types"),
|
|
)
|
|
)
|
|
|
|
return LocationResolveResponse(results=results)
|