validops-east-1 commited on
Commit
1912d07
·
1 Parent(s): 947ea10

feat: google apis added

Browse files
.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"}