Spaces:
Running
Running
Commit ·
1912d07
1
Parent(s): 947ea10
feat: google apis added
Browse files- .gitignore +2 -1
- app/api/v1/google_maps.py +668 -0
- app/api/v1/router.py +2 -0
- app/config.py +7 -0
- app/models/schemas.py +175 -0
- app/services/google_maps_service.py +398 -0
.gitignore
CHANGED
|
@@ -136,4 +136,5 @@ deploy_hf.py
|
|
| 136 |
|
| 137 |
ddl
|
| 138 |
API_DESCRIPTION.md
|
| 139 |
-
ENTERPRISE-API-ROADMAP*.md
|
|
|
|
|
|
| 136 |
|
| 137 |
ddl
|
| 138 |
API_DESCRIPTION.md
|
| 139 |
+
ENTERPRISE-API-ROADMAP*.md
|
| 140 |
+
API-Implementation-Plan
|
app/api/v1/google_maps.py
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from typing import Any, Optional
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, Header, Query, Response
|
| 7 |
+
|
| 8 |
+
from app.api.deps import require_auth
|
| 9 |
+
from app.core.logger import get_logger
|
| 10 |
+
from app.models.schemas import (
|
| 11 |
+
GoogleAutocompleteMatchedSubstring,
|
| 12 |
+
GoogleAutocompletePrediction,
|
| 13 |
+
GoogleAutocompleteStructuredFormatting,
|
| 14 |
+
GoogleAutocompleteTerm,
|
| 15 |
+
GoogleGeocodeRequest,
|
| 16 |
+
GoogleGeocodeResponse,
|
| 17 |
+
GoogleGeocodeResult,
|
| 18 |
+
GooglePlaceAutocompleteRequest,
|
| 19 |
+
GooglePlaceAutocompleteResponse,
|
| 20 |
+
GooglePlaceDetailsRequest,
|
| 21 |
+
GooglePlaceDetailsResponse,
|
| 22 |
+
GooglePlacePhoto,
|
| 23 |
+
GooglePlaceResult,
|
| 24 |
+
GooglePlacesNearbyRequest,
|
| 25 |
+
GooglePlacesNearbyResponse,
|
| 26 |
+
GooglePlacesSearchRequest,
|
| 27 |
+
GooglePlacesSearchResponse,
|
| 28 |
+
GoogleQueryAutocompleteRequest,
|
| 29 |
+
GoogleQueryAutocompleteResponse,
|
| 30 |
+
GoogleReverseGeocodeRequest,
|
| 31 |
+
)
|
| 32 |
+
from app.services.google_maps_service import GoogleMapsService, PRICE_LEVEL_MAP
|
| 33 |
+
|
| 34 |
+
router = APIRouter(prefix="/google", tags=["Google Maps"])
|
| 35 |
+
_logger = get_logger(__name__)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_maps_service() -> GoogleMapsService:
|
| 39 |
+
return GoogleMapsService()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# Legacy response builders (for Geocoding — still on Google Geocoding API)
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
def _build_geocode_results(data: dict) -> list[GoogleGeocodeResult]:
|
| 47 |
+
results: list[GoogleGeocodeResult] = []
|
| 48 |
+
for r in data.get("results", []):
|
| 49 |
+
geometry = r.get("geometry", {})
|
| 50 |
+
location = geometry.get("location", {})
|
| 51 |
+
results.append(GoogleGeocodeResult(
|
| 52 |
+
formatted_address=r.get("formatted_address", ""),
|
| 53 |
+
place_id=r.get("place_id", ""),
|
| 54 |
+
latitude=location.get("lat", 0.0),
|
| 55 |
+
longitude=location.get("lng", 0.0),
|
| 56 |
+
location_type=geometry.get("location_type", ""),
|
| 57 |
+
address_components=r.get("address_components", []),
|
| 58 |
+
types=r.get("types", []),
|
| 59 |
+
))
|
| 60 |
+
return results
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
# New Places API response builders
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
|
| 67 |
+
def _parse_photos(photos_raw: list[dict]) -> list[GooglePlacePhoto]:
|
| 68 |
+
photos: list[GooglePlacePhoto] = []
|
| 69 |
+
for p in photos_raw:
|
| 70 |
+
photos.append(GooglePlacePhoto(
|
| 71 |
+
photo_reference=p.get("name", ""),
|
| 72 |
+
height=p.get("heightPx", 0),
|
| 73 |
+
width=p.get("widthPx", 0),
|
| 74 |
+
html_attributions=[
|
| 75 |
+
a.get("displayName", "") for a in p.get("authorAttributions", [])
|
| 76 |
+
],
|
| 77 |
+
))
|
| 78 |
+
return photos
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _build_place_results_new(data: dict) -> list[GooglePlaceResult]:
|
| 82 |
+
results: list[GooglePlaceResult] = []
|
| 83 |
+
for p in data.get("places", []):
|
| 84 |
+
loc = p.get("location", {})
|
| 85 |
+
price_level_str = p.get("priceLevel", "")
|
| 86 |
+
results.append(GooglePlaceResult(
|
| 87 |
+
place_id=p.get("id", ""),
|
| 88 |
+
name=p.get("displayName", {}).get("text", ""),
|
| 89 |
+
formatted_address=p.get("formattedAddress", ""),
|
| 90 |
+
latitude=loc.get("latitude", 0.0),
|
| 91 |
+
longitude=loc.get("longitude", 0.0),
|
| 92 |
+
rating=p.get("rating"),
|
| 93 |
+
user_ratings_total=p.get("userRatingCount"),
|
| 94 |
+
price_level=PRICE_LEVEL_MAP.get(price_level_str) if price_level_str else None,
|
| 95 |
+
types=p.get("types", []),
|
| 96 |
+
vicinity=p.get("shortFormattedAddress", ""),
|
| 97 |
+
business_status=p.get("businessStatus", ""),
|
| 98 |
+
photos=_parse_photos(p.get("photos", [])),
|
| 99 |
+
plus_code=p.get("plusCode", {}).get("globalCode") if p.get("plusCode") else None,
|
| 100 |
+
icon=p.get("iconMaskBaseUri"),
|
| 101 |
+
opening_hours=p.get("regularOpeningHours") or p.get("currentOpeningHours"),
|
| 102 |
+
website=p.get("websiteUri"),
|
| 103 |
+
formatted_phone_number=p.get("nationalPhoneNumber"),
|
| 104 |
+
international_phone_number=p.get("internationalPhoneNumber"),
|
| 105 |
+
google_maps_uri=p.get("googleMapsUri"),
|
| 106 |
+
))
|
| 107 |
+
return results
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _build_place_detail_new(data: dict) -> Optional[GooglePlaceResult]:
|
| 111 |
+
if not data or not data.get("id"):
|
| 112 |
+
return None
|
| 113 |
+
loc = data.get("location", {})
|
| 114 |
+
price_level_str = data.get("priceLevel", "")
|
| 115 |
+
return GooglePlaceResult(
|
| 116 |
+
place_id=data.get("id", ""),
|
| 117 |
+
name=data.get("displayName", {}).get("text", ""),
|
| 118 |
+
formatted_address=data.get("formattedAddress", ""),
|
| 119 |
+
latitude=loc.get("latitude", 0.0),
|
| 120 |
+
longitude=loc.get("longitude", 0.0),
|
| 121 |
+
rating=data.get("rating"),
|
| 122 |
+
user_ratings_total=data.get("userRatingCount"),
|
| 123 |
+
price_level=PRICE_LEVEL_MAP.get(price_level_str) if price_level_str else None,
|
| 124 |
+
types=data.get("types", []),
|
| 125 |
+
vicinity=data.get("shortFormattedAddress", ""),
|
| 126 |
+
business_status=data.get("businessStatus", ""),
|
| 127 |
+
photos=_parse_photos(data.get("photos", [])),
|
| 128 |
+
plus_code=data.get("plusCode", {}).get("globalCode") if data.get("plusCode") else None,
|
| 129 |
+
icon=data.get("iconMaskBaseUri"),
|
| 130 |
+
opening_hours=data.get("regularOpeningHours") or data.get("currentOpeningHours"),
|
| 131 |
+
website=data.get("websiteUri"),
|
| 132 |
+
formatted_phone_number=data.get("nationalPhoneNumber"),
|
| 133 |
+
international_phone_number=data.get("internationalPhoneNumber"),
|
| 134 |
+
google_maps_uri=data.get("googleMapsUri"),
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _build_autocomplete_predictions_new(data: dict) -> list[GoogleAutocompletePrediction]:
|
| 139 |
+
predictions: list[GoogleAutocompletePrediction] = []
|
| 140 |
+
for s in data.get("suggestions", []):
|
| 141 |
+
pp = s.get("placePrediction") or s.get("queryPrediction")
|
| 142 |
+
if not pp:
|
| 143 |
+
continue
|
| 144 |
+
|
| 145 |
+
text = pp.get("text", {})
|
| 146 |
+
description = text.get("text", "") if isinstance(text, dict) else str(text)
|
| 147 |
+
place_id = pp.get("placeId", "")
|
| 148 |
+
|
| 149 |
+
sf_raw = pp.get("structuredFormat", {}) or pp.get("structuredFormatting", {})
|
| 150 |
+
sf = None
|
| 151 |
+
if sf_raw:
|
| 152 |
+
main_text = sf_raw.get("mainText", {})
|
| 153 |
+
if isinstance(main_text, dict):
|
| 154 |
+
main_text = main_text.get("text", "")
|
| 155 |
+
secondary_text = sf_raw.get("secondaryText", {})
|
| 156 |
+
if isinstance(secondary_text, dict):
|
| 157 |
+
secondary_text = secondary_text.get("text", "")
|
| 158 |
+
main_matches_raw = sf_raw.get("mainTextMatchedSubstrings", [])
|
| 159 |
+
secondary_matches_raw = sf_raw.get("secondaryTextMatchedSubstrings", [])
|
| 160 |
+
sf = GoogleAutocompleteStructuredFormatting(
|
| 161 |
+
main_text=main_text,
|
| 162 |
+
main_text_matched_substrings=[
|
| 163 |
+
GoogleAutocompleteMatchedSubstring(**m) for m in main_matches_raw
|
| 164 |
+
],
|
| 165 |
+
secondary_text=secondary_text,
|
| 166 |
+
secondary_text_matched_substrings=[
|
| 167 |
+
GoogleAutocompleteMatchedSubstring(**m) for m in secondary_matches_raw
|
| 168 |
+
],
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
terms = [
|
| 172 |
+
GoogleAutocompleteTerm(offset=0, value=description)
|
| 173 |
+
]
|
| 174 |
+
types = pp.get("types", [])
|
| 175 |
+
|
| 176 |
+
predictions.append(GoogleAutocompletePrediction(
|
| 177 |
+
description=description,
|
| 178 |
+
place_id=place_id,
|
| 179 |
+
structured_formatting=sf,
|
| 180 |
+
terms=terms,
|
| 181 |
+
types=types,
|
| 182 |
+
matched_substrings=[],
|
| 183 |
+
distance_meters=pp.get("distanceMeters"),
|
| 184 |
+
))
|
| 185 |
+
return predictions
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _build_autocomplete_predictions_legacy(data: dict) -> list[GoogleAutocompletePrediction]:
|
| 189 |
+
predictions: list[GoogleAutocompletePrediction] = []
|
| 190 |
+
for p in data.get("predictions", []):
|
| 191 |
+
sf_raw = p.get("structured_formatting")
|
| 192 |
+
sf = None
|
| 193 |
+
if sf_raw:
|
| 194 |
+
main_matches = [
|
| 195 |
+
GoogleAutocompleteMatchedSubstring(**m)
|
| 196 |
+
for m in sf_raw.get("main_text_matched_substrings", [])
|
| 197 |
+
]
|
| 198 |
+
secondary_matches = [
|
| 199 |
+
GoogleAutocompleteMatchedSubstring(**m)
|
| 200 |
+
for m in sf_raw.get("secondary_text_matched_substrings", [])
|
| 201 |
+
]
|
| 202 |
+
sf = GoogleAutocompleteStructuredFormatting(
|
| 203 |
+
main_text=sf_raw.get("main_text", ""),
|
| 204 |
+
main_text_matched_substrings=main_matches,
|
| 205 |
+
secondary_text=sf_raw.get("secondary_text", ""),
|
| 206 |
+
secondary_text_matched_substrings=secondary_matches,
|
| 207 |
+
)
|
| 208 |
+
terms = [GoogleAutocompleteTerm(**t) for t in p.get("terms", [])]
|
| 209 |
+
matched_subs = [
|
| 210 |
+
GoogleAutocompleteMatchedSubstring(**m)
|
| 211 |
+
for m in p.get("matched_substrings", [])
|
| 212 |
+
]
|
| 213 |
+
predictions.append(GoogleAutocompletePrediction(
|
| 214 |
+
description=p.get("description", ""),
|
| 215 |
+
place_id=p.get("place_id", ""),
|
| 216 |
+
structured_formatting=sf,
|
| 217 |
+
terms=terms,
|
| 218 |
+
types=p.get("types", []),
|
| 219 |
+
matched_substrings=matched_subs,
|
| 220 |
+
distance_meters=p.get("distance_meters"),
|
| 221 |
+
))
|
| 222 |
+
return predictions
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ---------------------------------------------------------------------------
|
| 226 |
+
# Routes — Autocomplete
|
| 227 |
+
# ---------------------------------------------------------------------------
|
| 228 |
+
|
| 229 |
+
@router.post("/places/autocomplete", response_model=GooglePlaceAutocompleteResponse,
|
| 230 |
+
summary="Get place predictions for autocomplete input (New Places API)")
|
| 231 |
+
async def place_autocomplete(
|
| 232 |
+
body: GooglePlaceAutocompleteRequest,
|
| 233 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 234 |
+
token: str = Depends(require_auth),
|
| 235 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 236 |
+
):
|
| 237 |
+
start = time.perf_counter()
|
| 238 |
+
result = await service.place_autocomplete(
|
| 239 |
+
input=body.input,
|
| 240 |
+
offset=body.offset,
|
| 241 |
+
origin=body.origin,
|
| 242 |
+
location=body.location,
|
| 243 |
+
radius=body.radius,
|
| 244 |
+
language=body.language,
|
| 245 |
+
types=body.types,
|
| 246 |
+
components=body.components,
|
| 247 |
+
strictbounds=body.strictbounds,
|
| 248 |
+
sessiontoken=body.sessiontoken,
|
| 249 |
+
api_key=x_goog_api_key,
|
| 250 |
+
)
|
| 251 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 252 |
+
error = result.get("error")
|
| 253 |
+
if error:
|
| 254 |
+
return GooglePlaceAutocompleteResponse(success=False, time_ms=elapsed_ms, predictions=[], count=0, error=error)
|
| 255 |
+
data = result.get("data", {})
|
| 256 |
+
predictions = _build_autocomplete_predictions_new(data)
|
| 257 |
+
return GooglePlaceAutocompleteResponse(success=True, time_ms=elapsed_ms, predictions=predictions, count=len(predictions))
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
@router.get("/places/autocomplete", response_model=GooglePlaceAutocompleteResponse,
|
| 261 |
+
summary="Get place predictions for autocomplete input (GET, New Places API)")
|
| 262 |
+
async def place_autocomplete_get(
|
| 263 |
+
input: str = Query(..., min_length=1, max_length=1000, description="Text string to search for"),
|
| 264 |
+
offset: Optional[int] = Query(None, ge=1),
|
| 265 |
+
origin: Optional[str] = Query(None, pattern=r"^-?\d+\.?\d*,-?\d+\.?\d*$"),
|
| 266 |
+
location: Optional[str] = Query(None, pattern=r"^-?\d+\.?\d*,-?\d+\.?\d*$"),
|
| 267 |
+
radius: Optional[int] = Query(None, ge=1, le=50000),
|
| 268 |
+
language: Optional[str] = Query(None, max_length=10),
|
| 269 |
+
types: Optional[str] = Query(None),
|
| 270 |
+
components: Optional[str] = Query(None),
|
| 271 |
+
strictbounds: Optional[bool] = Query(None),
|
| 272 |
+
sessiontoken: Optional[str] = Query(None),
|
| 273 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 274 |
+
token: str = Depends(require_auth),
|
| 275 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 276 |
+
):
|
| 277 |
+
start = time.perf_counter()
|
| 278 |
+
result = await service.place_autocomplete(
|
| 279 |
+
input=input, offset=offset, origin=origin, location=location,
|
| 280 |
+
radius=radius, language=language, types=types, components=components,
|
| 281 |
+
strictbounds=strictbounds, sessiontoken=sessiontoken,
|
| 282 |
+
api_key=x_goog_api_key,
|
| 283 |
+
)
|
| 284 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 285 |
+
error = result.get("error")
|
| 286 |
+
if error:
|
| 287 |
+
return GooglePlaceAutocompleteResponse(success=False, time_ms=elapsed_ms, predictions=[], count=0, error=error)
|
| 288 |
+
data = result.get("data", {})
|
| 289 |
+
predictions = _build_autocomplete_predictions_new(data)
|
| 290 |
+
return GooglePlaceAutocompleteResponse(success=True, time_ms=elapsed_ms, predictions=predictions, count=len(predictions))
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@router.post("/places/queryautocomplete", response_model=GoogleQueryAutocompleteResponse,
|
| 294 |
+
summary="Get query predictions for autocomplete input (New Places API)")
|
| 295 |
+
async def query_autocomplete(
|
| 296 |
+
body: GoogleQueryAutocompleteRequest,
|
| 297 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 298 |
+
token: str = Depends(require_auth),
|
| 299 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 300 |
+
):
|
| 301 |
+
start = time.perf_counter()
|
| 302 |
+
result = await service.query_autocomplete(
|
| 303 |
+
input=body.input,
|
| 304 |
+
offset=body.offset,
|
| 305 |
+
location=body.location,
|
| 306 |
+
radius=body.radius,
|
| 307 |
+
language=body.language,
|
| 308 |
+
api_key=x_goog_api_key,
|
| 309 |
+
)
|
| 310 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 311 |
+
error = result.get("error")
|
| 312 |
+
if error:
|
| 313 |
+
return GoogleQueryAutocompleteResponse(success=False, time_ms=elapsed_ms, predictions=[], count=0, error=error)
|
| 314 |
+
data = result.get("data", {})
|
| 315 |
+
predictions = _build_autocomplete_predictions_new(data)
|
| 316 |
+
return GoogleQueryAutocompleteResponse(success=True, time_ms=elapsed_ms, predictions=predictions, count=len(predictions))
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
@router.get("/places/queryautocomplete", response_model=GoogleQueryAutocompleteResponse,
|
| 320 |
+
summary="Get query predictions for autocomplete input (GET, New Places API)")
|
| 321 |
+
async def query_autocomplete_get(
|
| 322 |
+
input: str = Query(..., min_length=1, max_length=1000, description="Text string to search for"),
|
| 323 |
+
offset: Optional[int] = Query(None, ge=1),
|
| 324 |
+
location: Optional[str] = Query(None, pattern=r"^-?\d+\.?\d*,-?\d+\.?\d*$"),
|
| 325 |
+
radius: Optional[int] = Query(None, ge=1, le=50000),
|
| 326 |
+
language: Optional[str] = Query(None, max_length=10),
|
| 327 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 328 |
+
token: str = Depends(require_auth),
|
| 329 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 330 |
+
):
|
| 331 |
+
start = time.perf_counter()
|
| 332 |
+
result = await service.query_autocomplete(
|
| 333 |
+
input=input, offset=offset, location=location,
|
| 334 |
+
radius=radius, language=language,
|
| 335 |
+
api_key=x_goog_api_key,
|
| 336 |
+
)
|
| 337 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 338 |
+
error = result.get("error")
|
| 339 |
+
if error:
|
| 340 |
+
return GoogleQueryAutocompleteResponse(success=False, time_ms=elapsed_ms, predictions=[], count=0, error=error)
|
| 341 |
+
data = result.get("data", {})
|
| 342 |
+
predictions = _build_autocomplete_predictions_new(data)
|
| 343 |
+
return GoogleQueryAutocompleteResponse(success=True, time_ms=elapsed_ms, predictions=predictions, count=len(predictions))
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
# ---------------------------------------------------------------------------
|
| 347 |
+
# Routes — Geocoding (still uses legacy API)
|
| 348 |
+
# ---------------------------------------------------------------------------
|
| 349 |
+
|
| 350 |
+
@router.post("/geocode", response_model=GoogleGeocodeResponse, summary="Forward geocode an address")
|
| 351 |
+
async def geocode(
|
| 352 |
+
body: GoogleGeocodeRequest,
|
| 353 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 354 |
+
token: str = Depends(require_auth),
|
| 355 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 356 |
+
):
|
| 357 |
+
start = time.perf_counter()
|
| 358 |
+
result = await service.geocode(
|
| 359 |
+
address=body.address,
|
| 360 |
+
region=body.region,
|
| 361 |
+
language=body.language,
|
| 362 |
+
bounds=body.bounds,
|
| 363 |
+
api_key=x_goog_api_key,
|
| 364 |
+
)
|
| 365 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 366 |
+
error = result.get("error")
|
| 367 |
+
if error:
|
| 368 |
+
return GoogleGeocodeResponse(success=False, time_ms=elapsed_ms, results=[], count=0, error=error)
|
| 369 |
+
data = result.get("data", {})
|
| 370 |
+
results = _build_geocode_results(data)
|
| 371 |
+
return GoogleGeocodeResponse(success=True, time_ms=elapsed_ms, results=results, count=len(results))
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
@router.get("/geocode", response_model=GoogleGeocodeResponse, summary="Forward geocode an address (GET)")
|
| 375 |
+
async def geocode_get(
|
| 376 |
+
address: str = Query(..., min_length=1, max_length=1000, description="Street address to geocode"),
|
| 377 |
+
region: Optional[str] = Query(None, max_length=2),
|
| 378 |
+
language: Optional[str] = Query(None, max_length=10),
|
| 379 |
+
bounds: Optional[str] = Query(None),
|
| 380 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 381 |
+
token: str = Depends(require_auth),
|
| 382 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 383 |
+
):
|
| 384 |
+
start = time.perf_counter()
|
| 385 |
+
result = await service.geocode(address=address, region=region, language=language, bounds=bounds, api_key=x_goog_api_key)
|
| 386 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 387 |
+
error = result.get("error")
|
| 388 |
+
if error:
|
| 389 |
+
return GoogleGeocodeResponse(success=False, time_ms=elapsed_ms, results=[], count=0, error=error)
|
| 390 |
+
data = result.get("data", {})
|
| 391 |
+
results = _build_geocode_results(data)
|
| 392 |
+
return GoogleGeocodeResponse(success=True, time_ms=elapsed_ms, results=results, count=len(results))
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
@router.post("/reverse-geocode", response_model=GoogleGeocodeResponse, summary="Reverse geocode coordinates to an address")
|
| 396 |
+
async def reverse_geocode(
|
| 397 |
+
body: GoogleReverseGeocodeRequest,
|
| 398 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 399 |
+
token: str = Depends(require_auth),
|
| 400 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 401 |
+
):
|
| 402 |
+
start = time.perf_counter()
|
| 403 |
+
result = await service.reverse_geocode(
|
| 404 |
+
latlng=body.latlng,
|
| 405 |
+
language=body.language,
|
| 406 |
+
result_type=body.result_type,
|
| 407 |
+
location_type=body.location_type,
|
| 408 |
+
api_key=x_goog_api_key,
|
| 409 |
+
)
|
| 410 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 411 |
+
error = result.get("error")
|
| 412 |
+
if error:
|
| 413 |
+
return GoogleGeocodeResponse(success=False, time_ms=elapsed_ms, results=[], count=0, error=error)
|
| 414 |
+
data = result.get("data", {})
|
| 415 |
+
results = _build_geocode_results(data)
|
| 416 |
+
return GoogleGeocodeResponse(success=True, time_ms=elapsed_ms, results=results, count=len(results))
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
@router.get("/reverse-geocode", response_model=GoogleGeocodeResponse, summary="Reverse geocode coordinates to an address (GET)")
|
| 420 |
+
async def reverse_geocode_get(
|
| 421 |
+
latlng: str = Query(..., pattern=r"^-?\d+\.?\d*,-?\d+\.?\d*$", description="Latitude,Longitude"),
|
| 422 |
+
language: Optional[str] = Query(None, max_length=10),
|
| 423 |
+
result_type: Optional[str] = Query(None),
|
| 424 |
+
location_type: Optional[str] = Query(None),
|
| 425 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 426 |
+
token: str = Depends(require_auth),
|
| 427 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 428 |
+
):
|
| 429 |
+
start = time.perf_counter()
|
| 430 |
+
result = await service.reverse_geocode(
|
| 431 |
+
latlng=latlng, language=language,
|
| 432 |
+
result_type=result_type, location_type=location_type,
|
| 433 |
+
api_key=x_goog_api_key,
|
| 434 |
+
)
|
| 435 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 436 |
+
error = result.get("error")
|
| 437 |
+
if error:
|
| 438 |
+
return GoogleGeocodeResponse(success=False, time_ms=elapsed_ms, results=[], count=0, error=error)
|
| 439 |
+
data = result.get("data", {})
|
| 440 |
+
results = _build_geocode_results(data)
|
| 441 |
+
return GoogleGeocodeResponse(success=True, time_ms=elapsed_ms, results=results, count=len(results))
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
# ---------------------------------------------------------------------------
|
| 445 |
+
# Routes — Places Search / Nearby / Details (New Places API)
|
| 446 |
+
# ---------------------------------------------------------------------------
|
| 447 |
+
|
| 448 |
+
@router.post("/places/search", response_model=GooglePlacesSearchResponse, summary="Search for places using a text query (New Places API)")
|
| 449 |
+
async def places_search(
|
| 450 |
+
body: GooglePlacesSearchRequest,
|
| 451 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 452 |
+
token: str = Depends(require_auth),
|
| 453 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 454 |
+
):
|
| 455 |
+
start = time.perf_counter()
|
| 456 |
+
result = await service.places_search(
|
| 457 |
+
query=body.query,
|
| 458 |
+
region=body.region,
|
| 459 |
+
language=body.language,
|
| 460 |
+
min_price=body.min_price,
|
| 461 |
+
max_price=body.max_price,
|
| 462 |
+
open_now=body.open_now,
|
| 463 |
+
type_filter=body.type,
|
| 464 |
+
radius=body.radius,
|
| 465 |
+
page_token=body.page_token,
|
| 466 |
+
page_size=body.page_size,
|
| 467 |
+
min_rating=body.min_rating,
|
| 468 |
+
api_key=x_goog_api_key,
|
| 469 |
+
)
|
| 470 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 471 |
+
error = result.get("error")
|
| 472 |
+
if error:
|
| 473 |
+
return GooglePlacesSearchResponse(success=False, time_ms=elapsed_ms, results=[], count=0, error=error)
|
| 474 |
+
data = result.get("data", {})
|
| 475 |
+
results = _build_place_results_new(data)
|
| 476 |
+
return GooglePlacesSearchResponse(
|
| 477 |
+
success=True, time_ms=elapsed_ms, results=results,
|
| 478 |
+
count=len(results), next_page_token=data.get("nextPageToken"),
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
@router.post("/places/nearby", response_model=GooglePlacesNearbyResponse, summary="Search for places near a location (New Places API)")
|
| 483 |
+
async def places_nearby(
|
| 484 |
+
body: GooglePlacesNearbyRequest,
|
| 485 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 486 |
+
token: str = Depends(require_auth),
|
| 487 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 488 |
+
):
|
| 489 |
+
start = time.perf_counter()
|
| 490 |
+
result = await service.places_nearby(
|
| 491 |
+
location=body.location,
|
| 492 |
+
radius=body.radius,
|
| 493 |
+
keyword=body.keyword,
|
| 494 |
+
language=body.language,
|
| 495 |
+
min_price=body.min_price,
|
| 496 |
+
max_price=body.max_price,
|
| 497 |
+
open_now=body.open_now,
|
| 498 |
+
type_filter=body.type,
|
| 499 |
+
page_token=body.page_token,
|
| 500 |
+
page_size=body.page_size,
|
| 501 |
+
rank_preference=body.rank_preference,
|
| 502 |
+
api_key=x_goog_api_key,
|
| 503 |
+
)
|
| 504 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 505 |
+
error = result.get("error")
|
| 506 |
+
if error:
|
| 507 |
+
return GooglePlacesNearbyResponse(success=False, time_ms=elapsed_ms, results=[], count=0, error=error)
|
| 508 |
+
data = result.get("data", {})
|
| 509 |
+
results = _build_place_results_new(data)
|
| 510 |
+
return GooglePlacesNearbyResponse(
|
| 511 |
+
success=True, time_ms=elapsed_ms, results=results,
|
| 512 |
+
count=len(results), next_page_token=data.get("nextPageToken"),
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
@router.get("/places/search", response_model=GooglePlacesSearchResponse,
|
| 517 |
+
summary="Search for places using a text query (GET, New Places API)")
|
| 518 |
+
async def places_search_get(
|
| 519 |
+
query: str = Query(..., min_length=1, max_length=1000, description="Text query for place search"),
|
| 520 |
+
region: Optional[str] = Query(None, max_length=2),
|
| 521 |
+
language: Optional[str] = Query(None, max_length=10),
|
| 522 |
+
type_filter: Optional[str] = Query(None, alias="type"),
|
| 523 |
+
radius: Optional[int] = Query(None, ge=1, le=50000),
|
| 524 |
+
min_price: Optional[int] = Query(None, ge=0, le=4, description="Minimum price level (0=free, 4=most expensive)"),
|
| 525 |
+
max_price: Optional[int] = Query(None, ge=0, le=4, description="Maximum price level (0=free, 4=most expensive)"),
|
| 526 |
+
open_now: Optional[bool] = Query(None, description="Only return places that are open now"),
|
| 527 |
+
page_token: Optional[str] = Query(None, description="Token for pagination"),
|
| 528 |
+
page_size: Optional[int] = Query(None, ge=1, le=200, description="Number of results per page"),
|
| 529 |
+
min_rating: Optional[float] = Query(None, ge=0.0, le=5.0, description="Minimum rating filter"),
|
| 530 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 531 |
+
token: str = Depends(require_auth),
|
| 532 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 533 |
+
):
|
| 534 |
+
start = time.perf_counter()
|
| 535 |
+
result = await service.places_search(
|
| 536 |
+
query=query, region=region, language=language, type_filter=type_filter,
|
| 537 |
+
radius=radius, min_price=min_price, max_price=max_price, open_now=open_now,
|
| 538 |
+
page_token=page_token, page_size=page_size, min_rating=min_rating,
|
| 539 |
+
api_key=x_goog_api_key,
|
| 540 |
+
)
|
| 541 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 542 |
+
error = result.get("error")
|
| 543 |
+
if error:
|
| 544 |
+
return GooglePlacesSearchResponse(success=False, time_ms=elapsed_ms, results=[], count=0, error=error)
|
| 545 |
+
data = result.get("data", {})
|
| 546 |
+
results = _build_place_results_new(data)
|
| 547 |
+
return GooglePlacesSearchResponse(
|
| 548 |
+
success=True, time_ms=elapsed_ms, results=results,
|
| 549 |
+
count=len(results), next_page_token=data.get("nextPageToken"),
|
| 550 |
+
)
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
@router.get("/places/nearby", response_model=GooglePlacesNearbyResponse,
|
| 554 |
+
summary="Search for places near a location (GET, New Places API)")
|
| 555 |
+
async def places_nearby_get(
|
| 556 |
+
location: str = Query(..., pattern=r"^-?\d+\.?\d*,-?\d+\.?\d*$", description="Latitude,Longitude"),
|
| 557 |
+
radius: int = Query(1000, ge=1, le=50000),
|
| 558 |
+
keyword: Optional[str] = Query(None, max_length=500),
|
| 559 |
+
language: Optional[str] = Query(None, max_length=10),
|
| 560 |
+
type_filter: Optional[str] = Query(None, alias="type"),
|
| 561 |
+
min_price: Optional[int] = Query(None, ge=0, le=4, description="Minimum price level"),
|
| 562 |
+
max_price: Optional[int] = Query(None, ge=0, le=4, description="Maximum price level"),
|
| 563 |
+
open_now: Optional[bool] = Query(None, description="Only return places that are open now"),
|
| 564 |
+
page_token: Optional[str] = Query(None, description="Token for pagination"),
|
| 565 |
+
page_size: Optional[int] = Query(None, ge=1, le=200, description="Number of results per page"),
|
| 566 |
+
rank_preference: Optional[str] = Query(None, pattern="^(POPULARITY|DISTANCE)$", description="Ranking preference"),
|
| 567 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 568 |
+
token: str = Depends(require_auth),
|
| 569 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 570 |
+
):
|
| 571 |
+
start = time.perf_counter()
|
| 572 |
+
result = await service.places_nearby(
|
| 573 |
+
location=location, radius=radius, keyword=keyword,
|
| 574 |
+
language=language, type_filter=type_filter,
|
| 575 |
+
min_price=min_price, max_price=max_price, open_now=open_now,
|
| 576 |
+
page_token=page_token, page_size=page_size, rank_preference=rank_preference,
|
| 577 |
+
api_key=x_goog_api_key,
|
| 578 |
+
)
|
| 579 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 580 |
+
error = result.get("error")
|
| 581 |
+
if error:
|
| 582 |
+
return GooglePlacesNearbyResponse(success=False, time_ms=elapsed_ms, results=[], count=0, error=error)
|
| 583 |
+
data = result.get("data", {})
|
| 584 |
+
results = _build_place_results_new(data)
|
| 585 |
+
return GooglePlacesNearbyResponse(
|
| 586 |
+
success=True, time_ms=elapsed_ms, results=results,
|
| 587 |
+
count=len(results), next_page_token=data.get("nextPageToken"),
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
@router.post("/places/details", response_model=GooglePlaceDetailsResponse,
|
| 592 |
+
summary="Get detailed information about a place (New Places API)")
|
| 593 |
+
async def place_details(
|
| 594 |
+
body: GooglePlaceDetailsRequest,
|
| 595 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 596 |
+
token: str = Depends(require_auth),
|
| 597 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 598 |
+
):
|
| 599 |
+
start = time.perf_counter()
|
| 600 |
+
result = await service.place_details(
|
| 601 |
+
place_id=body.place_id,
|
| 602 |
+
region=body.region,
|
| 603 |
+
language=body.language,
|
| 604 |
+
fields=body.fields,
|
| 605 |
+
api_key=x_goog_api_key,
|
| 606 |
+
)
|
| 607 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 608 |
+
error = result.get("error")
|
| 609 |
+
if error:
|
| 610 |
+
return GooglePlaceDetailsResponse(success=False, time_ms=elapsed_ms, result=None, error=error)
|
| 611 |
+
data = result.get("data", {})
|
| 612 |
+
detail = _build_place_detail_new(data)
|
| 613 |
+
return GooglePlaceDetailsResponse(success=True, time_ms=elapsed_ms, result=detail)
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
@router.get("/places/details", response_model=GooglePlaceDetailsResponse,
|
| 617 |
+
summary="Get detailed information about a place (GET, New Places API)")
|
| 618 |
+
async def place_details_get(
|
| 619 |
+
place_id: str = Query(..., min_length=1, max_length=500, description="Google Place ID"),
|
| 620 |
+
region: Optional[str] = Query(None, max_length=2),
|
| 621 |
+
language: Optional[str] = Query(None, max_length=10),
|
| 622 |
+
fields: Optional[str] = Query(None),
|
| 623 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 624 |
+
token: str = Depends(require_auth),
|
| 625 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 626 |
+
):
|
| 627 |
+
start = time.perf_counter()
|
| 628 |
+
result = await service.place_details(
|
| 629 |
+
place_id=place_id, region=region, language=language, fields=fields,
|
| 630 |
+
api_key=x_goog_api_key,
|
| 631 |
+
)
|
| 632 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 633 |
+
error = result.get("error")
|
| 634 |
+
if error:
|
| 635 |
+
return GooglePlaceDetailsResponse(success=False, time_ms=elapsed_ms, result=None, error=error)
|
| 636 |
+
data = result.get("data", {})
|
| 637 |
+
detail = _build_place_detail_new(data)
|
| 638 |
+
return GooglePlaceDetailsResponse(success=True, time_ms=elapsed_ms, result=detail)
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
@router.get("/places/photo",
|
| 642 |
+
summary="Get a place photo by photo reference (New Places API)")
|
| 643 |
+
async def place_photo(
|
| 644 |
+
photo_reference: str = Query(..., min_length=1, description="Photo reference name (e.g. 'places/ChIJ.../photos/...')"),
|
| 645 |
+
max_width_px: int = Query(400, ge=1, le=4800),
|
| 646 |
+
max_height_px: int = Query(400, ge=1, le=4800),
|
| 647 |
+
x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
|
| 648 |
+
token: str = Depends(require_auth),
|
| 649 |
+
service: GoogleMapsService = Depends(get_maps_service),
|
| 650 |
+
):
|
| 651 |
+
from app.config import get_settings
|
| 652 |
+
import httpx
|
| 653 |
+
|
| 654 |
+
cfg = get_settings()
|
| 655 |
+
url = f"{cfg.google_maps_places_base_url}/{photo_reference}/media"
|
| 656 |
+
headers = {"X-Goog-Api-Key": x_goog_api_key}
|
| 657 |
+
params = {"maxWidthPx": max_width_px, "maxHeightPx": max_height_px}
|
| 658 |
+
|
| 659 |
+
async with httpx.AsyncClient(timeout=cfg.google_maps_timeout, follow_redirects=True) as client:
|
| 660 |
+
resp = await client.get(url, headers=headers, params=params)
|
| 661 |
+
if resp.status_code != 200:
|
| 662 |
+
error_body = resp.text[:300]
|
| 663 |
+
return Response(
|
| 664 |
+
content='{"success":false,"error":"HTTP %d: %s"}' % (resp.status_code, error_body),
|
| 665 |
+
media_type="application/json",
|
| 666 |
+
status_code=200,
|
| 667 |
+
)
|
| 668 |
+
return Response(content=resp.content, media_type=resp.headers.get("content-type", "image/jpeg"))
|
app/api/v1/router.py
CHANGED
|
@@ -11,6 +11,7 @@ from app.api.v1 import (
|
|
| 11 |
csv_analysis,
|
| 12 |
database,
|
| 13 |
embeddings,
|
|
|
|
| 14 |
json_extract,
|
| 15 |
keys_extract,
|
| 16 |
qr_decoder,
|
|
@@ -51,6 +52,7 @@ api_v1_router.include_router(chat.router, tags=["Chat"])
|
|
| 51 |
api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
|
| 52 |
api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
|
| 53 |
api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
|
|
|
|
| 54 |
api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
|
| 55 |
api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
|
| 56 |
api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
|
|
|
|
| 11 |
csv_analysis,
|
| 12 |
database,
|
| 13 |
embeddings,
|
| 14 |
+
google_maps,
|
| 15 |
json_extract,
|
| 16 |
keys_extract,
|
| 17 |
qr_decoder,
|
|
|
|
| 52 |
api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
|
| 53 |
api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
|
| 54 |
api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
|
| 55 |
+
api_v1_router.include_router(google_maps.router, tags=["Google Maps"])
|
| 56 |
api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
|
| 57 |
api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
|
| 58 |
api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
|
app/config.py
CHANGED
|
@@ -95,6 +95,13 @@ class Settings(BaseSettings):
|
|
| 95 |
redis_retry_on_timeout: bool = Field(default=True, alias="REDIS_RETRY_ON_TIMEOUT")
|
| 96 |
redis_health_check_interval: int = Field(default=30, alias="REDIS_HEALTH_CHECK_INTERVAL")
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
# Scheduler settings
|
| 99 |
max_http_timeout: float = 300.0
|
| 100 |
default_scheduler_timezone: str = "UTC"
|
|
|
|
| 95 |
redis_retry_on_timeout: bool = Field(default=True, alias="REDIS_RETRY_ON_TIMEOUT")
|
| 96 |
redis_health_check_interval: int = Field(default=30, alias="REDIS_HEALTH_CHECK_INTERVAL")
|
| 97 |
|
| 98 |
+
# Google Maps / GCP settings
|
| 99 |
+
gcp_api_key: str = Field(default="", alias="GCP_API_KEY")
|
| 100 |
+
google_maps_base_url: str = "https://maps.googleapis.com/maps/api"
|
| 101 |
+
google_maps_places_base_url: str = "https://places.googleapis.com/v1"
|
| 102 |
+
google_maps_timeout: int = 15
|
| 103 |
+
google_maps_max_retries: int = 2
|
| 104 |
+
|
| 105 |
# Scheduler settings
|
| 106 |
max_http_timeout: float = 300.0
|
| 107 |
default_scheduler_timezone: str = "UTC"
|
app/models/schemas.py
CHANGED
|
@@ -708,3 +708,178 @@ class WebhookSocketStatsResponse(BaseModel):
|
|
| 708 |
total_messages: int
|
| 709 |
total_subscribers: int
|
| 710 |
channels_detail: Dict[str, Dict[str, Any]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
total_messages: int
|
| 709 |
total_subscribers: int
|
| 710 |
channels_detail: Dict[str, Dict[str, Any]]
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
# ---------------------------------------------------------------------------
|
| 714 |
+
# Google Maps (Geocoding & Places)
|
| 715 |
+
# ---------------------------------------------------------------------------
|
| 716 |
+
|
| 717 |
+
class GoogleGeocodeRequest(BaseModel):
|
| 718 |
+
address: str = Field(..., min_length=1, max_length=1000, description="Street address to geocode")
|
| 719 |
+
region: Optional[str] = Field(None, max_length=2, description="Region biasing (ccTLD two-character value, e.g. 'uk', 'us')")
|
| 720 |
+
language: Optional[str] = Field(None, max_length=10, description="Language code for results")
|
| 721 |
+
bounds: Optional[str] = Field(None, description="Bounding box to bias results: 'lat_sw,lng_sw|lat_ne,lng_ne'")
|
| 722 |
+
|
| 723 |
+
class GoogleReverseGeocodeRequest(BaseModel):
|
| 724 |
+
latlng: str = Field(..., pattern=r"^-?\d+\.?\d*,-?\d+\.?\d*$", description="Latitude,Longitude to reverse geocode (e.g. '40.714224,-73.961452')")
|
| 725 |
+
language: Optional[str] = Field(None, max_length=10, description="Language code for results")
|
| 726 |
+
result_type: Optional[str] = Field(None, description="Restrict to specific address types (comma-separated)")
|
| 727 |
+
location_type: Optional[str] = Field(None, description="Restrict to specific location types (comma-separated: ROOFTOP, RANGE_INTERPOLATED, GEOMETRIC_CENTER, APPROXIMATE)")
|
| 728 |
+
|
| 729 |
+
class GoogleGeocodeResult(BaseModel):
|
| 730 |
+
formatted_address: str
|
| 731 |
+
place_id: str
|
| 732 |
+
latitude: float
|
| 733 |
+
longitude: float
|
| 734 |
+
location_type: str = ""
|
| 735 |
+
address_components: List[Dict[str, Any]] = []
|
| 736 |
+
types: List[str] = []
|
| 737 |
+
|
| 738 |
+
class GoogleGeocodeResponse(BaseModel):
|
| 739 |
+
success: bool
|
| 740 |
+
time_ms: float
|
| 741 |
+
results: List[GoogleGeocodeResult] = []
|
| 742 |
+
count: int = 0
|
| 743 |
+
error: Optional[str] = None
|
| 744 |
+
|
| 745 |
+
class GooglePlacesSearchRequest(BaseModel):
|
| 746 |
+
query: str = Field(..., min_length=1, max_length=1000, description="Text query for place search")
|
| 747 |
+
region: Optional[str] = Field(None, max_length=2, description="Region biasing (ccTLD)")
|
| 748 |
+
language: Optional[str] = Field(None, max_length=10, description="Language code")
|
| 749 |
+
min_price: Optional[int] = Field(None, ge=0, le=4, description="Minimum price level (0=free, 4=most expensive)")
|
| 750 |
+
max_price: Optional[int] = Field(None, ge=0, le=4, description="Maximum price level (0=free, 4=most expensive)")
|
| 751 |
+
open_now: Optional[bool] = Field(None, description="Only return places that are open now")
|
| 752 |
+
type: Optional[str] = Field(None, description="Restrict to a specific place type (e.g. 'restaurant', 'cafe')")
|
| 753 |
+
radius: Optional[int] = Field(None, ge=1, le=50000, description="Search radius in meters (max 50000)")
|
| 754 |
+
page_token: Optional[str] = Field(None, description="Token for pagination (from previous response next_page_token)")
|
| 755 |
+
page_size: Optional[int] = Field(None, ge=1, le=200, description="Number of results per page (1-200)")
|
| 756 |
+
min_rating: Optional[float] = Field(None, ge=0.0, le=5.0, description="Minimum rating filter")
|
| 757 |
+
|
| 758 |
+
class GooglePlacesNearbyRequest(BaseModel):
|
| 759 |
+
location: str = Field(..., pattern=r"^-?\d+\.?\d*,-?\d+\.?\d*$", description="Latitude,Longitude of the center point")
|
| 760 |
+
radius: int = Field(default=1000, ge=1, le=50000, description="Search radius in meters (max 50000)")
|
| 761 |
+
keyword: Optional[str] = Field(None, max_length=500, description="Keyword to match against place names/types")
|
| 762 |
+
language: Optional[str] = Field(None, max_length=10, description="Language code")
|
| 763 |
+
min_price: Optional[int] = Field(None, ge=0, le=4, description="Minimum price level")
|
| 764 |
+
max_price: Optional[int] = Field(None, ge=0, le=4, description="Maximum price level")
|
| 765 |
+
open_now: Optional[bool] = Field(None, description="Only return places that are open now")
|
| 766 |
+
type: Optional[str] = Field(None, description="Restrict to a specific place type")
|
| 767 |
+
page_token: Optional[str] = Field(None, description="Token for pagination (from previous response next_page_token)")
|
| 768 |
+
page_size: Optional[int] = Field(None, ge=1, le=200, description="Number of results per page (1-200)")
|
| 769 |
+
rank_preference: Optional[str] = Field(None, pattern="^(POPULARITY|DISTANCE)$", description="Ranking preference: POPULARITY or DISTANCE")
|
| 770 |
+
|
| 771 |
+
class GooglePlaceDetailsRequest(BaseModel):
|
| 772 |
+
place_id: str = Field(..., min_length=1, max_length=500, description="Google Place ID to fetch details for")
|
| 773 |
+
region: Optional[str] = Field(None, max_length=2, description="Region biasing")
|
| 774 |
+
language: Optional[str] = Field(None, max_length=10, description="Language code")
|
| 775 |
+
fields: Optional[str] = Field(None, description="Comma-separated fields to include (default: all)")
|
| 776 |
+
|
| 777 |
+
class GooglePlacePhoto(BaseModel):
|
| 778 |
+
photo_reference: str
|
| 779 |
+
height: int
|
| 780 |
+
width: int
|
| 781 |
+
html_attributions: List[str] = []
|
| 782 |
+
|
| 783 |
+
class GooglePlaceResult(BaseModel):
|
| 784 |
+
place_id: str
|
| 785 |
+
name: str
|
| 786 |
+
formatted_address: str = ""
|
| 787 |
+
latitude: float = 0.0
|
| 788 |
+
longitude: float = 0.0
|
| 789 |
+
rating: Optional[float] = None
|
| 790 |
+
user_ratings_total: Optional[int] = None
|
| 791 |
+
price_level: Optional[int] = None
|
| 792 |
+
types: List[str] = []
|
| 793 |
+
vicinity: str = ""
|
| 794 |
+
business_status: Optional[str] = None
|
| 795 |
+
photos: List[GooglePlacePhoto] = []
|
| 796 |
+
plus_code: Optional[str] = None
|
| 797 |
+
icon: Optional[str] = None
|
| 798 |
+
opening_hours: Optional[Dict[str, Any]] = None
|
| 799 |
+
website: Optional[str] = None
|
| 800 |
+
formatted_phone_number: Optional[str] = None
|
| 801 |
+
international_phone_number: Optional[str] = None
|
| 802 |
+
google_maps_uri: Optional[str] = None
|
| 803 |
+
|
| 804 |
+
class GooglePlacesSearchResponse(BaseModel):
|
| 805 |
+
success: bool
|
| 806 |
+
time_ms: float
|
| 807 |
+
results: List[GooglePlaceResult] = []
|
| 808 |
+
count: int = 0
|
| 809 |
+
next_page_token: Optional[str] = None
|
| 810 |
+
error: Optional[str] = None
|
| 811 |
+
|
| 812 |
+
class GooglePlacesNearbyResponse(BaseModel):
|
| 813 |
+
success: bool
|
| 814 |
+
time_ms: float
|
| 815 |
+
results: List[GooglePlaceResult] = []
|
| 816 |
+
count: int = 0
|
| 817 |
+
next_page_token: Optional[str] = None
|
| 818 |
+
error: Optional[str] = None
|
| 819 |
+
|
| 820 |
+
class GooglePlaceDetailsResponse(BaseModel):
|
| 821 |
+
success: bool
|
| 822 |
+
time_ms: float
|
| 823 |
+
result: Optional[GooglePlaceResult] = None
|
| 824 |
+
error: Optional[str] = None
|
| 825 |
+
|
| 826 |
+
|
| 827 |
+
# ---------------------------------------------------------------------------
|
| 828 |
+
# Google Places Autocomplete
|
| 829 |
+
# ---------------------------------------------------------------------------
|
| 830 |
+
|
| 831 |
+
class GooglePlaceAutocompleteRequest(BaseModel):
|
| 832 |
+
input: str = Field(..., min_length=1, max_length=1000, description="Text string to search for")
|
| 833 |
+
offset: Optional[int] = Field(None, ge=1, description="Character position to start matching within input")
|
| 834 |
+
origin: Optional[str] = Field(None, pattern=r"^-?\d+\.?\d*,-?\d+\.?\d*$", description="Origin lat,lng for distance-biased results")
|
| 835 |
+
location: Optional[str] = Field(None, pattern=r"^-?\d+\.?\d*,-?\d+\.?\d*$", description="Point around which to bias results")
|
| 836 |
+
radius: Optional[int] = Field(None, ge=1, le=50000, description="Radius in meters from location (max 50000)")
|
| 837 |
+
language: Optional[str] = Field(None, max_length=10, description="Language code for results")
|
| 838 |
+
types: Optional[str] = Field(None, description="Place types to restrict results (geocode|address|establishment|(regions)|(cities))")
|
| 839 |
+
components: Optional[str] = Field(None, description="Component filters (e.g. 'country:IN|country:US')")
|
| 840 |
+
strictbounds: Optional[bool] = Field(None, description="If true, only return places strictly within location/radius")
|
| 841 |
+
sessiontoken: Optional[str] = Field(None, description="Random string to group autocomplete requests into a session for billing")
|
| 842 |
+
|
| 843 |
+
class GoogleQueryAutocompleteRequest(BaseModel):
|
| 844 |
+
input: str = Field(..., min_length=1, max_length=1000, description="Text string to search for")
|
| 845 |
+
offset: Optional[int] = Field(None, ge=1, description="Character position to start matching within input")
|
| 846 |
+
location: Optional[str] = Field(None, pattern=r"^-?\d+\.?\d*,-?\d+\.?\d*$", description="Point around which to bias results")
|
| 847 |
+
radius: Optional[int] = Field(None, ge=1, le=50000, description="Radius in meters from location (max 50000)")
|
| 848 |
+
language: Optional[str] = Field(None, max_length=10, description="Language code for results")
|
| 849 |
+
|
| 850 |
+
class GoogleAutocompleteTerm(BaseModel):
|
| 851 |
+
offset: int
|
| 852 |
+
value: str
|
| 853 |
+
|
| 854 |
+
class GoogleAutocompleteMatchedSubstring(BaseModel):
|
| 855 |
+
offset: int
|
| 856 |
+
length: int
|
| 857 |
+
|
| 858 |
+
class GoogleAutocompleteStructuredFormatting(BaseModel):
|
| 859 |
+
main_text: str
|
| 860 |
+
main_text_matched_substrings: List[GoogleAutocompleteMatchedSubstring] = []
|
| 861 |
+
secondary_text: str = ""
|
| 862 |
+
secondary_text_matched_substrings: List[GoogleAutocompleteMatchedSubstring] = []
|
| 863 |
+
|
| 864 |
+
class GoogleAutocompletePrediction(BaseModel):
|
| 865 |
+
description: str
|
| 866 |
+
place_id: str
|
| 867 |
+
structured_formatting: Optional[GoogleAutocompleteStructuredFormatting] = None
|
| 868 |
+
terms: List[GoogleAutocompleteTerm] = []
|
| 869 |
+
types: List[str] = []
|
| 870 |
+
matched_substrings: List[GoogleAutocompleteMatchedSubstring] = []
|
| 871 |
+
distance_meters: Optional[int] = None
|
| 872 |
+
|
| 873 |
+
class GooglePlaceAutocompleteResponse(BaseModel):
|
| 874 |
+
success: bool
|
| 875 |
+
time_ms: float
|
| 876 |
+
predictions: List[GoogleAutocompletePrediction] = []
|
| 877 |
+
count: int = 0
|
| 878 |
+
error: Optional[str] = None
|
| 879 |
+
|
| 880 |
+
class GoogleQueryAutocompleteResponse(BaseModel):
|
| 881 |
+
success: bool
|
| 882 |
+
time_ms: float
|
| 883 |
+
predictions: List[GoogleAutocompletePrediction] = []
|
| 884 |
+
count: int = 0
|
| 885 |
+
error: Optional[str] = None
|
app/services/google_maps_service.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Any, Dict, Optional
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
|
| 9 |
+
from app.config import get_settings
|
| 10 |
+
|
| 11 |
+
_logger = logging.getLogger(__name__)
|
| 12 |
+
_settings = get_settings()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
PRICE_LEVEL_MAP: dict[str, int] = {
|
| 16 |
+
"PRICE_LEVEL_UNSPECIFIED": 0,
|
| 17 |
+
"PRICE_LEVEL_FREE": 0,
|
| 18 |
+
"PRICE_LEVEL_INEXPENSIVE": 1,
|
| 19 |
+
"PRICE_LEVEL_MODERATE": 2,
|
| 20 |
+
"PRICE_LEVEL_EXPENSIVE": 3,
|
| 21 |
+
"PRICE_LEVEL_VERY_EXPENSIVE": 4,
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
DEFAULT_SEARCH_FIELD_MASK = (
|
| 25 |
+
"places.id,places.displayName,places.formattedAddress,"
|
| 26 |
+
"places.location,places.rating,places.userRatingCount,"
|
| 27 |
+
"places.priceLevel,places.types,places.businessStatus,"
|
| 28 |
+
"places.photos,places.plusCode,places.shortFormattedAddress,"
|
| 29 |
+
"places.regularOpeningHours,places.websiteUri,"
|
| 30 |
+
"places.internationalPhoneNumber,places.nationalPhoneNumber,"
|
| 31 |
+
"places.googleMapsUri,places.iconMaskBaseUri"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
DEFAULT_DETAILS_FIELD_MASK = (
|
| 35 |
+
"id,displayName,formattedAddress,location,rating,userRatingCount,"
|
| 36 |
+
"priceLevel,types,businessStatus,photos,plusCode,shortFormattedAddress,"
|
| 37 |
+
"regularOpeningHours,currentOpeningHours,websiteUri,"
|
| 38 |
+
"internationalPhoneNumber,nationalPhoneNumber,googleMapsUri,"
|
| 39 |
+
"iconMaskBaseUri,editorialSummary"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
DEFAULT_AUTOCOMPLETE_FIELD_MASK = (
|
| 43 |
+
"suggestions.placePrediction.text.text,"
|
| 44 |
+
"suggestions.placePrediction.placeId,"
|
| 45 |
+
"suggestions.placePrediction.types,"
|
| 46 |
+
"suggestions.placePrediction.distanceMeters,"
|
| 47 |
+
"suggestions.queryPrediction.text.text"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class GoogleMapsService:
|
| 52 |
+
|
| 53 |
+
def __init__(self) -> None:
|
| 54 |
+
self._base_url: str = _settings.google_maps_base_url
|
| 55 |
+
self._places_base_url: str = _settings.google_maps_places_base_url
|
| 56 |
+
self._timeout: int = _settings.google_maps_timeout
|
| 57 |
+
self._max_retries: int = _settings.google_maps_max_retries
|
| 58 |
+
|
| 59 |
+
# -----------------------------------------------------------------------
|
| 60 |
+
# Geocoding — still uses the legacy Google Geocoding API
|
| 61 |
+
# -----------------------------------------------------------------------
|
| 62 |
+
|
| 63 |
+
async def geocode(self, address: str, region: Optional[str] = None,
|
| 64 |
+
language: Optional[str] = None, bounds: Optional[str] = None,
|
| 65 |
+
api_key: Optional[str] = None) -> Dict[str, Any]:
|
| 66 |
+
if not api_key:
|
| 67 |
+
raise ValueError("Google API key is missing")
|
| 68 |
+
params: Dict[str, Any] = {
|
| 69 |
+
"address": address,
|
| 70 |
+
"key": api_key,
|
| 71 |
+
}
|
| 72 |
+
if region:
|
| 73 |
+
params["region"] = region
|
| 74 |
+
if language:
|
| 75 |
+
params["language"] = language
|
| 76 |
+
if bounds:
|
| 77 |
+
params["bounds"] = bounds
|
| 78 |
+
|
| 79 |
+
return await self._call_api("/geocode/json", params)
|
| 80 |
+
|
| 81 |
+
async def reverse_geocode(self, latlng: str, language: Optional[str] = None,
|
| 82 |
+
result_type: Optional[str] = None,
|
| 83 |
+
location_type: Optional[str] = None,
|
| 84 |
+
api_key: Optional[str] = None) -> Dict[str, Any]:
|
| 85 |
+
if not api_key:
|
| 86 |
+
raise ValueError("Google API key is missing")
|
| 87 |
+
params: Dict[str, Any] = {
|
| 88 |
+
"latlng": latlng,
|
| 89 |
+
"key": api_key,
|
| 90 |
+
}
|
| 91 |
+
if language:
|
| 92 |
+
params["language"] = language
|
| 93 |
+
if result_type:
|
| 94 |
+
params["result_type"] = result_type
|
| 95 |
+
if location_type:
|
| 96 |
+
params["location_type"] = location_type
|
| 97 |
+
|
| 98 |
+
return await self._call_api("/geocode/json", params)
|
| 99 |
+
|
| 100 |
+
# -----------------------------------------------------------------------
|
| 101 |
+
# Places API (New) — places.googleapis.com/v1
|
| 102 |
+
# -----------------------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
def _price_levels_from_range(self, min_price: Optional[int], max_price: Optional[int]) -> list[str]:
|
| 105 |
+
levels = ["PRICE_LEVEL_FREE", "PRICE_LEVEL_INEXPENSIVE", "PRICE_LEVEL_MODERATE", "PRICE_LEVEL_EXPENSIVE", "PRICE_LEVEL_VERY_EXPENSIVE"]
|
| 106 |
+
lo = max(0, min_price if min_price is not None else 0)
|
| 107 |
+
hi = min(4, max_price if max_price is not None else 4)
|
| 108 |
+
if lo > hi:
|
| 109 |
+
lo, hi = hi, lo
|
| 110 |
+
return [levels[i] for i in range(lo, hi + 1)]
|
| 111 |
+
|
| 112 |
+
async def places_search(self, query: str, region: Optional[str] = None,
|
| 113 |
+
language: Optional[str] = None, min_price: Optional[int] = None,
|
| 114 |
+
max_price: Optional[int] = None, open_now: Optional[bool] = None,
|
| 115 |
+
type_filter: Optional[str] = None,
|
| 116 |
+
radius: Optional[int] = None,
|
| 117 |
+
page_token: Optional[str] = None,
|
| 118 |
+
page_size: Optional[int] = None,
|
| 119 |
+
min_rating: Optional[float] = None,
|
| 120 |
+
api_key: Optional[str] = None) -> Dict[str, Any]:
|
| 121 |
+
body: Dict[str, Any] = {
|
| 122 |
+
"textQuery": query,
|
| 123 |
+
}
|
| 124 |
+
if page_token:
|
| 125 |
+
body["pageToken"] = page_token
|
| 126 |
+
else:
|
| 127 |
+
body["maxResultCount"] = page_size or 10
|
| 128 |
+
if region:
|
| 129 |
+
body["regionCode"] = region.upper()
|
| 130 |
+
if language:
|
| 131 |
+
body["languageCode"] = language
|
| 132 |
+
if type_filter:
|
| 133 |
+
body["includedType"] = type_filter
|
| 134 |
+
if min_price is not None or max_price is not None:
|
| 135 |
+
body["priceLevels"] = self._price_levels_from_range(min_price, max_price)
|
| 136 |
+
if open_now is not None:
|
| 137 |
+
body["openNow"] = open_now
|
| 138 |
+
if min_rating is not None:
|
| 139 |
+
body["minRating"] = min_rating
|
| 140 |
+
|
| 141 |
+
return await self._call_places_api("POST", "/places:searchText", body=body, field_mask=DEFAULT_SEARCH_FIELD_MASK, api_key=api_key)
|
| 142 |
+
|
| 143 |
+
async def places_nearby(self, location: str, radius: int = 1000,
|
| 144 |
+
keyword: Optional[str] = None, language: Optional[str] = None,
|
| 145 |
+
min_price: Optional[int] = None, max_price: Optional[int] = None,
|
| 146 |
+
open_now: Optional[bool] = None,
|
| 147 |
+
type_filter: Optional[str] = None,
|
| 148 |
+
page_token: Optional[str] = None,
|
| 149 |
+
page_size: Optional[int] = None,
|
| 150 |
+
rank_preference: Optional[str] = None,
|
| 151 |
+
api_key: Optional[str] = None) -> Dict[str, Any]:
|
| 152 |
+
parts = location.split(",")
|
| 153 |
+
lat = float(parts[0].strip())
|
| 154 |
+
lng = float(parts[1].strip())
|
| 155 |
+
|
| 156 |
+
body: Dict[str, Any] = {
|
| 157 |
+
"locationRestriction": {
|
| 158 |
+
"circle": {
|
| 159 |
+
"center": {"latitude": lat, "longitude": lng},
|
| 160 |
+
"radius": float(radius),
|
| 161 |
+
}
|
| 162 |
+
},
|
| 163 |
+
}
|
| 164 |
+
if page_token:
|
| 165 |
+
body["pageToken"] = page_token
|
| 166 |
+
else:
|
| 167 |
+
body["maxResultCount"] = page_size or 10
|
| 168 |
+
if type_filter:
|
| 169 |
+
body["includedTypes"] = type_filter.split(",")
|
| 170 |
+
if keyword:
|
| 171 |
+
body["includedPrimaryTypes"] = [keyword]
|
| 172 |
+
if language:
|
| 173 |
+
body["languageCode"] = language
|
| 174 |
+
if min_price is not None or max_price is not None:
|
| 175 |
+
body["priceLevels"] = self._price_levels_from_range(min_price, max_price)
|
| 176 |
+
if open_now is not None:
|
| 177 |
+
body["openNow"] = open_now
|
| 178 |
+
if rank_preference:
|
| 179 |
+
body["rankPreference"] = rank_preference.upper()
|
| 180 |
+
|
| 181 |
+
return await self._call_places_api("POST", "/places:searchNearby", body=body, field_mask=DEFAULT_SEARCH_FIELD_MASK, api_key=api_key)
|
| 182 |
+
|
| 183 |
+
async def place_autocomplete(self, input: str, offset: Optional[int] = None,
|
| 184 |
+
origin: Optional[str] = None,
|
| 185 |
+
location: Optional[str] = None,
|
| 186 |
+
radius: Optional[int] = None,
|
| 187 |
+
language: Optional[str] = None,
|
| 188 |
+
types: Optional[str] = None,
|
| 189 |
+
components: Optional[str] = None,
|
| 190 |
+
strictbounds: Optional[bool] = None,
|
| 191 |
+
sessiontoken: Optional[str] = None,
|
| 192 |
+
api_key: Optional[str] = None) -> Dict[str, Any]:
|
| 193 |
+
body: Dict[str, Any] = {
|
| 194 |
+
"input": input,
|
| 195 |
+
}
|
| 196 |
+
if language:
|
| 197 |
+
body["languageCode"] = language
|
| 198 |
+
if offset is not None:
|
| 199 |
+
body["inputOffset"] = offset
|
| 200 |
+
if location:
|
| 201 |
+
parts = location.split(",")
|
| 202 |
+
body["locationBias"] = {
|
| 203 |
+
"circle": {
|
| 204 |
+
"center": {"latitude": float(parts[0].strip()), "longitude": float(parts[1].strip())},
|
| 205 |
+
"radius": float(radius or 50000),
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
if origin:
|
| 209 |
+
parts = origin.split(",")
|
| 210 |
+
body["origin"] = {"latitude": float(parts[0].strip()), "longitude": float(parts[1].strip())}
|
| 211 |
+
if types:
|
| 212 |
+
mapped = []
|
| 213 |
+
for t in types.replace("(", "").replace(")", "").split("|"):
|
| 214 |
+
t = t.strip()
|
| 215 |
+
if t == "cities":
|
| 216 |
+
t = "locality"
|
| 217 |
+
mapped.append(t)
|
| 218 |
+
body["includedPrimaryTypes"] = mapped
|
| 219 |
+
if components:
|
| 220 |
+
parts_list = [c.split(":") for c in components.split("|") if ":" in c]
|
| 221 |
+
for pair in parts_list:
|
| 222 |
+
key, val = pair[0].strip(), pair[1].strip()
|
| 223 |
+
if key == "country":
|
| 224 |
+
body["regionCode"] = val.upper()
|
| 225 |
+
if sessiontoken:
|
| 226 |
+
body["sessionToken"] = sessiontoken
|
| 227 |
+
|
| 228 |
+
field_mask = "suggestions.placePrediction.text.text,suggestions.placePrediction.placeId,suggestions.placePrediction.types,suggestions.placePrediction.distanceMeters,suggestions.placePrediction.structuredFormat.mainText.text,suggestions.placePrediction.structuredFormat.secondaryText.text"
|
| 229 |
+
|
| 230 |
+
return await self._call_places_api("POST", "/places:autocomplete", body=body, field_mask=field_mask, api_key=api_key)
|
| 231 |
+
|
| 232 |
+
async def query_autocomplete(self, input: str, offset: Optional[int] = None,
|
| 233 |
+
location: Optional[str] = None,
|
| 234 |
+
radius: Optional[int] = None,
|
| 235 |
+
language: Optional[str] = None,
|
| 236 |
+
api_key: Optional[str] = None) -> Dict[str, Any]:
|
| 237 |
+
body: Dict[str, Any] = {
|
| 238 |
+
"input": input,
|
| 239 |
+
"includeQueryPredictions": True,
|
| 240 |
+
}
|
| 241 |
+
if language:
|
| 242 |
+
body["languageCode"] = language
|
| 243 |
+
if offset is not None:
|
| 244 |
+
body["inputOffset"] = offset
|
| 245 |
+
if location:
|
| 246 |
+
parts = location.split(",")
|
| 247 |
+
body["locationBias"] = {
|
| 248 |
+
"circle": {
|
| 249 |
+
"center": {"latitude": float(parts[0].strip()), "longitude": float(parts[1].strip())},
|
| 250 |
+
"radius": float(radius or 50000),
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
field_mask = "suggestions.placePrediction.text.text,suggestions.placePrediction.placeId,suggestions.placePrediction.types,suggestions.placePrediction.distanceMeters,suggestions.queryPrediction.text.text"
|
| 255 |
+
|
| 256 |
+
return await self._call_places_api("POST", "/places:autocomplete", body=body, field_mask=field_mask, api_key=api_key)
|
| 257 |
+
|
| 258 |
+
async def place_details(self, place_id: str, region: Optional[str] = None,
|
| 259 |
+
language: Optional[str] = None,
|
| 260 |
+
fields: Optional[str] = None,
|
| 261 |
+
api_key: Optional[str] = None) -> Dict[str, Any]:
|
| 262 |
+
path = f"/places/{place_id}"
|
| 263 |
+
field_mask = fields or DEFAULT_DETAILS_FIELD_MASK
|
| 264 |
+
params = {}
|
| 265 |
+
if language:
|
| 266 |
+
params["languageCode"] = language
|
| 267 |
+
|
| 268 |
+
return await self._call_places_api("GET", path, params=params, field_mask=field_mask, api_key=api_key)
|
| 269 |
+
|
| 270 |
+
# -----------------------------------------------------------------------
|
| 271 |
+
# Internal — Legacy API (for Geocoding)
|
| 272 |
+
# -----------------------------------------------------------------------
|
| 273 |
+
|
| 274 |
+
async def _call_api(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
| 275 |
+
url = f"{self._base_url}{path}"
|
| 276 |
+
last_error: Optional[str] = None
|
| 277 |
+
|
| 278 |
+
for attempt in range(1 + self._max_retries):
|
| 279 |
+
try:
|
| 280 |
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
| 281 |
+
response = await client.get(url, params=params)
|
| 282 |
+
response.raise_for_status()
|
| 283 |
+
data: Dict[str, Any] = response.json()
|
| 284 |
+
return self._normalize_response(data)
|
| 285 |
+
|
| 286 |
+
except httpx.TimeoutException:
|
| 287 |
+
last_error = "Request timed out"
|
| 288 |
+
_logger.warning("Google Maps API timeout on %s (attempt %d/%d)", path, attempt + 1, 1 + self._max_retries)
|
| 289 |
+
except httpx.HTTPStatusError as e:
|
| 290 |
+
last_error = f"HTTP {e.response.status_code}: {e.response.text[:500]}"
|
| 291 |
+
_logger.warning("Google Maps API HTTP error on %s: %s (attempt %d/%d)", path, last_error, attempt + 1, 1 + self._max_retries)
|
| 292 |
+
if 400 <= e.response.status_code < 500:
|
| 293 |
+
break
|
| 294 |
+
except httpx.RequestError as e:
|
| 295 |
+
last_error = f"Request failed: {e}"
|
| 296 |
+
_logger.warning("Google Maps API request error on %s: %s (attempt %d/%d)", path, last_error, attempt + 1, 1 + self._max_retries)
|
| 297 |
+
except Exception as e:
|
| 298 |
+
last_error = f"Unexpected error: {e}"
|
| 299 |
+
_logger.error("Google Maps API unexpected error on %s: %s", path, last_error)
|
| 300 |
+
break
|
| 301 |
+
|
| 302 |
+
if attempt < self._max_retries:
|
| 303 |
+
await asyncio.sleep(1.0 * (attempt + 1))
|
| 304 |
+
|
| 305 |
+
return {"success": False, "error": last_error or "Unknown error"}
|
| 306 |
+
|
| 307 |
+
def _normalize_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
| 308 |
+
status: str = data.get("status", "")
|
| 309 |
+
error_message: str = data.get("error_message", "")
|
| 310 |
+
|
| 311 |
+
if status == "OK" or status == "ZERO_RESULTS":
|
| 312 |
+
return {
|
| 313 |
+
"success": True,
|
| 314 |
+
"status": status,
|
| 315 |
+
"data": data,
|
| 316 |
+
"error": None,
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
if status == "OVER_QUERY_LIMIT":
|
| 320 |
+
return {"success": False, "status": status, "error": "API quota exceeded. Please wait and retry."}
|
| 321 |
+
if status == "REQUEST_DENIED":
|
| 322 |
+
return {"success": False, "status": status, "error": f"Request denied: {error_message}"}
|
| 323 |
+
if status == "INVALID_REQUEST":
|
| 324 |
+
return {"success": False, "status": status, "error": f"Invalid request: {error_message}"}
|
| 325 |
+
if status == "NOT_FOUND":
|
| 326 |
+
return {"success": False, "status": status, "error": "The specified place was not found."}
|
| 327 |
+
|
| 328 |
+
return {"success": False, "status": status, "error": error_message or f"Unknown status: {status}"}
|
| 329 |
+
|
| 330 |
+
# -----------------------------------------------------------------------
|
| 331 |
+
# Internal — Places API (New)
|
| 332 |
+
# -----------------------------------------------------------------------
|
| 333 |
+
|
| 334 |
+
async def _call_places_api(self, method: str, path: str,
|
| 335 |
+
body: Optional[Dict[str, Any]] = None,
|
| 336 |
+
params: Optional[Dict[str, Any]] = None,
|
| 337 |
+
field_mask: Optional[str] = None,
|
| 338 |
+
api_key: Optional[str] = None) -> Dict[str, Any]:
|
| 339 |
+
if not api_key:
|
| 340 |
+
raise ValueError("Google API key is missing")
|
| 341 |
+
url = f"{self._places_base_url}{path}"
|
| 342 |
+
headers: Dict[str, str] = {
|
| 343 |
+
"X-Goog-Api-Key": api_key,
|
| 344 |
+
"Content-Type": "application/json",
|
| 345 |
+
}
|
| 346 |
+
if field_mask:
|
| 347 |
+
headers["X-Goog-FieldMask"] = field_mask
|
| 348 |
+
|
| 349 |
+
last_error: Optional[str] = None
|
| 350 |
+
|
| 351 |
+
for attempt in range(1 + self._max_retries):
|
| 352 |
+
try:
|
| 353 |
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
| 354 |
+
if method == "POST":
|
| 355 |
+
response = await client.post(url, json=body, headers=headers)
|
| 356 |
+
else:
|
| 357 |
+
response = await client.get(url, params=params, headers=headers)
|
| 358 |
+
|
| 359 |
+
if response.status_code == 200:
|
| 360 |
+
data: Dict[str, Any] = response.json()
|
| 361 |
+
return {"success": True, "data": data, "error": None}
|
| 362 |
+
|
| 363 |
+
try:
|
| 364 |
+
error_body = response.json()
|
| 365 |
+
error_msg = error_body.get("error", {}).get("message", response.text[:500])
|
| 366 |
+
error_detail = error_body.get("error", {})
|
| 367 |
+
_logger.warning("Places API error on %s: status=%s code=%s message=%s",
|
| 368 |
+
path, response.status_code, error_detail.get("code"), error_detail.get("message"))
|
| 369 |
+
except Exception:
|
| 370 |
+
error_msg = response.text[:500]
|
| 371 |
+
_logger.warning("Places API error on %s: HTTP %s body=%s", path, response.status_code, error_msg)
|
| 372 |
+
last_error = f"HTTP {response.status_code}: {error_msg}"
|
| 373 |
+
|
| 374 |
+
if response.status_code == 429:
|
| 375 |
+
backoff = 2.0 ** (attempt + 1)
|
| 376 |
+
_logger.warning("Places API rate limited on %s, backing off %.1fs", path, backoff)
|
| 377 |
+
if attempt < self._max_retries:
|
| 378 |
+
await asyncio.sleep(backoff)
|
| 379 |
+
continue
|
| 380 |
+
|
| 381 |
+
if 400 <= response.status_code < 500:
|
| 382 |
+
break
|
| 383 |
+
|
| 384 |
+
except httpx.TimeoutException:
|
| 385 |
+
last_error = "Request timed out"
|
| 386 |
+
_logger.warning("Places API timeout on %s (attempt %d/%d)", path, attempt + 1, 1 + self._max_retries)
|
| 387 |
+
except httpx.RequestError as e:
|
| 388 |
+
last_error = f"Request failed: {e}"
|
| 389 |
+
_logger.warning("Places API request error on %s: %s (attempt %d/%d)", path, last_error, attempt + 1, 1 + self._max_retries)
|
| 390 |
+
except Exception as e:
|
| 391 |
+
last_error = f"Unexpected error: {e}"
|
| 392 |
+
_logger.error("Places API unexpected error on %s: %s", path, last_error)
|
| 393 |
+
break
|
| 394 |
+
|
| 395 |
+
if attempt < self._max_retries:
|
| 396 |
+
await asyncio.sleep(1.0 * (attempt + 1))
|
| 397 |
+
|
| 398 |
+
return {"success": False, "error": last_error or "Unknown error"}
|