Spaces:
Running
Running
Commit ·
0e4591f
1
Parent(s): 2170658
feat: add static maps service
Browse files- .gitignore +2 -1
- app/api/server.py +2 -0
- app/api/v1/google_maps.py +143 -35
- app/models/schemas.py +234 -0
- app/services/google_maps_service.py +301 -30
.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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 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 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 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 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"}
|