Source code for apple_maps_api.client

"""Apple Maps Server API client library.

API docs: https://developer.apple.com/documentation/applemapsserverapi

This library provides a Python client for the Apple Maps Server API with JWT-based
authentication, automatic token management, and retry logic.
"""

import logging
import os
import time
from collections.abc import Sequence
from typing import Required, TypedDict, Unpack, overload
from urllib.parse import parse_qs, urlparse

import funcy as f
import httpx2
import jwt
from tenacity import (
    before_sleep_log,
    retry,
    retry_if_exception,
    stop_after_attempt,
    wait_exponential,
)

from apple_maps_api.models import (
    AddressCategory,
    AutocompleteResult,
    MapRegion,
    PlaceResults,
    PoiCategory,
    SearchACResultType,
    SearchAutocompleteResponse,
    SearchRegionPriority,
    SearchResponse,
    SearchResultType,
    TokenResponse,
)

[docs] log = logging.getLogger(__name__)
def _csv(value: str | Sequence | None) -> str | None: """Serialize a string or sequence of values to Apple's comma-separated form.""" if value is None: return None if isinstance(value, str): return value return ",".join(str(v) for v in value) def _lat_lng_to_apple_str( *, lat: float | None = None, lng: float | None = None, ) -> str | None: """Serialize an optional lat/lng pair to Apple's 'lat,lng' string form.""" if lat is None and lng is None: return None assert lat is not None and lng is not None, "lat and lng must both be provided" return f"{lat},{lng}" def _search_region(region: MapRegion | None) -> str | None: """Serialize MapRegion to Apple's searchRegion form: northLat,eastLng,southLat,westLng.""" if region is None: return None return ( f"{region.northLatitude},{region.eastLongitude}," f"{region.southLatitude},{region.westLongitude}" ) # shared timeout: 10s connect, 30s read _TIMEOUT = httpx2.Timeout(connect=10, read=30, write=10, pool=10) # Auth JWT lifetime is client-chosen; Apple does not document a max for Maps Server API. # Access tokens from /v1/token are separately ~30 min (Apple-controlled). # https://developer.apple.com/documentation/applemapsserverapi/creating-and-using-tokens-with-maps-server-api _DEFAULT_JWT_TTL_SECONDS = 60 * 60 def _is_retryable_http_error(exception: BaseException) -> bool: """Return True to retry, False to stop. Retries on network-level errors and HTTP 429/5xx. All other 4xx errors are permanent failures and should not be retried. """ if not isinstance(exception, httpx2.HTTPError): return False if isinstance(exception, httpx2.HTTPStatusError): status = ( exception.response.status_code if exception.response is not None else None ) return status == 429 or (status is not None and status >= 500) return True _retry_policy = retry( stop=stop_after_attempt(6), wait=wait_exponential(multiplier=1, min=0, max=32), retry=retry_if_exception(_is_retryable_http_error), before_sleep=before_sleep_log(log, logging.INFO), reraise=True, ) class _GeocodeOptionsBase(TypedDict, total=False): """Shared optional kwargs for :meth:`AppleMapsClient.geocode`. Location params (all optional; different roles): - ``lat`` / ``lng``: app-defined point hint; sent as Apple's ``searchLocation`` (must pass both). Primary geographic bias. - ``search_region``: app-defined bounding-box hint as :class:`MapRegion`. - ``user_lat`` / ``user_lng``: the *user's* current position (must pass both). Used for ranking/relevance; if ``lat``/``lng`` are omitted, some Apple endpoints may fall back to user position as the search hint. """ limit_to_countries: str | Sequence[str] lang: str search_region: MapRegion user_lat: float user_lng: float
[docs] class GeocodeOptionsLatLng(_GeocodeOptionsBase, total=False): """Geocode options with required ``lat`` and ``lng`` location bias."""
[docs] lat: Required[float]
[docs] lng: Required[float]
[docs] class GeocodeOptions(_GeocodeOptionsBase, total=False): """All optional kwargs for :meth:`AppleMapsClient.geocode`."""
[docs] lat: float
[docs] lng: float
# Location bias is optional; when set both lat and lng are required. # Overloads below enforce that for pyright; runtime asserts in _lat_lng_to_apple_str. class _SearchOptionsBase(TypedDict, total=False): """Shared optional kwargs for :meth:`AppleMapsClient.search`. Location params (all optional; different roles): - ``lat`` / ``lng``: app-defined point hint; sent as Apple's ``searchLocation`` (must pass both). Primary geographic bias — "search near this map point". - ``search_region``: app-defined bounding-box hint as :class:`MapRegion`. - ``user_lat`` / ``user_lng``: the *user's* current position (must pass both). Used for ranking/relevance; Search may fall back to it as ``searchLocation`` when ``lat``/``lng`` are omitted. """ categories: str | Sequence[str | PoiCategory] exclude_categories: str | Sequence[str | PoiCategory] limit_to_countries: str | Sequence[str] lang: str result_type_filter: str | Sequence[str | SearchResultType] search_region: MapRegion user_lat: float user_lng: float search_region_priority: SearchRegionPriority | str enable_pagination: bool page_token: str include_address_categories: str | Sequence[str | AddressCategory] exclude_address_categories: str | Sequence[str | AddressCategory]
[docs] class SearchOptionsLatLng(_SearchOptionsBase, total=False): """Search options with required ``lat`` and ``lng`` location bias."""
[docs] lat: Required[float]
[docs] lng: Required[float]
[docs] class SearchOptions(_SearchOptionsBase, total=False): """All optional kwargs for :meth:`AppleMapsClient.search`."""
[docs] lat: float
[docs] lng: float
class _AutocompleteOptionsBase(TypedDict, total=False): """Shared optional kwargs for :meth:`AppleMapsClient.autocomplete`. Location params match search (see :class:`_SearchOptionsBase`): ``lat``/``lng`` → ``searchLocation`` bias; ``search_region`` as MapRegion; ``user_lat``/``user_lng`` is the user's position (ranking; may fall back as ``searchLocation`` when lat/lng are omitted). """ limit_to_countries: str | Sequence[str] lang: str result_type_filter: str | Sequence[str | SearchACResultType] include_poi_categories: str | Sequence[str | PoiCategory] exclude_poi_categories: str | Sequence[str | PoiCategory] search_region: MapRegion user_lat: float user_lng: float search_region_priority: SearchRegionPriority | str include_address_categories: str | Sequence[str | AddressCategory] exclude_address_categories: str | Sequence[str | AddressCategory]
[docs] class AutocompleteOptionsLatLng(_AutocompleteOptionsBase, total=False): """Autocomplete options with required ``lat`` and ``lng`` location bias."""
[docs] lat: Required[float]
[docs] lng: Required[float]
[docs] class AutocompleteOptions(_AutocompleteOptionsBase, total=False): """All optional kwargs for :meth:`AppleMapsClient.autocomplete`."""
[docs] lat: float
[docs] lng: float
[docs] class AppleMapsClient: """A client for the Apple Maps Server API. Handles JWT-based authentication with automatic token refresh, and provides methods for geocoding, reverse geocoding, search, and autocomplete. """ def __init__( self, *, team_id: str, key_id: str, private_key: str, origin: str | None = None ): """Initialize the client. :param team_id: Apple Developer Team ID (10-character string). :param key_id: Maps key identifier from Apple Developer account. :param private_key: ES256 private key, either PEM-encoded or raw base64 DER. :param origin: Optional domain restriction for MapKit JS tokens (e.g., "https://example.com"). """ if not team_id: raise ValueError("team_id must be provided.") if not key_id: raise ValueError("key_id must be provided.") if not private_key: raise ValueError("private_key must be provided.")
[docs] self.team_id = team_id
[docs] self.key_id = key_id
[docs] self.origin = origin
# accept both PEM-formatted keys and raw base64 DER (no PEM wrapping) stripped = private_key.strip() if not stripped.startswith("-----BEGIN"): stripped = ( f"-----BEGIN PRIVATE KEY-----\n{stripped}\n-----END PRIVATE KEY-----" )
[docs] self.private_key = stripped
[docs] self.base_url = "https://maps-api.apple.com"
self._access_token: str | None = None self._token_expires_at: float = 0.0 @classmethod
[docs] def from_env(cls) -> "AppleMapsClient": """Construct a client from APPLE_MAPS_* environment variables. Required: - APPLE_MAPS_TEAM_ID - APPLE_MAPS_KEY_ID - APPLE_MAPS_P8_KEY Optional: - APPLE_MAPS_ORIGIN """ team_id = os.environ.get("APPLE_MAPS_TEAM_ID", "") key_id = os.environ.get("APPLE_MAPS_KEY_ID", "") private_key = os.environ.get("APPLE_MAPS_P8_KEY", "") origin = os.environ.get("APPLE_MAPS_ORIGIN") or None missing = [ name for name, value in ( ("APPLE_MAPS_TEAM_ID", team_id), ("APPLE_MAPS_KEY_ID", key_id), ("APPLE_MAPS_P8_KEY", private_key), ) if not value ] if missing: raise ValueError( f"missing required environment variables: {', '.join(missing)}" ) return cls( team_id=team_id, key_id=key_id, private_key=private_key, origin=origin, )
def _create_jwt(self, *, ttl_seconds: int = _DEFAULT_JWT_TTL_SECONDS) -> str: """Build an ES256-signed JWT for MapKit JS or /v1/token exchange. Header: alg=ES256, kid, typ=JWT. Payload: iss, iat, exp, optional origin. Spec: https://developer.apple.com/documentation/applemapsserverapi/creating-and-using-tokens-with-maps-server-api Vendored mirror (offline): docs/apple_maps_documentation/developer.apple.com_documentation_applemapsserverapi_creating-and-using-tokens-with-maps-server-api.md This is the *auth JWT* — a credential proving we own the Maps private key. For Server API use it is exchanged for an *access token* (see _fetch_token). For MapKit JS it is returned directly via create_mapkit_token(). """ assert ttl_seconds > 0, "ttl_seconds must be positive" now = int(time.time()) payload: dict[str, object] = { "iss": self.team_id, "iat": now, "exp": now + ttl_seconds, } if self.origin: payload["origin"] = self.origin # ES256 + kid required by Apple Maps token spec (link in docstring above) return jwt.encode( payload, self.private_key, algorithm="ES256", headers={"kid": self.key_id}, ) @_retry_policy def _fetch_token(self, auth_jwt: str) -> TokenResponse: """Exchange the auth JWT for a short-lived Maps access token. Calls GET /v1/token with the auth JWT in the Authorization header. Apple returns an *access token* (Bearer) valid for ~30 minutes that authorises all subsequent Maps API requests (geocode, search, etc.). Spec: https://developer.apple.com/documentation/applemapsserverapi/-v1-token """ response = httpx2.get( f"{self.base_url}/v1/token", headers={"Authorization": f"Bearer {auth_jwt}"}, timeout=_TIMEOUT, ) response.raise_for_status() return TokenResponse.model_validate(response.json()) def _ensure_token(self) -> str: """Return a valid access token, fetching a new one if needed. Auth flow: 1. _create_jwt() → signs a fresh auth JWT with our ES256 private key 2. _fetch_token() → GETs /v1/token, gets back an access token 3. access token → cached in-process; reused until 60 s before expiry The access token is what gets sent as `Authorization: Bearer` on every Maps API call. The auth JWT is a one-shot credential and is discarded. Spec: https://developer.apple.com/documentation/applemapsserverapi/creating-and-using-tokens-with-maps-server-api https://developer.apple.com/documentation/applemapsserverapi/-v1-token """ if self._access_token and time.time() < self._token_expires_at - 60: return self._access_token auth_jwt = self._create_jwt() token_response = self._fetch_token(auth_jwt) self._access_token = token_response.accessToken self._token_expires_at = time.time() + token_response.expiresInSeconds return self._access_token
[docs] def create_token(self) -> str: """Return a valid Maps access token for Apple Maps Server API use. This is the *access token* (not the auth JWT) suitable for server-side API calls (geocode, search, etc.). It is NOT suitable for MapKit JS. Use `create_mapkit_token()` for browser-side MapKit JS initialization. Spec: https://developer.apple.com/documentation/applemapsserverapi/-v1-token """ return self._ensure_token()
[docs] def create_mapkit_token( self, *, ttl_seconds: int = _DEFAULT_JWT_TTL_SECONDS ) -> str: """Return a signed JWT for MapKit JS browser initialization. MapKit JS requires the raw signed JWT, not the Server API access token. Pass this to the `authorizationCallback` done() function. If `origin` was set on the client, the token is restricted to that domain. :param ttl_seconds: JWT lifetime in seconds (default: 1 hour). Apple does not document a maximum for Maps tokens. Spec: https://developer.apple.com/documentation/applemapsserverapi/creating-and-using-tokens-with-maps-server-api https://developer.apple.com/documentation/mapkitjs/creating-and-using-tokens-with-mapkit-js """ return self._create_jwt(ttl_seconds=ttl_seconds)
@_retry_policy def _make_request(self, path: str, params: dict[str, str]) -> dict[str, object]: """Make an authenticated GET request to the Apple Maps API. :param path: API endpoint path (e.g., '/v1/geocode'). :param params: Query parameters. :return: JSON response as a dictionary. """ token = self._ensure_token() url = f"{self.base_url}{path}" headers = {"Authorization": f"Bearer {token}"} response = httpx2.get(url, params=params, headers=headers, timeout=_TIMEOUT) response.raise_for_status() return response.json() @overload
[docs] def geocode( self, query: str, **kwargs: Unpack[GeocodeOptionsLatLng] ) -> PlaceResults: ...
@overload def geocode( self, query: str, **kwargs: Unpack[_GeocodeOptionsBase] ) -> PlaceResults: ... def geocode(self, query: str, **kwargs: Unpack[GeocodeOptions]) -> PlaceResults: """Convert an address string to coordinates. Maps to GET /v1/geocode. :param query: Address to geocode (e.g., "1 Apple Park Way"). :param limit_to_countries: ISO 3166-1 alpha-2 country codes to limit results (e.g. ``["US", "CA"]``). :param lang: BCP 47 language code (default: "en-US"). :param lat: Latitude for app-defined search bias (must pass with lng). Sent as Apple's ``searchLocation``. :param lng: Longitude for app-defined search bias (must pass with lat). :param search_region: App-defined bounding-box hint as :class:`MapRegion`. :param user_lat: Latitude of the user's position (must pass with user_lng). Used for ranking/relevance; if ``lat``/``lng`` are omitted, some endpoints may fall back to this as the search hint. :param user_lng: Longitude of the user's position (must pass with user_lat). """ if not query: raise ValueError("query must be provided.") params: dict[str, str] = dict( f.compact( { "q": query, "limitToCountries": _csv(kwargs.get("limit_to_countries")), "lang": kwargs.get("lang"), "searchLocation": _lat_lng_to_apple_str( lat=kwargs.get("lat"), lng=kwargs.get("lng"), ), "searchRegion": _search_region(kwargs.get("search_region")), "userLocation": _lat_lng_to_apple_str( lat=kwargs.get("user_lat"), lng=kwargs.get("user_lng"), ), } ) ) raw = self._make_request("/v1/geocode", params) return PlaceResults.model_validate(raw)
[docs] def reverse_geocode( self, *, lat: float, lng: float, lang: str | None = None, ) -> PlaceResults: """Convert coordinates to an address. Maps to GET /v1/reverseGeocode. :param lat: Latitude of the point to reverse geocode. :param lng: Longitude of the point to reverse geocode. :param lang: BCP 47 language code (default: "en-US"). """ params: dict[str, str] = dict(f.compact({"loc": f"{lat},{lng}", "lang": lang})) raw = self._make_request("/v1/reverseGeocode", params) return PlaceResults.model_validate(raw)
@overload
[docs] def search( self, query: str, **kwargs: Unpack[SearchOptionsLatLng] ) -> SearchResponse: ...
@overload def search( self, query: str = "", **kwargs: Unpack[_SearchOptionsBase] ) -> SearchResponse: ... def search( self, query: str = "", **kwargs: Unpack[SearchOptions] ) -> SearchResponse: """Search for places by name or category. Maps to GET /v1/search. Location bias (optional): pass ``lat=`` and ``lng=`` together. For page 2+, pass only ``page_token`` from a prior response's ``paginationInfo.nextPageToken``. Apple rejects other search params (including ``q`` and ``enablePagination``) on token requests. :param query: Search query (e.g., "coffee", "Apple Park"). Required unless ``page_token`` is set. :param lat: Latitude for app-defined search bias (must pass with lng). Sent as Apple's ``searchLocation`` — "search near this map point". :param lng: Longitude for app-defined search bias (must pass with lat). :param categories: POI categories to include (e.g. ``["MovieTheater", "Cafe"]``). :param exclude_categories: POI categories to exclude (e.g. ``["Parking"]``). :param limit_to_countries: ISO 3166-1 alpha-2 country codes (e.g. ``["US", "CA"]``). :param lang: BCP 47 language code (default: "en-US"). :param result_type_filter: Result types (e.g. ``["Poi", "Address"]``). :param search_region: App-defined bounding-box hint as :class:`MapRegion`. :param user_lat: Latitude of the user's position (must pass with user_lng). Used for ranking/relevance; Search may fall back to it as ``searchLocation`` when ``lat``/``lng`` are omitted. :param user_lng: Longitude of the user's position (must pass with user_lat). :param search_region_priority: Importance of ``search_region`` (:class:`SearchRegionPriority` or ``"default"`` / ``"required"``). :param enable_pagination: Request paginated results (first page only). :param page_token: Token from ``paginationInfo`` for a subsequent page. When set, sent alone — do not combine with query or other filters. :param include_address_categories: Address categories to include (e.g. ``["AdministrativeArea"]``). :param exclude_address_categories: Address categories to exclude. """ page_token = kwargs.get("page_token") if page_token: # Apple: pageToken requests must not include q, enablePagination, etc. raw = self._make_request("/v1/search", {"pageToken": page_token}) return SearchResponse.model_validate(raw) if not query: raise ValueError("query must be provided.") raw_params: dict[str, object] = { "q": query, "searchLocation": _lat_lng_to_apple_str( lat=kwargs.get("lat"), lng=kwargs.get("lng"), ), "includePoiCategories": _csv(kwargs.get("categories")), "excludePoiCategories": _csv(kwargs.get("exclude_categories")), "limitToCountries": _csv(kwargs.get("limit_to_countries")), "lang": kwargs.get("lang"), "resultTypeFilter": _csv(kwargs.get("result_type_filter")), "searchRegion": _search_region(kwargs.get("search_region")), "userLocation": _lat_lng_to_apple_str( lat=kwargs.get("user_lat"), lng=kwargs.get("user_lng"), ), "searchRegionPriority": kwargs.get("search_region_priority"), "includeAddressCategories": _csv(kwargs.get("include_address_categories")), "excludeAddressCategories": _csv(kwargs.get("exclude_address_categories")), } if kwargs.get("enable_pagination"): raw_params["enablePagination"] = "true" params: dict[str, str] = dict(f.compact(raw_params)) raw = self._make_request("/v1/search", params) return SearchResponse.model_validate(raw) @overload
[docs] def autocomplete( self, query: str, **kwargs: Unpack[AutocompleteOptionsLatLng] ) -> SearchAutocompleteResponse: ...
@overload def autocomplete( self, query: str, **kwargs: Unpack[_AutocompleteOptionsBase] ) -> SearchAutocompleteResponse: ... def autocomplete( self, query: str, **kwargs: Unpack[AutocompleteOptions] ) -> SearchAutocompleteResponse: """Autocomplete partial addresses and place names. Maps to GET /v1/searchAutocomplete. Location bias (optional): pass ``lat=`` and ``lng=`` together. Result count is fixed by Apple; the API has no limit/maxResults parameter. For more results, use search() (supports enable_pagination) or search_completion() to expand a single autocomplete hit. :param query: Partial address or place name to autocomplete. :param lat: Latitude for app-defined search bias (must pass with lng). Sent as Apple's ``searchLocation`` — "search near this map point". :param lng: Longitude for app-defined search bias (must pass with lat). :param limit_to_countries: ISO 3166-1 alpha-2 country codes (e.g. ``["US", "CA"]``). :param lang: BCP 47 language code (default: "en-US"). :param result_type_filter: Result types (e.g. ``["Address", "Poi"]``). :param include_poi_categories: POI categories to include (e.g. ``["Cafe"]``). :param exclude_poi_categories: POI categories to exclude. :param search_region: App-defined bounding-box hint as :class:`MapRegion`. :param user_lat: Latitude of the user's position (must pass with user_lng). Used for ranking/relevance; may fall back as ``searchLocation`` when ``lat``/``lng`` are omitted. :param user_lng: Longitude of the user's position (must pass with user_lat). :param search_region_priority: Importance of ``search_region`` (:class:`SearchRegionPriority` or ``"default"`` / ``"required"``). :param include_address_categories: Address categories to include. :param exclude_address_categories: Address categories to exclude. """ if not query: raise ValueError("query must be provided.") params: dict[str, str] = dict( f.compact( { "q": query, "searchLocation": _lat_lng_to_apple_str( lat=kwargs.get("lat"), lng=kwargs.get("lng"), ), # Note: Apple maps documentation says to use 'limitToCountries' param but it seems to work only with almost full address "limitToCountries": _csv(kwargs.get("limit_to_countries")), "lang": kwargs.get("lang"), "resultTypeFilter": _csv(kwargs.get("result_type_filter")), "includePoiCategories": _csv(kwargs.get("include_poi_categories")), "excludePoiCategories": _csv(kwargs.get("exclude_poi_categories")), "searchRegion": _search_region(kwargs.get("search_region")), "userLocation": _lat_lng_to_apple_str( lat=kwargs.get("user_lat"), lng=kwargs.get("user_lng"), ), "searchRegionPriority": kwargs.get("search_region_priority"), "includeAddressCategories": _csv( kwargs.get("include_address_categories") ), "excludeAddressCategories": _csv( kwargs.get("exclude_address_categories") ), } ) ) raw = self._make_request("/v1/searchAutocomplete", params) return SearchAutocompleteResponse.model_validate(raw)
[docs] def search_completion( self, completion: AutocompleteResult | str, *, lang: str | None = None, ) -> SearchResponse: """Resolve an autocomplete suggestion to full search results. Maps to GET /v1/search using the completionUrl from an AutocompleteResult. The completionUrl already encodes the query and opaque metadata Apple needs to return precise results for the suggestion. :param completion: An AutocompleteResult or its completionUrl string. :param lang: BCP 47 language code (e.g., "en-US"). Apple does not carry the language through the completionUrl, so callers must re-specify it here. :raises ValueError: If the completion has no completionUrl. """ if isinstance(completion, AutocompleteResult): url_str = completion.completionUrl if not url_str: raise ValueError("AutocompleteResult has no completionUrl.") else: url_str = completion parsed = urlparse(url_str) params: dict[str, str] = { k: v[0] for k, v in parse_qs(parsed.query, keep_blank_values=True).items() } if lang is not None: params["lang"] = lang raw = self._make_request(parsed.path, params) return SearchResponse.model_validate(raw)