validops-east-1 commited on
Commit
0e4591f
·
1 Parent(s): 2170658

feat: add static maps service

Browse files
.gitignore CHANGED
@@ -137,4 +137,5 @@ deploy_hf.py
137
  ddl
138
  API_DESCRIPTION.md
139
  ENTERPRISE-API-ROADMAP*.md
140
- API-Implementation-Plan
 
 
137
  ddl
138
  API_DESCRIPTION.md
139
  ENTERPRISE-API-ROADMAP*.md
140
+ API-Implementation-Plan
141
+ google_oauth_test_creds.postman_environment.json
app/api/server.py CHANGED
@@ -107,6 +107,8 @@ async def lifespan(app: FastAPI):
107
  await pool_manager.close_all()
108
  from app.api.v1.google_oauth import close_oauth_service
109
  await close_oauth_service()
 
 
110
  from app.services.supabase import get_supabase_client
111
  client = get_supabase_client()
112
  if client:
 
107
  await pool_manager.close_all()
108
  from app.api.v1.google_oauth import close_oauth_service
109
  await close_oauth_service()
110
+ from app.api.v1.google_maps import close_maps_service
111
+ await close_maps_service()
112
  from app.services.supabase import get_supabase_client
113
  client = get_supabase_client()
114
  if client:
app/api/v1/google_maps.py CHANGED
@@ -1,9 +1,11 @@
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.core.logger import get_logger
9
  from app.models.schemas import (
@@ -27,6 +29,8 @@ from app.models.schemas import (
27
  GoogleQueryAutocompleteRequest,
28
  GoogleQueryAutocompleteResponse,
29
  GoogleReverseGeocodeRequest,
 
 
30
  )
31
  from app.services.google_maps_service import GoogleMapsService, PRICE_LEVEL_MAP
32
 
@@ -34,8 +38,15 @@ router = APIRouter(prefix="/google", tags=["Google Maps"])
34
  _logger = get_logger(__name__)
35
 
36
 
 
 
 
37
  def get_maps_service() -> GoogleMapsService:
38
- return GoogleMapsService()
 
 
 
 
39
 
40
 
41
  # ---------------------------------------------------------------------------
@@ -632,35 +643,132 @@ async def place_photo(
632
  x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
633
  service: GoogleMapsService = Depends(get_maps_service),
634
  ):
635
- from app.config import get_settings
636
- import json
637
- import httpx
638
-
639
- cfg = get_settings()
640
- url = f"{cfg.google_maps_places_base_url}/{photo_reference}/media"
641
- headers = {"X-Goog-Api-Key": x_goog_api_key}
642
- params = {"maxWidthPx": max_width_px, "maxHeightPx": max_height_px}
643
-
644
- async with httpx.AsyncClient(timeout=cfg.google_maps_timeout, follow_redirects=True) as client:
645
- resp = await client.get(url, headers=headers, params=params)
646
- if resp.status_code != 200:
647
- # Try to extract a clean error message from the response
648
- try:
649
- error_body = resp.json()
650
- err = error_body.get("error", {})
651
- msg = err.get("message", "")
652
- if "API key not valid" in msg or err.get("status") == "API_KEY_INVALID":
653
- clean_error = "Invalid Google API key. Please provide a valid API key via the X-Goog-Api-Key header."
654
- elif msg:
655
- clean_error = msg.rstrip(".") + "."
656
- else:
657
- clean_error = f"Failed to fetch photo (HTTP {resp.status_code})."
658
- except Exception:
659
- clean_error = f"Failed to fetch photo (HTTP {resp.status_code})."
660
-
661
- return Response(
662
- content=json.dumps({"success": False, "error": clean_error}),
663
- media_type="application/json",
664
- status_code=200,
665
- )
666
- return Response(content=resp.content, media_type=resp.headers.get("content-type", "image/jpeg"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import base64
4
+ import json
5
  import time
6
+ from typing import Any, Literal, Optional
7
 
8
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
9
 
10
  from app.core.logger import get_logger
11
  from app.models.schemas import (
 
29
  GoogleQueryAutocompleteRequest,
30
  GoogleQueryAutocompleteResponse,
31
  GoogleReverseGeocodeRequest,
32
+ GoogleStaticMapRequest,
33
+ GoogleStaticMapResponse,
34
  )
35
  from app.services.google_maps_service import GoogleMapsService, PRICE_LEVEL_MAP
36
 
 
38
  _logger = get_logger(__name__)
39
 
40
 
41
+ _maps_service = GoogleMapsService()
42
+
43
+
44
  def get_maps_service() -> GoogleMapsService:
45
+ return _maps_service
46
+
47
+
48
+ async def close_maps_service() -> None:
49
+ await _maps_service.close()
50
 
51
 
52
  # ---------------------------------------------------------------------------
 
643
  x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
644
  service: GoogleMapsService = Depends(get_maps_service),
645
  ):
646
+ result = await service.place_photo(
647
+ photo_reference=photo_reference,
648
+ max_width_px=max_width_px,
649
+ max_height_px=max_height_px,
650
+ api_key=x_goog_api_key,
651
+ )
652
+ error = result.get("error")
653
+ if error:
654
+ return Response(
655
+ content=json.dumps({"success": False, "error": error}),
656
+ media_type="application/json",
657
+ status_code=200,
658
+ )
659
+ return Response(
660
+ content=result.get("content", b""),
661
+ media_type=result.get("content_type", "image/jpeg"),
662
+ )
663
+
664
+
665
+ # ---------------------------------------------------------------------------
666
+ # Routes — Static Map (Google Maps Static API)
667
+ # ---------------------------------------------------------------------------
668
+
669
+ def _parse_static_map_size(size: str) -> tuple[int, int]:
670
+ """Split a validated 'WIDTHxHEIGHT' size string into (width, height)."""
671
+ width, _, height = size.partition("x")
672
+ return int(width), int(height)
673
+
674
+
675
+ @router.post("/staticmap", response_model=GoogleStaticMapResponse,
676
+ summary="Generate a static map image (Google Maps Static API)")
677
+ async def static_map(
678
+ body: GoogleStaticMapRequest,
679
+ x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
680
+ service: GoogleMapsService = Depends(get_maps_service),
681
+ ):
682
+ start = time.perf_counter()
683
+ params = body.model_dump(exclude_none=True)
684
+ result = await service.static_map(params, api_key=x_goog_api_key)
685
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
686
+ width, height = _parse_static_map_size(body.size)
687
+
688
+ error = result.get("error")
689
+ if error:
690
+ return GoogleStaticMapResponse(
691
+ success=False, time_ms=elapsed_ms, width=width, height=height,
692
+ scale=body.scale, format=body.format, error=error,
693
+ )
694
+
695
+ return GoogleStaticMapResponse(
696
+ success=True,
697
+ time_ms=elapsed_ms,
698
+ url=result.get("url"),
699
+ content_type=result.get("content_type", "image/png"),
700
+ image_base64=base64.b64encode(result.get("content", b"")).decode("ascii"),
701
+ width=width,
702
+ height=height,
703
+ scale=body.scale,
704
+ format=body.format,
705
+ warning=result.get("warning"),
706
+ )
707
+
708
+
709
+ @router.get("/staticmap",
710
+ summary="Generate a static map image (GET, returns raw image bytes)")
711
+ async def static_map_get(
712
+ center: Optional[str] = Query(None, max_length=1000, description="Center of the map: 'lat,lng' or address"),
713
+ zoom: Optional[int] = Query(None, ge=0, le=21, description="Map zoom level (0-21)"),
714
+ size: str = Query("640x640", pattern=r"^\d{1,3}x\d{1,3}$", description="Image size as 'WIDTHxHEIGHT'"),
715
+ scale: int = Query(1, ge=1, le=2, description="Pixel density scale (1 or 2)"),
716
+ format: Literal["png", "png8", "png32", "gif", "jpg", "jpg-baseline"] = Query("png", description="Image format"),
717
+ maptype: Literal["roadmap", "satellite", "hybrid", "terrain"] = Query("roadmap", description="Map type"),
718
+ language: Optional[str] = Query(None, max_length=10, description="Language code for labels"),
719
+ region: Optional[str] = Query(None, max_length=2, description="Region biasing (ccTLD)"),
720
+ map_id: Optional[str] = Query(None, max_length=200, description="Map ID for cloud-based styling"),
721
+ markers: Optional[list[str]] = Query(None, description="Raw 'markers' parameter values (pipe-delimited, repeated)"),
722
+ path: Optional[list[str]] = Query(None, description="Raw 'path' parameter values (pipe-delimited, repeated)"),
723
+ visible: Optional[str] = Query(None, description="Pipe-delimited locations to keep visible"),
724
+ styles: Optional[list[str]] = Query(None, description="Raw 'style' parameter values (pipe-delimited, repeated)"),
725
+ x_goog_api_key: str = Header(..., alias="X-Goog-Api-Key", description="Your Google API key"),
726
+ service: GoogleMapsService = Depends(get_maps_service),
727
+ ):
728
+ start = time.perf_counter()
729
+
730
+ width, height = _parse_static_map_size(size)
731
+ if not (1 <= width <= 640 and 1 <= height <= 640):
732
+ raise HTTPException(status_code=422, detail="size dimensions must each be between 1 and 640 pixels")
733
+ has_elements = bool(markers or path or visible)
734
+ if not has_elements and (not center or zoom is None):
735
+ raise HTTPException(
736
+ status_code=422,
737
+ detail="center and zoom are required unless markers, path, or visible are provided",
738
+ )
739
+
740
+ params: dict[str, Any] = {
741
+ "center": center,
742
+ "zoom": zoom,
743
+ "size": size,
744
+ "scale": scale,
745
+ "format": format,
746
+ "maptype": maptype,
747
+ "language": language,
748
+ "region": region,
749
+ "map_id": map_id,
750
+ "markers": markers or [],
751
+ "paths": path or [],
752
+ "visible": visible,
753
+ "styles": styles or [],
754
+ }
755
+ result = await service.static_map(params, api_key=x_goog_api_key)
756
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
757
+
758
+ error = result.get("error")
759
+ if error:
760
+ return Response(
761
+ content=json.dumps({"success": False, "time_ms": elapsed_ms, "error": error}),
762
+ media_type="application/json",
763
+ status_code=200,
764
+ )
765
+
766
+ headers = {}
767
+ warning = result.get("warning")
768
+ if warning:
769
+ headers["X-Staticmap-API-Warning"] = warning
770
+ return Response(
771
+ content=result.get("content", b""),
772
+ media_type=result.get("content_type", "image/png"),
773
+ headers=headers,
774
+ )
app/models/schemas.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  from enum import Enum
4
  from typing import Any, Dict, List, Literal, Optional
5
 
@@ -953,3 +954,236 @@ class GoogleOAuthVerifyResponse(BaseModel):
953
  valid: bool
954
  user: Optional[GoogleOAuthUserInfo] = None
955
  error: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import re
4
  from enum import Enum
5
  from typing import Any, Dict, List, Literal, Optional
6
 
 
954
  valid: bool
955
  user: Optional[GoogleOAuthUserInfo] = None
956
  error: Optional[str] = None
957
+
958
+
959
+ # ---------------------------------------------------------------------------
960
+ # Google Maps Static API
961
+ # ---------------------------------------------------------------------------
962
+
963
+ _MAP_NAMED_COLORS: frozenset = frozenset({
964
+ "black", "brown", "green", "purple", "yellow", "blue", "gray", "orange", "red", "white",
965
+ })
966
+
967
+
968
+ class GoogleStaticMapMarkerStyle(BaseModel):
969
+ """Visual style descriptor for a group of static map markers."""
970
+
971
+ size: Optional[Literal["tiny", "small", "mid", "normal"]] = Field(
972
+ None, description="Marker size (tiny, small, mid, or default 'normal')"
973
+ )
974
+ color: Optional[str] = Field(
975
+ None, description="24-bit hex color (0xRRGGBB) or a predefined named color"
976
+ )
977
+ label: Optional[str] = Field(
978
+ None, pattern=r"^[A-Z0-9]$", description="Single uppercase alphanumeric marker label (A-Z, 0-9)"
979
+ )
980
+ icon: Optional[str] = Field(None, max_length=2000, description="Custom icon URL (PNG/JPEG/GIF)")
981
+ anchor: Optional[str] = Field(
982
+ None, max_length=50, description="Icon anchor (predefined alignment or 'x,y' point)"
983
+ )
984
+ scale: Optional[int] = Field(None, ge=1, le=4, description="Marker scale factor (1, 2 or 4)")
985
+
986
+ @field_validator("color")
987
+ @classmethod
988
+ def _validate_marker_color(cls, v: Optional[str]) -> Optional[str]:
989
+ if v is None:
990
+ return v
991
+ normalized = v.lower()
992
+ if normalized in _MAP_NAMED_COLORS:
993
+ return v
994
+ if re.fullmatch(r"0x[0-9a-fA-F]{6}", normalized):
995
+ return v
996
+ raise ValueError("color must be a named color or a 24-bit hex value like '0x00FF00'")
997
+
998
+ @model_validator(mode="after")
999
+ def _check_anchor_requires_icon(self) -> "GoogleStaticMapMarkerStyle":
1000
+ if self.anchor and not self.icon:
1001
+ raise ValueError("anchor is only valid when a custom icon is provided")
1002
+ return self
1003
+
1004
+
1005
+ class GoogleStaticMapMarker(BaseModel):
1006
+ """One or more marker locations sharing the same visual style."""
1007
+
1008
+ style: Optional[GoogleStaticMapMarkerStyle] = Field(None, description="Optional marker style")
1009
+ locations: List[str] = Field(..., min_length=1, description="One or more locations (lat,lng or address)")
1010
+
1011
+ @field_validator("locations")
1012
+ @classmethod
1013
+ def _validate_locations(cls, v: List[str]) -> List[str]:
1014
+ cleaned = [loc.strip() for loc in v]
1015
+ if any(not loc for loc in cleaned):
1016
+ raise ValueError("marker locations must not contain empty values")
1017
+ return cleaned
1018
+
1019
+
1020
+ class GoogleStaticMapPathStyle(BaseModel):
1021
+ """Visual style descriptor for a static map path / polyline."""
1022
+
1023
+ color: Optional[str] = Field(None, description="Line color as 24-bit or 32-bit hex (0xRRGGBB or 0xRRGGBBAA)")
1024
+ weight: Optional[int] = Field(None, ge=0, description="Path line weight in pixels")
1025
+ fill: Optional[str] = Field(None, description="Fill color as hex (0xRRGGBB or 0xRRGGBBAA)")
1026
+ geodesic: Optional[bool] = Field(None, description="Draw the path as a geodesic curve")
1027
+
1028
+ @field_validator("color", "fill")
1029
+ @classmethod
1030
+ def _validate_path_hex(cls, v: Optional[str]) -> Optional[str]:
1031
+ if v is None:
1032
+ return v
1033
+ if re.fullmatch(r"0x[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?", v):
1034
+ return v
1035
+ raise ValueError("color must be a hex value like '0x00FF00' or '0x00FF00AA'")
1036
+
1037
+
1038
+ class GoogleStaticMapPath(BaseModel):
1039
+ """A single path / polyline overlay on the static map."""
1040
+
1041
+ style: Optional[GoogleStaticMapPathStyle] = Field(None, description="Optional path style")
1042
+ points: Optional[List[str]] = Field(
1043
+ None, min_length=2, description="Two or more connected points (lat,lng or address)"
1044
+ )
1045
+ encoded_polyline: Optional[str] = Field(
1046
+ None, description="Encoded polyline (without the 'enc:' prefix)"
1047
+ )
1048
+
1049
+ @field_validator("points")
1050
+ @classmethod
1051
+ def _validate_points(cls, v: Optional[List[str]]) -> Optional[List[str]]:
1052
+ if v is None:
1053
+ return v
1054
+ cleaned = [pt.strip() for pt in v]
1055
+ if any(not pt for pt in cleaned):
1056
+ raise ValueError("path points must not contain empty values")
1057
+ return cleaned
1058
+
1059
+ @model_validator(mode="after")
1060
+ def _check_points_or_polyline(self) -> "GoogleStaticMapPath":
1061
+ has_points = self.points is not None and len(self.points) >= 2
1062
+ has_polyline = bool(self.encoded_polyline)
1063
+ if not has_points and not has_polyline:
1064
+ raise ValueError("path requires either at least 2 points or an encoded_polyline")
1065
+ if has_points and has_polyline:
1066
+ raise ValueError("path accepts either points or encoded_polyline, not both")
1067
+ return self
1068
+
1069
+
1070
+ class GoogleStaticMapStyleRule(BaseModel):
1071
+ """A single custom map style rule (feature/element + style operations)."""
1072
+
1073
+ feature: Optional[str] = Field(None, description="Map feature to style (e.g. 'road', 'water', 'poi')")
1074
+ element: Optional[str] = Field(None, description="Element of the feature (e.g. 'geometry', 'labels')")
1075
+ hue: Optional[str] = Field(None, description="Hue color as hex ('#RRGGBB')")
1076
+ lightness: Optional[float] = Field(None, ge=-100, le=100, description="Lightness adjustment (-100 to 100)")
1077
+ saturation: Optional[float] = Field(None, ge=-100, le=100, description="Saturation adjustment (-100 to 100)")
1078
+ gamma: Optional[float] = Field(None, ge=0.01, le=10.0, description="Gamma correction (0.01 to 10.0)")
1079
+ invert_lightness: Optional[bool] = Field(None, description="Invert the existing lightness")
1080
+ visibility: Optional[Literal["on", "off", "simplified"]] = Field(
1081
+ None, description="Visibility of the element (on, off, simplified)"
1082
+ )
1083
+ color: Optional[str] = Field(None, description="Color as hex ('#RRGGBB')")
1084
+ weight: Optional[int] = Field(None, ge=0, description="Feature weight in pixels")
1085
+
1086
+ @field_validator("color", "hue")
1087
+ @classmethod
1088
+ def _validate_style_hex(cls, v: Optional[str]) -> Optional[str]:
1089
+ if v is None:
1090
+ return v
1091
+ if re.fullmatch(r"#[0-9a-fA-F]{6}", v):
1092
+ return v
1093
+ raise ValueError("color/hue must be a hex value like '#FF0000'")
1094
+
1095
+ @model_validator(mode="after")
1096
+ def _check_has_operation(self) -> "GoogleStaticMapStyleRule":
1097
+ has_operation = any(
1098
+ v is not None
1099
+ for v in (
1100
+ self.hue, self.lightness, self.saturation, self.gamma,
1101
+ self.invert_lightness, self.visibility, self.color, self.weight,
1102
+ )
1103
+ )
1104
+ if not has_operation:
1105
+ raise ValueError("style rule must contain at least one style operation")
1106
+ return self
1107
+
1108
+
1109
+ class GoogleStaticMapRequest(BaseModel):
1110
+ """Request payload for generating a Google Maps Static API image."""
1111
+
1112
+ center: Optional[str] = Field(
1113
+ None, max_length=1000, description="Center of the map: 'lat,lng' pair or a geocodable address"
1114
+ )
1115
+ zoom: Optional[int] = Field(None, ge=0, le=21, description="Map zoom level (0-21)")
1116
+ size: str = Field(
1117
+ "640x640", description="Image size as '{width}x{height}', each dimension 1-640 pixels"
1118
+ )
1119
+ scale: int = Field(1, ge=1, le=2, description="Pixel density scale factor (1 or 2)")
1120
+ format: Literal["png", "png8", "png32", "gif", "jpg", "jpg-baseline"] = Field(
1121
+ "png", description="Image format"
1122
+ )
1123
+ maptype: Literal["roadmap", "satellite", "hybrid", "terrain"] = Field(
1124
+ "roadmap", description="Map type"
1125
+ )
1126
+ language: Optional[str] = Field(None, max_length=10, description="Language code for map labels")
1127
+ region: Optional[str] = Field(None, max_length=2, description="Region biasing (two-character ccTLD)")
1128
+ map_id: Optional[str] = Field(None, max_length=200, description="Map ID for cloud-based map styling")
1129
+ markers: Optional[List[GoogleStaticMapMarker]] = Field(None, description="Marker groups to place on the map")
1130
+ paths: Optional[List[GoogleStaticMapPath]] = Field(None, description="Paths / polylines to overlay")
1131
+ visible: Optional[List[str]] = Field(
1132
+ None, min_length=1, description="Locations to keep visible on the map (no markers drawn)"
1133
+ )
1134
+ styles: Optional[List[GoogleStaticMapStyleRule]] = Field(None, description="Custom map style rules")
1135
+ signature_secret: Optional[str] = Field(
1136
+ None, max_length=500, description="URL signing secret used to digitally sign the request"
1137
+ )
1138
+
1139
+ @field_validator("size")
1140
+ @classmethod
1141
+ def _validate_size(cls, v: str) -> str:
1142
+ match = re.fullmatch(r"(\d{1,3})x(\d{1,3})", v)
1143
+ if not match:
1144
+ raise ValueError("size must be of the form '{width}x{height}' (e.g. '640x480')")
1145
+ width, height = int(match.group(1)), int(match.group(2))
1146
+ if not (1 <= width <= 640 and 1 <= height <= 640):
1147
+ raise ValueError("size dimensions must each be between 1 and 640 pixels")
1148
+ return v
1149
+
1150
+ @field_validator("center")
1151
+ @classmethod
1152
+ def _validate_center(cls, v: Optional[str]) -> Optional[str]:
1153
+ if v is None:
1154
+ return v
1155
+ cleaned = v.strip()
1156
+ if not cleaned:
1157
+ raise ValueError("center must not be empty")
1158
+ return cleaned
1159
+
1160
+ @model_validator(mode="after")
1161
+ def _check_center_and_zoom(self) -> "GoogleStaticMapRequest":
1162
+ has_elements = bool(self.markers or self.paths or self.visible)
1163
+ if not has_elements:
1164
+ missing = []
1165
+ if not self.center:
1166
+ missing.append("center")
1167
+ if self.zoom is None:
1168
+ missing.append("zoom")
1169
+ if missing:
1170
+ raise ValueError(
1171
+ f"{', '.join(missing)} is/are required when no markers, paths, or visible locations are supplied"
1172
+ )
1173
+ return self
1174
+
1175
+
1176
+ class GoogleStaticMapResponse(BaseModel):
1177
+ """Response payload for the static map generation endpoint."""
1178
+
1179
+ success: bool
1180
+ time_ms: float
1181
+ url: Optional[str] = Field(None, description="Fully built Google Static Maps URL")
1182
+ content_type: str = Field("image/png", description="MIME type of the returned image")
1183
+ image_base64: Optional[str] = Field(None, description="Base64-encoded image bytes")
1184
+ width: int = Field(640, description="Image width in pixels")
1185
+ height: int = Field(640, description="Image height in pixels")
1186
+ scale: int = Field(1, description="Scale factor used for the image")
1187
+ format: str = Field("png", description="Image format")
1188
+ warning: Optional[str] = Field(None, description="Warning returned by the Maps Static API, if any")
1189
+ error: Optional[str] = None
app/services/google_maps_service.py CHANGED
@@ -1,8 +1,13 @@
1
  from __future__ import annotations
2
 
3
  import asyncio
 
 
 
4
  import logging
5
- from typing import Any, Dict, Optional
 
 
6
 
7
  import httpx
8
 
@@ -47,6 +52,21 @@ DEFAULT_AUTOCOMPLETE_FIELD_MASK = (
47
  "suggestions.queryPrediction.text.text"
48
  )
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  class GoogleMapsService:
52
 
@@ -55,6 +75,35 @@ class GoogleMapsService:
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
@@ -267,6 +316,180 @@ class GoogleMapsService:
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
  # -----------------------------------------------------------------------
@@ -288,14 +511,14 @@ class GoogleMapsService:
288
  async def _call_api(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
289
  url = f"{self._base_url}{path}"
290
  last_error: Optional[str] = None
 
291
 
292
  for attempt in range(1 + self._max_retries):
293
  try:
294
- async with httpx.AsyncClient(timeout=self._timeout) as client:
295
- response = await client.get(url, params=params)
296
- response.raise_for_status()
297
- data: Dict[str, Any] = response.json()
298
- return self._normalize_response(data)
299
 
300
  except httpx.TimeoutException:
301
  last_error = "Request timed out"
@@ -390,34 +613,34 @@ class GoogleMapsService:
390
  headers["X-Goog-FieldMask"] = field_mask
391
 
392
  last_error: Optional[str] = None
 
393
 
394
  for attempt in range(1 + self._max_retries):
395
  try:
396
- async with httpx.AsyncClient(timeout=self._timeout) as client:
397
- if method == "POST":
398
- response = await client.post(url, json=body, headers=headers)
399
- else:
400
- response = await client.get(url, params=params, headers=headers)
401
-
402
- if response.status_code == 200:
403
- data: Dict[str, Any] = response.json()
404
- return {"success": True, "data": data, "error": None}
405
-
406
- last_error = self._format_places_api_error(response.status_code, response)
407
- _logger.warning(
408
- "Places API error on %s: HTTP %s -> %s",
409
- path, response.status_code, last_error,
410
- )
411
 
412
- if response.status_code == 429:
413
- backoff = 2.0 ** (attempt + 1)
414
- _logger.warning("Places API rate limited on %s, backing off %.1fs", path, backoff)
415
- if attempt < self._max_retries:
416
- await asyncio.sleep(backoff)
417
- continue
418
-
419
- if 400 <= response.status_code < 500:
420
- break
 
 
 
 
 
 
 
 
 
 
421
 
422
  except httpx.TimeoutException:
423
  last_error = "Request timed out"
@@ -434,3 +657,51 @@ class GoogleMapsService:
434
  await asyncio.sleep(1.0 * (attempt + 1))
435
 
436
  return {"success": False, "error": last_error or "Unknown error"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import asyncio
4
+ import base64
5
+ import hashlib
6
+ import hmac
7
  import logging
8
+ import re
9
+ from typing import Any, Dict, Optional, Tuple
10
+ from urllib.parse import unquote, urlencode
11
 
12
  import httpx
13
 
 
52
  "suggestions.queryPrediction.text.text"
53
  )
54
 
55
+ STATIC_MAP_BASE_URL = "https://maps.googleapis.com/maps/api/staticmap"
56
+
57
+ STATIC_MAP_FORMAT_CONTENT_TYPES: dict[str, str] = {
58
+ "png": "image/png",
59
+ "png8": "image/png",
60
+ "png32": "image/png",
61
+ "gif": "image/gif",
62
+ "jpg": "image/jpeg",
63
+ "jpg-baseline": "image/jpeg",
64
+ }
65
+
66
+ _STYLE_OPERATION_KEYS = (
67
+ "hue", "lightness", "saturation", "gamma", "invert_lightness", "visibility", "color", "weight",
68
+ )
69
+
70
 
71
  class GoogleMapsService:
72
 
 
75
  self._places_base_url: str = _settings.google_maps_places_base_url
76
  self._timeout: int = _settings.google_maps_timeout
77
  self._max_retries: int = _settings.google_maps_max_retries
78
+ self._client: Optional[httpx.AsyncClient] = None
79
+ self._client_lock = asyncio.Lock()
80
+
81
+ # -----------------------------------------------------------------------
82
+ # HTTP client management
83
+ # -----------------------------------------------------------------------
84
+
85
+ async def _get_client(self) -> httpx.AsyncClient:
86
+ """Return the shared connection-pooled AsyncClient, creating it lazily."""
87
+ if self._client is None or self._client.is_closed:
88
+ async with self._client_lock:
89
+ if self._client is None or self._client.is_closed:
90
+ self._client = httpx.AsyncClient(
91
+ timeout=self._timeout,
92
+ follow_redirects=True,
93
+ limits=httpx.Limits(
94
+ max_connections=100,
95
+ max_keepalive_connections=20,
96
+ keepalive_expiry=30,
97
+ ),
98
+ )
99
+ return self._client
100
+
101
+ async def close(self) -> None:
102
+ """Close the shared AsyncClient and release pooled connections."""
103
+ async with self._client_lock:
104
+ if self._client is not None and not self._client.is_closed:
105
+ await self._client.aclose()
106
+ self._client = None
107
 
108
  # -----------------------------------------------------------------------
109
  # Geocoding — still uses the legacy Google Geocoding API
 
316
 
317
  return await self._call_places_api("GET", path, params=params, field_mask=field_mask, api_key=api_key)
318
 
319
+ # -----------------------------------------------------------------------
320
+ # Google Maps Static API — image generation
321
+ # -----------------------------------------------------------------------
322
+
323
+ @staticmethod
324
+ def _build_marker_param(marker: Dict[str, Any]) -> str:
325
+ """Serialize a marker group dict into a Google `markers` parameter string."""
326
+ style = marker.get("style") or {}
327
+ parts: list[str] = []
328
+ for key in ("size", "color", "label", "icon", "anchor", "scale"):
329
+ value = style.get(key)
330
+ if value is not None:
331
+ parts.append(f"{key}:{value}")
332
+ parts.extend(marker.get("locations", []))
333
+ return "|".join(parts)
334
+
335
+ @staticmethod
336
+ def _build_path_param(path: Dict[str, Any]) -> str:
337
+ """Serialize a path dict into a Google `path` parameter string."""
338
+ style = path.get("style") or {}
339
+ parts: list[str] = []
340
+ if style.get("color"):
341
+ parts.append(f"color:{style['color']}")
342
+ if style.get("weight") is not None:
343
+ parts.append(f"weight:{style['weight']}")
344
+ if style.get("fill"):
345
+ parts.append(f"fill:{style['fill']}")
346
+ if style.get("geodesic"):
347
+ parts.append("geodesic:true")
348
+ if path.get("encoded_polyline"):
349
+ parts.append(f"enc:{path['encoded_polyline']}")
350
+ else:
351
+ parts.extend(path.get("points", []))
352
+ return "|".join(parts)
353
+
354
+ @staticmethod
355
+ def _build_style_param(rule: Dict[str, Any]) -> str:
356
+ """Serialize a style rule dict into a Google `style` parameter string."""
357
+ parts: list[str] = []
358
+ if rule.get("feature"):
359
+ parts.append(f"feature:{rule['feature']}")
360
+ if rule.get("element"):
361
+ parts.append(f"element:{rule['element']}")
362
+ for key in _STYLE_OPERATION_KEYS:
363
+ value = rule.get(key)
364
+ if value is None:
365
+ continue
366
+ if isinstance(value, bool):
367
+ parts.append(f"{key}:{str(value).lower()}")
368
+ else:
369
+ parts.append(f"{key}:{value}")
370
+ return "|".join(parts)
371
+
372
+ @staticmethod
373
+ def _sign_static_map_url(url: str, signing_secret: str) -> str:
374
+ """Apply a Google Maps URL digital signature (HMAC-SHA1) to a request URL."""
375
+ # Strip the protocol scheme and host, keeping only the path + query.
376
+ path_and_query = url.split("://", 1)[1].split("/", 1)[1]
377
+ # Remove any existing signature parameter before signing.
378
+ path_and_query = re.sub(r"&signature=[^&]*", "", path_and_query)
379
+ decoded = unquote(path_and_query)
380
+ key = base64.urlsafe_b64decode(signing_secret + "=" * (-len(signing_secret) % 4))
381
+ signature = base64.urlsafe_b64encode(
382
+ hmac.new(key, decoded.encode("utf-8"), hashlib.sha1).digest()
383
+ ).rstrip(b"=").decode("ascii")
384
+ return f"{url}&signature={signature}"
385
+
386
+ def build_static_map_url(self, params: Dict[str, Any], api_key: str,
387
+ signature_secret: Optional[str] = None) -> Optional[str]:
388
+ """Build a Google Static Maps URL from validated params.
389
+
390
+ Returns ``None`` when the API key is missing. ``markers`` / ``paths`` /
391
+ ``styles`` entries may be either structured dicts (as produced by the
392
+ request schema) or pre-formatted strings (raw GET query values).
393
+ """
394
+ if not api_key or not api_key.strip():
395
+ return None
396
+
397
+ query: list[Tuple[str, str]] = []
398
+ for key in ("center", "zoom", "size", "scale", "format", "maptype", "language", "region", "map_id"):
399
+ value = params.get(key)
400
+ if value is not None:
401
+ query.append((key, str(value)))
402
+
403
+ for marker in params.get("markers", []):
404
+ query.append(("markers", marker if isinstance(marker, str) else self._build_marker_param(marker)))
405
+ for path in params.get("paths", []):
406
+ query.append(("path", path if isinstance(path, str) else self._build_path_param(path)))
407
+
408
+ visible = params.get("visible")
409
+ if visible:
410
+ if isinstance(visible, str):
411
+ visible = [v for v in visible.split("|") if v.strip()]
412
+ if visible:
413
+ query.append(("visible", "|".join(visible)))
414
+
415
+ for rule in params.get("styles", []):
416
+ query.append(("style", rule if isinstance(rule, str) else self._build_style_param(rule)))
417
+
418
+ query.append(("key", api_key))
419
+ url = f"{STATIC_MAP_BASE_URL}?{urlencode(query)}"
420
+ if signature_secret:
421
+ url = self._sign_static_map_url(url, signature_secret)
422
+ return url
423
+
424
+ @staticmethod
425
+ def _format_static_map_error(status_code: int, response: httpx.Response) -> str:
426
+ """Extract a clean, human-readable error message from a Static Maps error response."""
427
+ body = response.text.strip()
428
+ if status_code == 401:
429
+ return "Unauthorized. The API key may be missing or restricted."
430
+ if status_code == 403:
431
+ if "api key" in body.lower():
432
+ return "Invalid Google API key. Please provide a valid API key via the X-Goog-Api-Key header."
433
+ return "Access forbidden. The API key may not have the required APIs enabled or the request is unsigned."
434
+ if status_code == 429:
435
+ return "API rate limit exceeded. Please wait and retry."
436
+ if body:
437
+ return body[:500] if len(body) > 500 else body
438
+ return f"Maps Static API error (HTTP {status_code})."
439
+
440
+ async def static_map(self, params: Dict[str, Any], api_key: str) -> Dict[str, Any]:
441
+ """Fetch a static map image from the Google Maps Static API.
442
+
443
+ ``params`` should be the validated request payload (as a dict). Returns a
444
+ dict with ``success``, and on success ``content`` (image bytes),
445
+ ``content_type``, ``url`` and optionally ``warning``.
446
+ """
447
+ signature_secret = params.pop("signature_secret", None) if isinstance(params, dict) else None
448
+ url = self.build_static_map_url(params, api_key, signature_secret)
449
+ if url is None:
450
+ return {"success": False, "error": "Google API key is missing. Provide a valid API key via the X-Goog-Api-Key header."}
451
+
452
+ last_error: Optional[str] = None
453
+ client = await self._get_client()
454
+ for attempt in range(1 + self._max_retries):
455
+ try:
456
+ response = await client.get(url)
457
+ if response.status_code == 200:
458
+ return {
459
+ "success": True,
460
+ "content": response.content,
461
+ "content_type": response.headers.get("content-type", "image/png"),
462
+ "url": url,
463
+ "warning": response.headers.get("X-Staticmap-API-Warning") or None,
464
+ "error": None,
465
+ }
466
+
467
+ last_error = self._format_static_map_error(response.status_code, response)
468
+ _logger.warning("Maps Static API error on static map: HTTP %s -> %s", response.status_code, last_error)
469
+
470
+ if response.status_code == 429:
471
+ if attempt < self._max_retries:
472
+ await asyncio.sleep(2.0 ** (attempt + 1))
473
+ continue
474
+ if 400 <= response.status_code < 500:
475
+ break
476
+
477
+ except httpx.TimeoutException:
478
+ last_error = "Request timed out"
479
+ _logger.warning("Maps Static API timeout (attempt %d/%d)", attempt + 1, 1 + self._max_retries)
480
+ except httpx.RequestError as e:
481
+ last_error = f"Request failed: {e}"
482
+ _logger.warning("Maps Static API request error: %s (attempt %d/%d)", e, attempt + 1, 1 + self._max_retries)
483
+ except Exception as e:
484
+ last_error = f"Unexpected error: {e}"
485
+ _logger.error("Maps Static API unexpected error: %s", last_error)
486
+ break
487
+
488
+ if attempt < self._max_retries:
489
+ await asyncio.sleep(1.0 * (attempt + 1))
490
+
491
+ return {"success": False, "error": last_error or "Unknown error"}
492
+
493
  # -----------------------------------------------------------------------
494
  # Internal — Legacy API (for Geocoding)
495
  # -----------------------------------------------------------------------
 
511
  async def _call_api(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
512
  url = f"{self._base_url}{path}"
513
  last_error: Optional[str] = None
514
+ client = await self._get_client()
515
 
516
  for attempt in range(1 + self._max_retries):
517
  try:
518
+ response = await client.get(url, params=params)
519
+ response.raise_for_status()
520
+ data: Dict[str, Any] = response.json()
521
+ return self._normalize_response(data)
 
522
 
523
  except httpx.TimeoutException:
524
  last_error = "Request timed out"
 
613
  headers["X-Goog-FieldMask"] = field_mask
614
 
615
  last_error: Optional[str] = None
616
+ client = await self._get_client()
617
 
618
  for attempt in range(1 + self._max_retries):
619
  try:
620
+ if method == "POST":
621
+ response = await client.post(url, json=body, headers=headers)
622
+ else:
623
+ response = await client.get(url, params=params, headers=headers)
 
 
 
 
 
 
 
 
 
 
 
624
 
625
+ if response.status_code == 200:
626
+ data: Dict[str, Any] = response.json()
627
+ return {"success": True, "data": data, "error": None}
628
+
629
+ last_error = self._format_places_api_error(response.status_code, response)
630
+ _logger.warning(
631
+ "Places API error on %s: HTTP %s -> %s",
632
+ path, response.status_code, last_error,
633
+ )
634
+
635
+ if response.status_code == 429:
636
+ backoff = 2.0 ** (attempt + 1)
637
+ _logger.warning("Places API rate limited on %s, backing off %.1fs", path, backoff)
638
+ if attempt < self._max_retries:
639
+ await asyncio.sleep(backoff)
640
+ continue
641
+
642
+ if 400 <= response.status_code < 500:
643
+ break
644
 
645
  except httpx.TimeoutException:
646
  last_error = "Request timed out"
 
657
  await asyncio.sleep(1.0 * (attempt + 1))
658
 
659
  return {"success": False, "error": last_error or "Unknown error"}
660
+
661
+ async def place_photo(self, photo_reference: str, max_width_px: int,
662
+ max_height_px: int, api_key: Optional[str]) -> Dict[str, Any]:
663
+ """Fetch a place photo's image bytes from the New Places API."""
664
+ if not api_key or not api_key.strip():
665
+ return {"success": False, "error": "Google API key is missing. Provide a valid API key via the X-Goog-Api-Key header."}
666
+ url = f"{self._places_base_url}/{photo_reference}/media"
667
+ headers: Dict[str, str] = {"X-Goog-Api-Key": api_key}
668
+ params: Dict[str, Any] = {"maxWidthPx": max_width_px, "maxHeightPx": max_height_px}
669
+
670
+ last_error: Optional[str] = None
671
+ client = await self._get_client()
672
+ for attempt in range(1 + self._max_retries):
673
+ try:
674
+ response = await client.get(url, headers=headers, params=params)
675
+ if response.status_code == 200:
676
+ return {
677
+ "success": True,
678
+ "content": response.content,
679
+ "content_type": response.headers.get("content-type", "image/jpeg"),
680
+ "error": None,
681
+ }
682
+
683
+ last_error = self._format_places_api_error(response.status_code, response)
684
+ _logger.warning("Places API photo error: HTTP %s -> %s", response.status_code, last_error)
685
+
686
+ if response.status_code == 429:
687
+ if attempt < self._max_retries:
688
+ await asyncio.sleep(2.0 ** (attempt + 1))
689
+ continue
690
+ if 400 <= response.status_code < 500:
691
+ break
692
+
693
+ except httpx.TimeoutException:
694
+ last_error = "Request timed out"
695
+ _logger.warning("Places API photo timeout (attempt %d/%d)", attempt + 1, 1 + self._max_retries)
696
+ except httpx.RequestError as e:
697
+ last_error = f"Request failed: {e}"
698
+ _logger.warning("Places API photo request error: %s (attempt %d/%d)", e, attempt + 1, 1 + self._max_retries)
699
+ except Exception as e:
700
+ last_error = f"Unexpected error: {e}"
701
+ _logger.error("Places API photo unexpected error: %s", last_error)
702
+ break
703
+
704
+ if attempt < self._max_retries:
705
+ await asyncio.sleep(1.0 * (attempt + 1))
706
+
707
+ return {"success": False, "error": last_error or "Unknown error"}