From ec59025195a8661f08922167c5c68654cb86cb70 Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Tue, 4 Aug 2026 16:42:31 -0700 Subject: [PATCH 01/18] docs: design for Apple Maps Server API SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple becomes a selectable primary geo provider with Google as fallback, motivated by Apple's 25k/day free quota and by directions/ETA being net-new capability here. Records two findings that shape the design. Apple exposes no photo, opening hours, rating, or price level at any tier, so those POI.Place fields stay Google-only. And Apple's PoiCategory enum collapses all retail to Store, which is the exact distinction offerbee's bestCard keys reward selection off — so category specificity is carried in the free-text q parameter and results are tagged with the requested type rather than the returned poiCategory. Documents credential sourcing: only Team ID and Key ID reach the JWT, the Maps ID is unused by the Server API, and the private key value accepts raw PEM or base64 so .env loaders that flatten newlines cannot break the token exchange. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KJuZpNMJnG4RGqKnZzxF5k --- .../specs/2026-08-04-apple-maps-sdk-design.md | 415 ++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md diff --git a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md new file mode 100644 index 00000000..020b199f --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md @@ -0,0 +1,415 @@ +# Apple Maps Server API SDK — Design + +Date: 2026-08-04 +Status: approved for planning + +## Goal + +Add an Apple Maps Server API client to this service so Apple can serve as the +primary geo provider with Google Maps as the fallback. Which provider is primary +is a config switch; see "Implementation phasing" for why the first deploy leaves +Google primary. Three motivations, in order: + +1. **Cut Google Maps spend.** Apple allows 25,000 service calls per day per + Apple Developer team at no cost. +2. **Add directions and ETA.** The service has no travel-time provider today. + Apple `/v1/directions` and `/v1/etas` are net-new capability. +3. **Keep Google as fallback** rather than removing it, so any Apple failure, + quota exhaustion, or data gap degrades instead of breaking. + +The consuming product is `offerbee`, a separate TypeScript monorepo whose Convex +backend calls this service over HTTP (`packages/backend/convex/geoService.ts`) +at `POST /v1/nearby-places` and `POST /v1/nearby-places-by-category`. For those +use cases the load-bearing fields are a place's **coordinate, name, address, and +category** — not rating, price level, opening hours, or photos. + +## What Apple actually returns + +Verified against Apple's published schema, not assumed. + +`Place`: + +| Field | Type | +|---|---| +| `id` | string (opaque) | +| `name` | string | +| `coordinate` | `Location` (latitude, longitude) | +| `formattedAddressLines` | `[string]` | +| `structuredAddress` | `StructuredAddress` | +| `country` / `countryCode` | string | +| `displayMapRegion` | `MapRegion` | +| `alternateIds` | `[string]` | + +`SearchResponse.Place` extends `Place` with exactly one field: `poiCategory`. + +`StructuredAddress`: `administrativeArea`, `administrativeAreaCode`, +`areasOfInterest`, `dependentLocalities`, `fullThoroughfare`, `locality`, +`postCode`, `subLocality`, `subThoroughfare`, `thoroughfare`. + +### Fields Apple does not provide, at any tier + +No photo, image, or thumbnail. No opening hours. No rating or review count. No +price level. No business status. Apple's complete object list is `TokenResponse`, +`AutocompleteResult`, `DirectionsResponse`, `EtaResponse`, `Location`, +`MapRegion`, `Place`, `PlaceResults`, `PlacesResponse`, +`SearchAutocompleteResponse`, `SearchMapRegion`, `SearchResponse`, +`StructuredAddress`, plus the scalar types `CountryCode`, `DirectionsAvoid`, +`Lang`, `PoiCategory`, `SearchLocation`, `SearchRegion`, `UserLocation`, and +`ErrorResponse`. There is no image object to request. Photos on Apple Maps place +cards come from licensed partners and are not exposed through the API. + +Consequence: `POI.Place.Photo`, `.Rating`, `.UserRatingsTotal`, `.PriceLevel`, +and `.Hours` cannot be populated from Apple. The planner paths that consume them +(`matching/score.go:30`, `matching/matcher.go:116`, `matching/matcher.go:131`) +degrade for Apple-sourced places. This is accepted: those fields are not needed +for the offerbee use cases driving this work. + +## Endpoints + +All ten, at `https://maps-api.apple.com`: + +| Endpoint | Required params | +|---|---| +| `GET /v1/token` | — (auth JWT in `Authorization`) | +| `GET /v1/geocode` | `q` | +| `GET /v1/reverseGeocode` | `loc` | +| `GET /v1/search` | `q` | +| `GET /v1/searchAutocomplete` | `q` | +| `GET /v1/place/:id` | path id | +| `GET /v1/place` | `ids` | +| `GET /v1/place/alternateIds` | `ids` | +| `GET /v1/directions` | `origin`, `destination` | +| `GET /v1/etas` | `origin`, `destinations` | + +`/v1/search` optional params: `excludePoiCategories`, `includePoiCategories`, +`limitToCountries`, `resultTypeFilter`, `lang`, `searchLocation`, `searchRegion`, +`userLocation`, `searchRegionPriority`, `enablePagination`, `pageToken`, +`includeAddressCategories`, `excludeAddressCategories`. + +**There is no radius parameter and no result limit.** `q` is required. +`searchLocation` and `searchRegion` are documented as *hints*, not constraints. +Geographic filtering is therefore the SDK caller's responsibility. + +## Package layout + +New top-level package with zero imports from `iowrappers` or `POI`, so it stays +extractable into its own module later without a rewrite: + +``` +applemaps/ + auth.go TokenSource: ES256 JWT signing, /v1/token exchange, TTL cache + client.go Client, Options, doJSON, backoff, 401-retry, typed 429 + types.go all request/response structs + poicategory.go the 75 PoiCategory constants + geocode.go Geocode, ReverseGeocode + search.go Search, SearchAll (pagination), SearchAutocomplete + place.go Place, Places, AlternateIDs + directions.go Directions, ETAs +``` + +Only dependency is `github.com/golang-jwt/jwt/v5`, already in `go.mod`. No new +module requirement. + +## Authentication + +Two hops. Sign an auth JWT locally with the `.p8` key, then exchange it for a +short-lived access token used on every other call. + +```go +type TokenSource struct { + teamID, keyID string // JWT iss and kid respectively + key *ecdsa.PrivateKey + httpClient *http.Client + + mu sync.Mutex + token string + expiry time.Time +} +``` + +Auth JWT header `{alg: ES256, kid: , typ: JWT}`, claims +`{iss: , iat: now, exp: now + 20m}`. `GET /v1/token` with +`Authorization: Bearer ` returns `{accessToken, expiresInSeconds}`, +where `expiresInSeconds` is 1800. + +Rules: + +- Cache the access token; refresh when fewer than 5 minutes remain. +- Hold `mu` across the refresh so a concurrent burst produces one exchange, not + N. `/v1/token` itself counts against the daily quota. +- On a `401` from any endpoint, invalidate the cached token and retry the request + exactly once. A second `401` is returned to the caller. + +## Credentials + +Read through the existing `envconfig` struct in `main.go:23`: + +```go +AppleMapsTeamID string `envconfig:"APPLE_MAPS_TEAM_ID"` +AppleMapsKeyID string `envconfig:"APPLE_MAPS_KEY_ID"` +AppleMapsPrivateKey string `envconfig:"APPLE_MAPS_PRIVATE_KEY"` // PEM contents +AppleMapsKeyFile string `envconfig:"APPLE_MAPS_PRIVATE_KEY_FILE"` // local dev only +``` + +PEM **contents** is the primary path because deployment goes through +`Procfile` / `heroku.yml`, where there is no filesystem to mount a `.p8` into. +The file path variant exists for local development only. If both are set, +contents wins. `*.p8` is added to `.gitignore`; the key never enters the repo. + +### Where each value comes from + +| Var | Value | Source | +|---|---|---| +| `APPLE_MAPS_PRIVATE_KEY` | full contents of the `AuthKey_.p8` file, `BEGIN`/`END` lines included | the key downloaded from the Apple Developer portal | +| `APPLE_MAPS_KEY_ID` | 10-character key ID | embedded in the `.p8` filename; also listed under developer.apple.com/account → Keys | +| `APPLE_MAPS_TEAM_ID` | 10-character team ID | developer.apple.com/account → Membership Details | + +The Maps ID (the `maps.*` identifier registered under Identifiers) is **not** +used by the Server API. It scopes the key at creation time and is required for +MapKit JS, but nothing in the token exchange or any endpoint references it. Only +Team ID (`iss`) and Key ID (`kid`) appear in the JWT. + +The key is a PKCS#8 EC private key on the P-256 curve (`prime256v1`), which is +what `ES256` requires. `jwt/v5` parses it directly — it falls back to +`x509.ParsePKCS8PrivateKey` internally, so no manual PEM handling is needed: + +```go +key, err := jwt.ParseECPrivateKeyFromPEM([]byte(pemBytes)) +``` + +### Value encoding + +`APPLE_MAPS_PRIVATE_KEY` accepts **either** raw PEM with real newlines **or** a +base64 encoding of the same bytes. Select on whether the trimmed value begins +with `-----BEGIN`; base64-decode otherwise. + +Raw newlines survive `heroku config:set` and Docker `--env-file`, but most `.env` +loaders flatten or truncate them, which is the common way this fails. Accepting +both forms removes that failure mode for the cost of one prefix check. + +### Local development + +The key lives at `~/.config/applemaps/AuthKey_.p8`, mode `600` inside a +`700` directory, and is referenced by absolute path through +`APPLE_MAPS_PRIVATE_KEY_FILE`. Deliberately outside any git working tree: a +gitignored secret that sits inside a repo is one `git add -f` or one edited +ignore rule away from being committed. + +Apple permits exactly one download of this key and offers no way to retrieve it +again. If it is lost, or leaked, the only remedy is revoking it in the Apple +Developer portal and issuing a new one. It should be backed up in a password +manager, and it must never be committed, logged, or included in a test fixture — +signing tests generate their own throwaway EC key. + +## Adapter onto the existing seam + +`iowrappers/apple_maps_client.go` defines `AppleMapsClient`, implementing the +existing `SearchClient` interface (`iowrappers/maps_client.go:19`) so no +downstream caller changes shape: + +| `SearchClient` method | Apple call | +|---|---| +| `Geocode` | `/v1/geocode?q=, , ` — coordinate out; `structuredAddress.locality` / `.administrativeArea` / `country` written back into `GeocodeQuery` | +| `ReverseGeocode` | `/v1/reverseGeocode?loc=,` — same fields, reverse direction | +| `NearbySearch` | `/v1/search` per mapped term, then client-side filter | + +`NearbySearch` details: + +1. Map `req.PlaceCat` / `req.Keyword` to a query term and category set (below). +2. Issue `/v1/search` with `q=`, `searchLocation=,`, + `searchRegion=`, `includePoiCategories=`, + `enablePagination=true`. The bbox is the coordinate offset by `req.Radius` in + each cardinal direction: `±radius/111320` degrees latitude, and + `±radius/(111320·cos(lat))` degrees longitude. +3. Page until a page yields zero results inside the radius, or 5 pages have been + fetched. The cap bounds quota consumption per search; when it is hit, log the + truncation rather than returning silently short. +4. **Haversine-filter every result to `req.Radius`.** Apple's location params are + hints, so unfiltered results can be arbitrarily far away. +5. Tag each place's `LocationType` with the **requested** type. + `POI/places.go:80` already documents `LocationType` as "the single type a + search tagged the place with (often the SEARCHED type, not the actual one)", + so this matches existing semantics rather than fighting them. +6. Fill unavailable fields explicitly, never by accidental zero value: + `Status = POI.Operational`, `Hours = POI.DefaultOpeningHours` for all seven + days, `Rating` / `PriceLevel` / `UserRatingsTotal` / `Photo` left zero. + +Because Apple reports no business status, the closure-persistence behaviour added +in commit `03f799c` stays Google-only. An Apple-sourced record can never be +retired by the `Operational` read filters, and is refreshed only by +`PlaceDetailsRefreshDuration` ageing. + +## Category mapping + +Apple's `PoiCategory` is a fixed 75-value enum whose entire retail branch is +`Store`, plus `FoodMarket`, `Pharmacy`, `GasStation`, `Bakery`, `Cafe`, +`Restaurant`. It has no `supermarket`, `department_store`, `clothing_store`, +`electronics_store`, or `convenience_store`. + +This matters because `geoService.ts:23` records that offerbee's `bestCard.ts` +"keys card-reward selection off the specific Google place type (supermarket ≠ +mall)" — exactly the distinction Apple collapses. + +Mitigation: **do not read `poiCategory` off the response to determine type.** +Carry specificity in the free-text `q`, use `includePoiCategories` only as a +coarse narrowing filter, and tag results with the requested type. + +Table lives in the adapter, not in `applemaps/`, since it is Google-taxonomy +specific: + +```go +type appleQuery struct { + term string + cats []applemaps.PoiCategory +} + +var appleQueryByLocationType = map[POI.LocationType]appleQuery{ /* ... */ } +``` + +| `POI.LocationType` | `q` | `includePoiCategories` | Known loss | +|---|---|---|---| +| `cafe` | cafe | `Cafe` | — | +| `restaurant` | restaurant | `Restaurant` | — | +| `bar` | bar | `Nightlife`, `Brewery` | Apple has no Bar; `Nightlife` also covers clubs | +| `bakery` | bakery | `Bakery` | — | +| `meal_takeaway` | takeout | `Restaurant` | Apple models no takeaway concept | +| `meal_delivery` | delivery | `Restaurant` | Apple models no delivery concept | +| `night_club` | night club | `Nightlife` | indistinguishable from `bar` | +| `museum` | museum | `Museum` | — | +| `art_gallery` | art gallery | `Museum` | no gallery category | +| `amusement_park` | amusement park | `AmusementPark` | — | +| `park` | park | `Park`, `NationalPark` | — | +| `tourist_attraction` | tourist attraction | `Landmark`, `NationalMonument` | coarse | +| `zoo` | zoo | `Zoo` | — | +| `aquarium` | aquarium | `Aquarium` | — | +| `movie_theater` | movie theater | `MovieTheater` | — | +| `stadium` | stadium | `Stadium` | — | +| `bowling_alley` | bowling alley | `Bowling` | — | +| `shopping_mall` | shopping mall | `Store` | `Store` covers all retail | +| `department_store` | department store | `Store` | as above | +| `supermarket` | supermarket | `FoodMarket` | `FoodMarket` also matches specialty grocers | +| `grocery_or_supermarket` | grocery store | `FoodMarket` | as above | +| `convenience_store` | convenience store | `Store`, `FoodMarket` | as above | +| `clothing_store` | clothing store | `Store` | as above | +| `store` | store | `Store` | — | +| `hardware_store` | hardware store | `Store` | as above | +| `home_goods_store` | home goods store | `Store` | as above | +| `electronics_store` | electronics store | `Store` | as above | +| `furniture_store` | furniture store | `Store` | as above | +| `book_store` | book store | `Store` | as above | +| `shoe_store` | shoe store | `Store` | as above | +| `jewelry_store` | jewelry store | `Store` | as above | +| `pet_store` | pet store | `Store` | `AnimalService` is veterinary, not retail | +| `bicycle_store` | bike shop | `Store` | as above | +| `florist` | florist | `Store` | as above | +| `liquor_store` | liquor store | `Store` | `Brewery`/`Winery`/`Distillery` are producers | +| `gas_station` | gas station | `GasStation` | — | +| `lodging` | hotel | `Hotel`, `RVPark`, `Campground` | — | +| `gym` | gym | `FitnessCenter` | — | +| `spa` | spa | `Spa` | — | +| `pharmacy` | pharmacy | `Pharmacy` | — | +| `drugstore` | drugstore | `Pharmacy`, `Store` | — | +| `beauty_salon` | beauty salon | `Beauty` | indistinguishable from `hair_care` | +| `hair_care` | hair salon | `Beauty` | as above | + +Eighteen of the forty-four types collapse to `Store`. Brand keyword searches +(`req.Keyword` set) are unaffected — the brand name is the discriminator there, +and `geoService.ts:21` confirms the brand endpoint already leaves `locationType` +undefined. + +## Cache identity + +Apple place IDs are stored prefixed as `apple:`; Google `place_id` values +stay bare. Rationale: + +- Existing cached Google records and offerbee's `placeId` field keep working with + no migration. +- Provenance is readable straight off the key, so an Apple-only data problem is + diagnosable and reversible. +- Both providers share one geo index and one freshness marker, so a cell warmed + by either provider satisfies a later request from either. A per-provider + keyspace would double cold-start cost and require duplicating the marker logic + in `iowrappers/poi_searcher.go:181`. + +Reconciling Apple and Google records for the same physical place is explicitly +out of scope: there is no join key, only name-plus-coordinate fuzzy matching. + +## Provider routing and fallback + +```go +type FallbackSearchClient struct { + primary, secondary SearchClient +} +``` + +Tries `primary`, falls back to `secondary` on error, on a typed quota error, or +on an empty result set. Every fallback logs the triggering reason so the real +Apple hit rate is measurable rather than assumed. Which client is primary is +config-selectable, so reverting to Google is a config change, not a deploy of new +code. + +## Quota guard + +The 25,000 daily calls are per team and **shared with MapKit JS**, and once +exhausted Apple returns `429` on every endpoint including `/v1/token`. Waiting +for that cliff would make the whole service fail over at an unpredictable moment. + +Instead: a Redis counter keyed `applemaps:quota:`, incremented per +outbound Apple call, expiring after 48 hours. Above a configurable threshold +(default 90%), route to Google pre-emptively. The threshold is ours to tune; the +cliff is not. + +## Testing + +`applemaps` package, using `httptest.Server` throughout — no live Apple calls in +tests: + +- token exchange happy path; refresh when near expiry; no refresh when fresh +- concurrent callers produce exactly one token exchange +- `401` triggers one invalidate-and-retry, and a second `401` surfaces +- `429` decodes to a typed quota error distinguishable from other failures +- query parameter encoding for every endpoint, including comma-joined lists +- response decoding for each object type, including absent optional fields +- pagination follows `pageToken` and terminates + +JWT signing is tested against an EC key generated in the test. The real `.p8` +never appears in a test fixture. + +Adapter tests in `iowrappers`, following the existing `miniredis` / +`redis_client_mocks` setup: + +- category map is table-driven and total over every `POI.LocationType` +- radius filter drops results outside `req.Radius` +- Apple IDs are stored with the `apple:` prefix; Google IDs are not +- `GeocodeQuery` round-trips through both geocode directions +- `FallbackSearchClient` falls back on error, on quota error, and on empty; does + not fall back on success + +## Open items + +1. **Apple Maps terms of service.** Apple's terms carry restrictions on caching + results and on combining Apple Maps data with other map providers. This + architecture does both: a persistent Redis place cache holding Google and + Apple records side by side. This needs a legal read before shipping to + production. It does not block building or testing the SDK. +2. **MapKit JS quota sharing.** If the offerbee native or web app uses MapKit JS, + it draws from the same 25,000 daily calls. Confirm before setting the quota + threshold. +3. **Recall parity is not achievable.** Apple has no radius search. Filtering + hint-based results client-side will not reproduce Google's nearby-search + recall. This does not change the design, but it does gate the rollout: ship + with Google primary, measure the recall delta on real queries via the + fallback logging, then flip the config once the delta is known and accepted. + The flip is a config change, so this costs no extra implementation work. + +## Implementation phasing + +Two phases, so the SDK is reviewable independently of the routing changes: + +1. **`applemaps/` package.** All ten endpoints, token lifecycle, typed errors, + full `httptest` suite. No changes outside the new directory except `go.mod` + and `.gitignore`. Independently verifiable against the real API with a + throwaway script. +2. **Integration.** `iowrappers/apple_maps_client.go` adapter, category map, + `FallbackSearchClient`, Redis quota counter, `main.go` config wiring, adapter + tests. Ships with Google primary per open item 3. From 3f543925d42fd968ac4e098fe0ca98cf7b241f13 Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Thu, 6 Aug 2026 23:27:40 -0700 Subject: [PATCH 02/18] feat(applemaps): types, typed errors, and token lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 0-2 of the Phase 1 plan. Guards the .p8 first: *.p8 is ignored before any Apple code lands, so there is no window in which a key could be committed. types.go covers all 22 documented objects. Fields are pointers only where a zero value is both meaningful and indistinguishable from absent — Eta.DistanceMeters (0 metres is a real distance) and Route.HasTolls (Apple documents true/false/undefined as three states). StepPaths follows Apple's prose description as a slice of polylines; its machine-readable schema contradicts that by annotating a flat Location array, and step 8 settles which is right against the live API. AllPoiCategories has 77 entries, not the 75 the design doc claimed. Apple's own reference undercounts: a formatting bug swallows Bakery into the Aquarium line, so counting the list's terms misses it. TokenSource holds its mutex across the /v1/token exchange rather than only around the cache read. /v1/token draws on the same 25k/day quota as every other endpoint, so a 50-goroutine cold start would otherwise spend 50 calls learning one token; a test asserts it spends exactly one. QuotaError is deliberately not retryable — the quota resets daily, so retrying inside a request cannot succeed and only burns more of an already-exhausted budget. ParsePrivateKey accepts raw PEM or base64-encoded PEM. Raw newlines survive heroku config:set and Docker --env-file but are flattened by many .env loaders, which would turn a correct key into an unparseable one at deploy time. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KJuZpNMJnG4RGqKnZzxF5k --- .gitignore | 5 + applemaps/auth.go | 231 ++++++++ applemaps/auth_test.go | 515 ++++++++++++++++++ applemaps/doc.go | 20 + applemaps/errors.go | 102 ++++ applemaps/poicategory.go | 132 +++++ applemaps/types.go | 278 ++++++++++ applemaps/types_test.go | 268 +++++++++ .../specs/2026-08-04-apple-maps-sdk-design.md | 4 +- .../specs/2026-08-04-apple-maps-sdk-plan.md | 154 ++++++ 10 files changed, 1707 insertions(+), 2 deletions(-) create mode 100644 applemaps/auth.go create mode 100644 applemaps/auth_test.go create mode 100644 applemaps/doc.go create mode 100644 applemaps/errors.go create mode 100644 applemaps/poicategory.go create mode 100644 applemaps/types.go create mode 100644 applemaps/types_test.go create mode 100644 docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md diff --git a/.gitignore b/.gitignore index bbabcf2a..cfbe234c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ .env .docker_build/ + +# Apple Maps private keys. Apple allows exactly one download of a .p8 and +# offers no way to retrieve it again, so a leak means revoking the key. +*.p8 + vendor bin/ .idea diff --git a/applemaps/auth.go b/applemaps/auth.go new file mode 100644 index 00000000..16a4cea5 --- /dev/null +++ b/applemaps/auth.go @@ -0,0 +1,231 @@ +package applemaps + +import ( + "context" + "crypto/ecdsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + // DefaultBaseURL is the Apple Maps Server API root. + DefaultBaseURL = "https://maps-api.apple.com" + + tokenPath = "/v1/token" + + // authJWTTTL is how long the locally signed auth JWT claims to be valid. It + // is only ever presented once, immediately, to /v1/token, so it needs no + // generous window. Apple rejects an auth JWT whose exp is more than 7 days + // out; 20 minutes stays far inside that and limits the value of a captured + // token. + authJWTTTL = 20 * time.Minute + + // tokenRefreshMargin is how long before expiry a cached access token is + // treated as stale. Apple issues 30-minute tokens, so 5 minutes leaves room + // for a slow request to complete on a token that was valid when it started. + tokenRefreshMargin = 5 * time.Minute +) + +// ParsePrivateKey parses the ECDSA private key from an Apple Maps .p8 file. +// +// It accepts either raw PEM ("-----BEGIN PRIVATE KEY-----...") or a base64 +// encoding of that PEM. Both forms are supported because raw newlines survive +// heroku config:set and Docker --env-file but are flattened by many .env +// loaders, which would otherwise turn a correct key into an unparseable one at +// deploy time. +func ParsePrivateKey(value string) (*ecdsa.PrivateKey, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, errors.New("applemaps: private key is empty") + } + + if !strings.HasPrefix(trimmed, "-----BEGIN") { + // Whitespace is stripped before decoding so a base64 blob wrapped across + // lines by a config system still parses. + compact := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == ' ' || r == '\t' { + return -1 + } + return r + }, trimmed) + + decoded, err := base64.StdEncoding.DecodeString(compact) + if err != nil { + return nil, fmt.Errorf("applemaps: private key is neither PEM nor base64-encoded PEM: %w", err) + } + trimmed = string(decoded) + } + + // jwt/v5 tries x509.ParseECPrivateKey and falls back to + // x509.ParsePKCS8PrivateKey, which is the encoding Apple's .p8 uses. + key, err := jwt.ParseECPrivateKeyFromPEM([]byte(trimmed)) + if err != nil { + return nil, fmt.Errorf("applemaps: parse private key: %w", err) + } + return key, nil +} + +// TokenSourceConfig configures a TokenSource. +type TokenSourceConfig struct { + // TeamID is the Apple Developer team ID, used as the JWT iss claim. + TeamID string + // KeyID is the MapKit key ID, used as the JWT kid header. + KeyID string + // PrivateKey is the key from the .p8 file, as returned by ParsePrivateKey. + PrivateKey *ecdsa.PrivateKey + // BaseURL defaults to DefaultBaseURL. Tests point it at an httptest server. + BaseURL string + // HTTPClient defaults to a client with a 10 second timeout. + HTTPClient *http.Client +} + +// TokenSource issues and caches Apple Maps access tokens. +// +// Apple's auth is a two-hop exchange: a JWT signed locally with the .p8 key is +// presented to /v1/token, which returns a short-lived access token used on every +// other endpoint. TokenSource owns that second token's lifetime. +// +// A TokenSource is safe for concurrent use. +type TokenSource struct { + teamID string + keyID string + key *ecdsa.PrivateKey + baseURL string + httpClient *http.Client + + // now is injectable so expiry behaviour is testable without sleeping. + now func() time.Time + + mu sync.Mutex + token string + expiry time.Time +} + +// NewTokenSource validates the credentials and returns a TokenSource. It makes +// no network call; the first exchange happens on the first Token call. +func NewTokenSource(cfg TokenSourceConfig) (*TokenSource, error) { + if strings.TrimSpace(cfg.TeamID) == "" { + return nil, errors.New("applemaps: TeamID is required") + } + if strings.TrimSpace(cfg.KeyID) == "" { + return nil, errors.New("applemaps: KeyID is required") + } + if cfg.PrivateKey == nil { + return nil, errors.New("applemaps: PrivateKey is required") + } + + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = DefaultBaseURL + } + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 10 * time.Second} + } + + return &TokenSource{ + teamID: cfg.TeamID, + keyID: cfg.KeyID, + key: cfg.PrivateKey, + baseURL: strings.TrimSuffix(baseURL, "/"), + httpClient: httpClient, + now: time.Now, + }, nil +} + +// Token returns a valid access token, exchanging or refreshing as needed. +// +// The lock is held across the exchange rather than only around the cache read. +// That serialises a cold burst into one HTTP call instead of one per caller, +// which matters because /v1/token consumes the same daily quota as every other +// endpoint — a 50-goroutine cold start would otherwise spend 50 calls to learn +// the same token. +func (ts *TokenSource) Token(ctx context.Context) (string, error) { + ts.mu.Lock() + defer ts.mu.Unlock() + + if ts.token != "" && ts.now().Before(ts.expiry.Add(-tokenRefreshMargin)) { + return ts.token, nil + } + return ts.exchangeLocked(ctx) +} + +// Invalidate discards the cached token so the next Token call re-exchanges. The +// client calls this after a 401, which is how a token revoked before its stated +// expiry is recovered from. +func (ts *TokenSource) Invalidate() { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.token = "" + ts.expiry = time.Time{} +} + +// authJWT builds and signs the short-lived JWT that /v1/token accepts. +func (ts *TokenSource) authJWT() (string, error) { + now := ts.now() + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "iss": ts.teamID, + "iat": now.Unix(), + "exp": now.Add(authJWTTTL).Unix(), + }) + // Apple identifies which of a team's keys signed the JWT by the kid header; + // without it the token is rejected as invalid. + token.Header["kid"] = ts.keyID + + signed, err := token.SignedString(ts.key) + if err != nil { + return "", fmt.Errorf("applemaps: sign auth JWT: %w", err) + } + return signed, nil +} + +// exchangeLocked performs the /v1/token call. Callers must hold ts.mu. +func (ts *TokenSource) exchangeLocked(ctx context.Context) (string, error) { + authToken, err := ts.authJWT() + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.baseURL+tokenPath, nil) + if err != nil { + return "", fmt.Errorf("applemaps: build token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+authToken) + req.Header.Set("Accept", "application/json") + + resp, err := ts.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("applemaps: token request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) + if err != nil { + return "", fmt.Errorf("applemaps: read token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", newAPIError(resp.StatusCode, body) + } + + var parsed TokenResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return "", fmt.Errorf("applemaps: decode token response: %w", err) + } + if parsed.AccessToken == "" { + return "", errors.New("applemaps: token response contained no access token") + } + + ts.token = parsed.AccessToken + ts.expiry = ts.now().Add(time.Duration(parsed.ExpiresInSeconds) * time.Second) + return ts.token, nil +} diff --git a/applemaps/auth_test.go b/applemaps/auth_test.go new file mode 100644 index 00000000..177be6be --- /dev/null +++ b/applemaps/auth_test.go @@ -0,0 +1,515 @@ +package applemaps + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// Every test in this file generates its own P-256 key. The real .p8 must never +// appear in a fixture: Apple allows exactly one download of it and there is no +// way to reissue the same key. +func testKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + return key +} + +func testKeyPEM(t *testing.T, key *ecdsa.PrivateKey) string { + t.Helper() + // PKCS#8 is the encoding Apple's .p8 files use. + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) +} + +// tokenServer returns a server that answers /v1/token with the given token and +// TTL, plus a counter of how many exchanges it served. +func tokenServer(t *testing.T, accessToken string, expiresIn int) (*httptest.Server, *atomic.Int64) { + t.Helper() + var calls atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != tokenPath { + t.Errorf("unexpected path %q", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"accessToken":%q,"expiresInSeconds":%d}`, accessToken, expiresIn) + })) + t.Cleanup(srv.Close) + return srv, &calls +} + +func newTestTokenSource(t *testing.T, baseURL string, key *ecdsa.PrivateKey) *TokenSource { + t.Helper() + ts, err := NewTokenSource(TokenSourceConfig{ + TeamID: "TEAM123456", + KeyID: "KEY7890123", + PrivateKey: key, + BaseURL: baseURL, + }) + if err != nil { + t.Fatalf("NewTokenSource: %v", err) + } + return ts +} + +func TestAuthJWTStructure(t *testing.T) { + key := testKey(t) + var captured string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = r.Header.Get("Authorization") + fmt.Fprint(w, `{"accessToken":"at","expiresInSeconds":1800}`) + })) + defer srv.Close() + + ts := newTestTokenSource(t, srv.URL, key) + if _, err := ts.Token(context.Background()); err != nil { + t.Fatalf("Token: %v", err) + } + + const prefix = "Bearer " + if len(captured) <= len(prefix) || captured[:len(prefix)] != prefix { + t.Fatalf("Authorization header: got %q, want %q prefix", captured, prefix) + } + raw := captured[len(prefix):] + + // The signature must verify against the public half of the signing key, + // which is what proves we signed with ES256 over the right bytes. + parsed, err := jwt.Parse(raw, func(*jwt.Token) (any, error) { return &key.PublicKey, nil }) + if err != nil { + t.Fatalf("parse auth JWT: %v", err) + } + if !parsed.Valid { + t.Fatal("auth JWT did not verify") + } + + if got := parsed.Method.Alg(); got != "ES256" { + t.Errorf("alg: got %q, want ES256", got) + } + if got := parsed.Header["kid"]; got != "KEY7890123" { + t.Errorf("kid header: got %v, want KEY7890123", got) + } + if got := parsed.Header["typ"]; got != "JWT" { + t.Errorf("typ header: got %v, want JWT", got) + } + + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + t.Fatalf("claims: got %T", parsed.Claims) + } + if got := claims["iss"]; got != "TEAM123456" { + t.Errorf("iss claim: got %v, want TEAM123456", got) + } + iat, ok := claims["iat"].(float64) + if !ok { + t.Fatal("iat claim missing") + } + exp, ok := claims["exp"].(float64) + if !ok { + t.Fatal("exp claim missing") + } + if wantTTL := authJWTTTL.Seconds(); exp-iat != wantTTL { + t.Errorf("exp-iat: got %v seconds, want %v", exp-iat, wantTTL) + } +} + +func TestTokenExchangeStoresTokenAndExpiry(t *testing.T) { + srv, calls := tokenServer(t, "access-token-1", 1800) + ts := newTestTokenSource(t, srv.URL, testKey(t)) + + start := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC) + ts.now = func() time.Time { return start } + + got, err := ts.Token(context.Background()) + if err != nil { + t.Fatalf("Token: %v", err) + } + if got != "access-token-1" { + t.Errorf("token: got %q, want access-token-1", got) + } + if calls.Load() != 1 { + t.Errorf("exchanges: got %d, want 1", calls.Load()) + } + if want := start.Add(1800 * time.Second); !ts.expiry.Equal(want) { + t.Errorf("expiry: got %v, want %v", ts.expiry, want) + } +} + +func TestTokenIsCachedInsideValidityWindow(t *testing.T) { + srv, calls := tokenServer(t, "cached", 1800) + ts := newTestTokenSource(t, srv.URL, testKey(t)) + + start := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC) + current := start + ts.now = func() time.Time { return current } + + for range 5 { + if _, err := ts.Token(context.Background()); err != nil { + t.Fatalf("Token: %v", err) + } + // Advance well inside the window: 1800s TTL less a 300s margin leaves + // 25 minutes of reuse. + current = current.Add(2 * time.Minute) + } + + if calls.Load() != 1 { + t.Errorf("exchanges: got %d, want 1 — token should have been reused", calls.Load()) + } +} + +func TestTokenRefreshesInsideMargin(t *testing.T) { + srv, calls := tokenServer(t, "refreshed", 1800) + ts := newTestTokenSource(t, srv.URL, testKey(t)) + + start := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC) + current := start + ts.now = func() time.Time { return current } + + if _, err := ts.Token(context.Background()); err != nil { + t.Fatalf("first Token: %v", err) + } + + // Move to 4 minutes before expiry: inside the 5 minute margin, so the token + // counts as stale even though Apple would still accept it. + current = start.Add(1800*time.Second - 4*time.Minute) + if _, err := ts.Token(context.Background()); err != nil { + t.Fatalf("second Token: %v", err) + } + + if calls.Load() != 2 { + t.Errorf("exchanges: got %d, want 2", calls.Load()) + } +} + +// A cold TokenSource hit by many goroutines must spend one quota call, not one +// per goroutine. +func TestConcurrentTokenCallsExchangeOnce(t *testing.T) { + var calls atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + // Hold the response briefly so callers genuinely overlap; without this + // the first exchange could complete before the others even start, and + // the test would pass without exercising the lock. + time.Sleep(20 * time.Millisecond) + fmt.Fprint(w, `{"accessToken":"shared","expiresInSeconds":1800}`) + })) + defer srv.Close() + + ts := newTestTokenSource(t, srv.URL, testKey(t)) + + const goroutines = 50 + var wg sync.WaitGroup + tokens := make([]string, goroutines) + errs := make([]error, goroutines) + + wg.Add(goroutines) + for i := range goroutines { + go func() { + defer wg.Done() + tokens[i], errs[i] = ts.Token(context.Background()) + }() + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("goroutine %d: %v", i, err) + } + if tokens[i] != "shared" { + t.Errorf("goroutine %d token: got %q, want shared", i, tokens[i]) + } + } + if got := calls.Load(); got != 1 { + t.Errorf("exchanges: got %d, want 1", got) + } +} + +func TestTokenExchangeErrors(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + check func(*testing.T, error) + }{ + { + name: "401 is an auth error", + statusCode: http.StatusUnauthorized, + body: `{"message":"Invalid token"}`, + check: func(t *testing.T, err error) { + var authErr *AuthError + if !errors.As(err, &authErr) { + t.Fatalf("got %T (%v), want *AuthError", err, err) + } + if authErr.Message != "Invalid token" { + t.Errorf("message: got %q", authErr.Message) + } + // Unwrapping to *APIError keeps status-code checks working for + // callers that do not care which specific kind it is. + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Error("AuthError should unwrap to *APIError") + } + }, + }, + { + name: "429 is a quota error", + statusCode: http.StatusTooManyRequests, + body: `{"message":"Quota exceeded","details":["daily limit"]}`, + check: func(t *testing.T, err error) { + var quotaErr *QuotaError + if !errors.As(err, "aErr) { + t.Fatalf("got %T (%v), want *QuotaError", err, err) + } + if len(quotaErr.Details) != 1 || quotaErr.Details[0] != "daily limit" { + t.Errorf("details: got %v", quotaErr.Details) + } + }, + }, + { + name: "500 is a plain API error", + statusCode: http.StatusInternalServerError, + body: `{"message":"boom"}`, + check: func(t *testing.T, err error) { + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("got %T, want *APIError", err) + } + var authErr *AuthError + var quotaErr *QuotaError + if errors.As(err, &authErr) || errors.As(err, "aErr) { + t.Error("500 must not classify as auth or quota") + } + }, + }, + { + name: "non-JSON body still yields a message", + statusCode: http.StatusBadGateway, + body: "gateway down", + check: func(t *testing.T, err error) { + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("got %T, want *APIError", err) + } + if apiErr.Message != "gateway down" { + t.Errorf("message: got %q, want the raw body", apiErr.Message) + } + }, + }, + { + name: "empty body falls back to the status text", + statusCode: http.StatusServiceUnavailable, + body: "", + check: func(t *testing.T, err error) { + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("got %T, want *APIError", err) + } + if apiErr.Message != http.StatusText(http.StatusServiceUnavailable) { + t.Errorf("message: got %q", apiErr.Message) + } + }, + }, + { + name: "200 with no access token is an error", + statusCode: http.StatusOK, + body: `{"expiresInSeconds":1800}`, + check: func(t *testing.T, err error) { + if err == nil { + t.Fatal("want an error for a token response with no token") + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.statusCode) + fmt.Fprint(w, tc.body) + })) + defer srv.Close() + + ts := newTestTokenSource(t, srv.URL, testKey(t)) + _, err := ts.Token(context.Background()) + if err == nil { + t.Fatal("want an error") + } + tc.check(t, err) + }) + } +} + +func TestInvalidateForcesReExchange(t *testing.T) { + srv, calls := tokenServer(t, "tok", 1800) + ts := newTestTokenSource(t, srv.URL, testKey(t)) + + if _, err := ts.Token(context.Background()); err != nil { + t.Fatalf("Token: %v", err) + } + ts.Invalidate() + if _, err := ts.Token(context.Background()); err != nil { + t.Fatalf("Token after Invalidate: %v", err) + } + + if calls.Load() != 2 { + t.Errorf("exchanges: got %d, want 2", calls.Load()) + } +} + +func TestTokenRespectsContextCancellation(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(2 * time.Second) + fmt.Fprint(w, `{"accessToken":"late","expiresInSeconds":1800}`) + })) + defer srv.Close() + + ts := newTestTokenSource(t, srv.URL, testKey(t)) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + if _, err := ts.Token(ctx); err == nil { + t.Fatal("want an error when the context expires") + } +} + +func TestNewTokenSourceValidation(t *testing.T) { + key := testKey(t) + tests := []struct { + name string + cfg TokenSourceConfig + }{ + {"missing team ID", TokenSourceConfig{KeyID: "K", PrivateKey: key}}, + {"blank team ID", TokenSourceConfig{TeamID: " ", KeyID: "K", PrivateKey: key}}, + {"missing key ID", TokenSourceConfig{TeamID: "T", PrivateKey: key}}, + {"missing private key", TokenSourceConfig{TeamID: "T", KeyID: "K"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := NewTokenSource(tc.cfg); err == nil { + t.Error("want a validation error") + } + }) + } + + t.Run("defaults are applied", func(t *testing.T) { + ts, err := NewTokenSource(TokenSourceConfig{TeamID: "T", KeyID: "K", PrivateKey: key}) + if err != nil { + t.Fatalf("NewTokenSource: %v", err) + } + if ts.baseURL != DefaultBaseURL { + t.Errorf("baseURL: got %q, want %q", ts.baseURL, DefaultBaseURL) + } + if ts.httpClient == nil { + t.Error("httpClient should default to non-nil") + } + }) + + t.Run("trailing slash is trimmed from base URL", func(t *testing.T) { + ts, err := NewTokenSource(TokenSourceConfig{ + TeamID: "T", KeyID: "K", PrivateKey: key, + BaseURL: "https://example.test/", + }) + if err != nil { + t.Fatalf("NewTokenSource: %v", err) + } + if ts.baseURL != "https://example.test" { + t.Errorf("baseURL: got %q", ts.baseURL) + } + }) +} + +func TestParsePrivateKey(t *testing.T) { + key := testKey(t) + keyPEM := testKeyPEM(t, key) + + t.Run("raw PEM", func(t *testing.T) { + got, err := ParsePrivateKey(keyPEM) + if err != nil { + t.Fatalf("ParsePrivateKey: %v", err) + } + if !got.Equal(key) { + t.Error("parsed key differs from the original") + } + }) + + t.Run("PEM with surrounding whitespace", func(t *testing.T) { + if _, err := ParsePrivateKey("\n " + keyPEM + " \n"); err != nil { + t.Fatalf("ParsePrivateKey: %v", err) + } + }) + + t.Run("base64-encoded PEM", func(t *testing.T) { + got, err := ParsePrivateKey(base64.StdEncoding.EncodeToString([]byte(keyPEM))) + if err != nil { + t.Fatalf("ParsePrivateKey: %v", err) + } + if !got.Equal(key) { + t.Error("parsed key differs from the original") + } + }) + + // This is the case that motivates accepting base64 at all: config systems + // that wrap long values across lines. + t.Run("base64 wrapped across lines", func(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte(keyPEM)) + var wrapped string + for i := 0; i < len(encoded); i += 64 { + end := min(i+64, len(encoded)) + wrapped += encoded[i:end] + "\n" + } + if _, err := ParsePrivateKey(wrapped); err != nil { + t.Fatalf("ParsePrivateKey: %v", err) + } + }) + + t.Run("rejects empty input", func(t *testing.T) { + if _, err := ParsePrivateKey(" \n "); err == nil { + t.Error("want an error for empty input") + } + }) + + t.Run("rejects garbage without panicking", func(t *testing.T) { + for _, input := range []string{ + "not a key at all", + "-----BEGIN PRIVATE KEY-----\nnot base64\n-----END PRIVATE KEY-----", + base64.StdEncoding.EncodeToString([]byte("still not a key")), + } { + if _, err := ParsePrivateKey(input); err == nil { + t.Errorf("want an error for %q", input) + } + } + }) + + // An RSA key in a .p8 would sign with RS256, not ES256, and Apple would + // reject the resulting JWT. Failing at parse time gives a clearer error than + // a 401 later. + t.Run("rejects a non-EC key", func(t *testing.T) { + block := &pem.Block{Type: "PRIVATE KEY", Bytes: []byte("bogus der")} + if _, err := ParsePrivateKey(string(pem.EncodeToMemory(block))); err == nil { + t.Error("want an error for a non-EC key") + } + }) +} diff --git a/applemaps/doc.go b/applemaps/doc.go new file mode 100644 index 00000000..e8d20029 --- /dev/null +++ b/applemaps/doc.go @@ -0,0 +1,20 @@ +// Package applemaps is a client for the Apple Maps Server API +// (https://maps-api.apple.com). +// +// The package is deliberately free of any dependency on the rest of this +// repository — it knows nothing about POI, iowrappers, or Redis — so it can be +// extracted into its own module without a rewrite. Mapping Apple's types onto +// this service's domain model is the job of the adapter in iowrappers, not of +// this package. +// +// Authentication is a two-hop exchange. A caller supplies an Apple Developer +// team ID, a MapKit key ID, and the ECDSA private key from the corresponding +// .p8 file; the client signs a short-lived ES256 JWT with them, exchanges it at +// /v1/token for an access token, and sends that token on every subsequent call. +// TokenSource handles the caching and refresh of that access token. +// +// Apple enforces a quota of 25,000 calls per day per developer team, shared with +// MapKit JS, and returns HTTP 429 on every endpoint once it is exhausted. The +// client surfaces that as *QuotaError so callers can route around it rather than +// treating it as a generic failure. +package applemaps diff --git a/applemaps/errors.go b/applemaps/errors.go new file mode 100644 index 00000000..8fe12fd9 --- /dev/null +++ b/applemaps/errors.go @@ -0,0 +1,102 @@ +package applemaps + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// maxErrorBodyBytes bounds how much of an error body is read. Apple's error +// bodies are small; a cap keeps a misbehaving proxy from streaming an unbounded +// response into memory. +const maxErrorBodyBytes = 64 << 10 + +// APIError is a non-2xx response from the Apple Maps Server API. +type APIError struct { + StatusCode int + Message string + Details []string +} + +func (e *APIError) Error() string { + if len(e.Details) == 0 { + return fmt.Sprintf("applemaps: HTTP %d: %s", e.StatusCode, e.Message) + } + return fmt.Sprintf("applemaps: HTTP %d: %s (%s)", e.StatusCode, e.Message, strings.Join(e.Details, "; ")) +} + +// AuthError is a 401. It means the access token is missing, expired, or invalid. +// The client retries once on its own after re-exchanging; an AuthError reaching +// a caller means the second attempt failed too, so the credentials themselves +// are suspect. +type AuthError struct{ *APIError } + +func (e *AuthError) Unwrap() error { return e.APIError } + +// QuotaError is a 429: the daily service call quota for this developer team is +// exhausted. Apple applies the quota per team across both the Server API and +// MapKit JS, and returns 429 from every endpoint including /v1/token once it is +// hit. +// +// Callers routing between providers should test for this specifically rather +// than treating it as a generic failure — it is not retryable within the same +// UTC day, so backing off and retrying will not help. +type QuotaError struct{ *APIError } + +func (e *QuotaError) Unwrap() error { return e.APIError } + +// NotFoundError reports that a request succeeded but matched nothing. Apple +// returns HTTP 200 with an empty results array for a geocode that resolves to +// no place, which is a different condition from a transport or auth failure and +// would otherwise surface as an indistinguishable zero value. +type NotFoundError struct { + // Query is the input that matched nothing, for use in the error message. + Query string +} + +func (e *NotFoundError) Error() string { + return fmt.Sprintf("applemaps: no results for %q", e.Query) +} + +// newAPIError converts a non-2xx response into the most specific error type +// available. A body that is not valid JSON still produces a usable message +// rather than an empty one. +func newAPIError(statusCode int, body []byte) error { + var parsed ErrorResponse + // A decode failure is not itself an error worth reporting: it only means the + // body was not Apple's documented error shape, and the raw body is used + // instead. + _ = json.Unmarshal(body, &parsed) + + err := &APIError{ + StatusCode: statusCode, + Message: parsed.Message, + Details: parsed.Details, + } + if err.Message == "" { + if raw := strings.TrimSpace(string(body)); raw != "" { + err.Message = raw + } else { + err.Message = http.StatusText(statusCode) + } + } + + switch statusCode { + case http.StatusUnauthorized: + return &AuthError{APIError: err} + case http.StatusTooManyRequests: + return &QuotaError{APIError: err} + default: + return err + } +} + +// retryable reports whether a failed request is worth sending again. +// +// 429 is deliberately excluded: the quota resets daily, so retrying inside one +// request's lifetime cannot succeed and only burns further calls against a quota +// that is already exhausted. +func retryable(statusCode int) bool { + return statusCode >= http.StatusInternalServerError +} diff --git a/applemaps/poicategory.go b/applemaps/poicategory.go new file mode 100644 index 00000000..b0aa91f7 --- /dev/null +++ b/applemaps/poicategory.go @@ -0,0 +1,132 @@ +package applemaps + +// PoiCategory describes a specific point-of-interest category. +// +// This is a closed enum defined by Apple; there is no way to request a category +// outside it. Note how coarse the retail branch is: PoiCategoryStore is the only +// general retail value, so supermarkets, shopping malls, clothing stores, and +// electronics stores are indistinguishable by category alone. PoiCategoryFoodMarket +// is the nearest thing to a grocery category and also matches specialty grocers. +type PoiCategory string + +const ( + PoiCategoryAirport PoiCategory = "Airport" + PoiCategoryAirportGate PoiCategory = "AirportGate" + PoiCategoryAirportTerminal PoiCategory = "AirportTerminal" + PoiCategoryAmusementPark PoiCategory = "AmusementPark" + PoiCategoryAnimalService PoiCategory = "AnimalService" + PoiCategoryATM PoiCategory = "ATM" + PoiCategoryAutomotiveRepair PoiCategory = "AutomotiveRepair" + PoiCategoryAquarium PoiCategory = "Aquarium" + PoiCategoryBakery PoiCategory = "Bakery" + PoiCategoryBank PoiCategory = "Bank" + PoiCategoryBaseball PoiCategory = "Baseball" + PoiCategoryBasketball PoiCategory = "Basketball" + PoiCategoryBeach PoiCategory = "Beach" + PoiCategoryBeauty PoiCategory = "Beauty" + PoiCategoryBowling PoiCategory = "Bowling" + PoiCategoryBrewery PoiCategory = "Brewery" + PoiCategoryCafe PoiCategory = "Cafe" + PoiCategoryCampground PoiCategory = "Campground" + PoiCategoryCarRental PoiCategory = "CarRental" + PoiCategoryCastle PoiCategory = "Castle" + PoiCategoryConventionCenter PoiCategory = "ConventionCenter" + PoiCategoryDistillery PoiCategory = "Distillery" + PoiCategoryEVCharger PoiCategory = "EVCharger" + PoiCategoryFairground PoiCategory = "Fairground" + PoiCategoryFishing PoiCategory = "Fishing" + PoiCategoryFireStation PoiCategory = "FireStation" + PoiCategoryFitnessCenter PoiCategory = "FitnessCenter" + PoiCategoryFoodMarket PoiCategory = "FoodMarket" + PoiCategoryFortress PoiCategory = "Fortress" + PoiCategoryGasStation PoiCategory = "GasStation" + PoiCategoryGoKart PoiCategory = "GoKart" + PoiCategoryGolf PoiCategory = "Golf" + PoiCategoryHiking PoiCategory = "Hiking" + PoiCategoryHospital PoiCategory = "Hospital" + PoiCategoryHotel PoiCategory = "Hotel" + PoiCategoryKayaking PoiCategory = "Kayaking" + PoiCategoryLandmark PoiCategory = "Landmark" + PoiCategoryLaundry PoiCategory = "Laundry" + PoiCategoryLibrary PoiCategory = "Library" + PoiCategoryMailbox PoiCategory = "Mailbox" + PoiCategoryMarina PoiCategory = "Marina" + PoiCategoryMiniGolf PoiCategory = "MiniGolf" + PoiCategoryMovieTheater PoiCategory = "MovieTheater" + PoiCategoryMuseum PoiCategory = "Museum" + PoiCategoryMusicVenue PoiCategory = "MusicVenue" + PoiCategoryNationalPark PoiCategory = "NationalPark" + PoiCategoryNationalMonument PoiCategory = "NationalMonument" + PoiCategoryNightlife PoiCategory = "Nightlife" + PoiCategoryPark PoiCategory = "Park" + PoiCategoryParking PoiCategory = "Parking" + PoiCategoryPharmacy PoiCategory = "Pharmacy" + PoiCategoryPlanetarium PoiCategory = "Planetarium" + PoiCategoryPlayground PoiCategory = "Playground" + PoiCategoryPolice PoiCategory = "Police" + PoiCategoryPostOffice PoiCategory = "PostOffice" + PoiCategoryPublicTransport PoiCategory = "PublicTransport" + PoiCategoryReligiousSite PoiCategory = "ReligiousSite" + PoiCategoryRestaurant PoiCategory = "Restaurant" + PoiCategoryRestroom PoiCategory = "Restroom" + PoiCategoryRockClimbing PoiCategory = "RockClimbing" + PoiCategoryRVPark PoiCategory = "RVPark" + PoiCategorySchool PoiCategory = "School" + PoiCategorySkatePark PoiCategory = "SkatePark" + PoiCategorySkating PoiCategory = "Skating" + PoiCategorySkiing PoiCategory = "Skiing" + PoiCategorySoccer PoiCategory = "Soccer" + PoiCategorySpa PoiCategory = "Spa" + PoiCategoryStadium PoiCategory = "Stadium" + PoiCategoryStore PoiCategory = "Store" + PoiCategorySurfing PoiCategory = "Surfing" + PoiCategorySwimming PoiCategory = "Swimming" + PoiCategoryTennis PoiCategory = "Tennis" + PoiCategoryTheater PoiCategory = "Theater" + PoiCategoryUniversity PoiCategory = "University" + PoiCategoryVolleyball PoiCategory = "Volleyball" + PoiCategoryWinery PoiCategory = "Winery" + PoiCategoryZoo PoiCategory = "Zoo" +) + +// AllPoiCategories lists every category Apple defines. Its main use is +// validating that a caller-supplied category is one Apple will accept, since an +// unknown value is rejected by the API rather than ignored. +var AllPoiCategories = []PoiCategory{ + PoiCategoryAirport, PoiCategoryAirportGate, PoiCategoryAirportTerminal, + PoiCategoryAmusementPark, PoiCategoryAnimalService, PoiCategoryATM, + PoiCategoryAutomotiveRepair, PoiCategoryAquarium, PoiCategoryBakery, + PoiCategoryBank, PoiCategoryBaseball, PoiCategoryBasketball, + PoiCategoryBeach, PoiCategoryBeauty, PoiCategoryBowling, + PoiCategoryBrewery, PoiCategoryCafe, PoiCategoryCampground, + PoiCategoryCarRental, PoiCategoryCastle, PoiCategoryConventionCenter, + PoiCategoryDistillery, PoiCategoryEVCharger, PoiCategoryFairground, + PoiCategoryFishing, PoiCategoryFireStation, PoiCategoryFitnessCenter, + PoiCategoryFoodMarket, PoiCategoryFortress, PoiCategoryGasStation, + PoiCategoryGoKart, PoiCategoryGolf, PoiCategoryHiking, + PoiCategoryHospital, PoiCategoryHotel, PoiCategoryKayaking, + PoiCategoryLandmark, PoiCategoryLaundry, PoiCategoryLibrary, + PoiCategoryMailbox, PoiCategoryMarina, PoiCategoryMiniGolf, + PoiCategoryMovieTheater, PoiCategoryMuseum, PoiCategoryMusicVenue, + PoiCategoryNationalPark, PoiCategoryNationalMonument, PoiCategoryNightlife, + PoiCategoryPark, PoiCategoryParking, PoiCategoryPharmacy, + PoiCategoryPlanetarium, PoiCategoryPlayground, PoiCategoryPolice, + PoiCategoryPostOffice, PoiCategoryPublicTransport, PoiCategoryReligiousSite, + PoiCategoryRestaurant, PoiCategoryRestroom, PoiCategoryRockClimbing, + PoiCategoryRVPark, PoiCategorySchool, PoiCategorySkatePark, + PoiCategorySkating, PoiCategorySkiing, PoiCategorySoccer, + PoiCategorySpa, PoiCategoryStadium, PoiCategoryStore, + PoiCategorySurfing, PoiCategorySwimming, PoiCategoryTennis, + PoiCategoryTheater, PoiCategoryUniversity, PoiCategoryVolleyball, + PoiCategoryWinery, PoiCategoryZoo, +} + +// Valid reports whether c is a category Apple defines. +func (c PoiCategory) Valid() bool { + for _, known := range AllPoiCategories { + if c == known { + return true + } + } + return false +} diff --git a/applemaps/types.go b/applemaps/types.go new file mode 100644 index 00000000..245b06f7 --- /dev/null +++ b/applemaps/types.go @@ -0,0 +1,278 @@ +package applemaps + +// Apple documents every response field as optional. Where a Go zero value would +// be indistinguishable from an absent field AND the zero value is itself +// meaningful — a distance of 0 metres, a route with no tolls — the field is a +// pointer. Where the zero value is not meaningful (an empty name, an empty +// slice) a plain type is used, because collapsing "absent" into "empty" loses +// nothing a caller could act on. + +// Location describes a point in terms of its latitude and longitude. +type Location struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +// MapRegion is a rectangular region expressed as its south-west and north-east +// corners. +type MapRegion struct { + NorthLatitude float64 `json:"northLatitude"` + EastLongitude float64 `json:"eastLongitude"` + SouthLatitude float64 `json:"southLatitude"` + WestLongitude float64 `json:"westLongitude"` +} + +// SearchMapRegion is Apple's name for the region a search response echoes back. +// Apple documents it as a separate object, but its fields are identical to +// MapRegion, so it is an alias rather than a duplicate declaration. +type SearchMapRegion = MapRegion + +// StructuredAddress describes the individual components of a place's address. +type StructuredAddress struct { + AdministrativeArea string `json:"administrativeArea,omitempty"` + AdministrativeAreaCode string `json:"administrativeAreaCode,omitempty"` + AreasOfInterest []string `json:"areasOfInterest,omitempty"` + DependentLocalities []string `json:"dependentLocalities,omitempty"` + FullThoroughfare string `json:"fullThoroughfare,omitempty"` + Locality string `json:"locality,omitempty"` + PostCode string `json:"postCode,omitempty"` + SubLocality string `json:"subLocality,omitempty"` + SubThoroughfare string `json:"subThoroughfare,omitempty"` + Thoroughfare string `json:"thoroughfare,omitempty"` +} + +// Place describes a place in terms of its spatial and administrative +// properties. +// +// Apple exposes no photo, opening hours, rating, review count, price level, or +// business status on this object, and there is no endpoint or parameter that +// adds them. Callers needing those fields must source them elsewhere. +type Place struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Coordinate Location `json:"coordinate"` + FormattedAddressLines []string `json:"formattedAddressLines,omitempty"` + StructuredAddress *StructuredAddress `json:"structuredAddress,omitempty"` + Country string `json:"country,omitempty"` + CountryCode string `json:"countryCode,omitempty"` + DisplayMapRegion *MapRegion `json:"displayMapRegion,omitempty"` + AlternateIDs []string `json:"alternateIds,omitempty"` +} + +// SearchPlace is a Place as returned by the search endpoints, which add a +// category. Apple names this type SearchResponse.Place. +// +// PoiCategory comes from a fixed enum whose entire retail branch is +// PoiCategoryStore, so it cannot distinguish a supermarket from a shopping mall +// from a clothing store. Callers needing that granularity should carry it in the +// search query rather than reading it back off this field. +type SearchPlace struct { + Place + PoiCategory PoiCategory `json:"poiCategory,omitempty"` +} + +// PaginationInfo carries the tokens and totals for a paginated search. Apple +// names this type SearchResponse.PaginationInfo. +type PaginationInfo struct { + NextPageToken string `json:"nextPageToken,omitempty"` + PrevPageToken string `json:"prevPageToken,omitempty"` + TotalPageCount int `json:"totalPageCount,omitempty"` + TotalResults int `json:"totalResults,omitempty"` +} + +// SearchResponse is the response from /v1/search. +type SearchResponse struct { + DisplayMapRegion *SearchMapRegion `json:"displayMapRegion,omitempty"` + Results []SearchPlace `json:"results,omitempty"` + PaginationInfo *PaginationInfo `json:"paginationInfo,omitempty"` +} + +// PlaceResults is the response from /v1/geocode and /v1/reverseGeocode. +type PlaceResults struct { + Results []Place `json:"results,omitempty"` +} + +// PlaceLookupError reports a single failed ID within an otherwise successful +// batch lookup. Apple names this type PlacesResponse.PlaceLookupError. +type PlaceLookupError struct { + ID string `json:"id,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` +} + +// PlacesResponse is the response from /v1/place. A batch lookup can partially +// succeed, populating both Results and Errors; neither field implies the other +// is empty. +type PlacesResponse struct { + Results []Place `json:"results,omitempty"` + Errors []PlaceLookupError `json:"errors,omitempty"` +} + +// AlternateIDs lists the alternate place IDs for one place ID. Apple names this +// type AlternateIdsResponse.AlternateIds. +type AlternateIDs struct { + ID string `json:"id,omitempty"` + AlternateIDs []string `json:"alternateIds,omitempty"` +} + +// AlternateIDsResponse is the response from /v1/place/alternateIds. Like +// PlacesResponse, it can partially succeed. +type AlternateIDsResponse struct { + Results []AlternateIDs `json:"results,omitempty"` + Errors []PlaceLookupError `json:"errors,omitempty"` +} + +// TokenResponse is the response from /v1/token. ExpiresInSeconds is observed to +// be 1800. +type TokenResponse struct { + AccessToken string `json:"accessToken"` + ExpiresInSeconds int `json:"expiresInSeconds"` +} + +// ErrorResponse is the body Apple returns with a non-2xx status. +type ErrorResponse struct { + Message string `json:"message,omitempty"` + Details []string `json:"details,omitempty"` +} + +// AutocompleteResult is a single suggestion from /v1/searchAutocomplete. +// +// CompletionURL is a relative URI into the search endpoint carrying opaque +// metadata about the suggestion; to resolve a suggestion in a specific language, +// the lang parameter must be set on the original autocomplete request rather +// than added to this URL. +type AutocompleteResult struct { + CompletionURL string `json:"completionUrl,omitempty"` + DisplayLines []string `json:"displayLines,omitempty"` + Location *Location `json:"location,omitempty"` + StructuredAddress *StructuredAddress `json:"structuredAddress,omitempty"` +} + +// SearchAutocompleteResponse is the response from /v1/searchAutocomplete. +type SearchAutocompleteResponse struct { + Results []AutocompleteResult `json:"results,omitempty"` +} + +// Eta is an estimated time of arrival for one destination. Apple names this type +// EtaResponse.Eta. +// +// The three numeric fields are pointers because zero is a legitimate value — +// a destination at the origin has a distance of 0 — and Apple marks them +// optional, so a plain int could not distinguish the two. +type Eta struct { + Destination *Location `json:"destination,omitempty"` + DistanceMeters *int `json:"distanceMeters,omitempty"` + ExpectedTravelTimeSeconds *int `json:"expectedTravelTimeSeconds,omitempty"` + StaticTravelTimeSeconds *int `json:"staticTravelTimeSeconds,omitempty"` + TransportType TransportType `json:"transportType,omitempty"` +} + +// EtaResponse is the response from /v1/etas. +type EtaResponse struct { + ETAs []Eta `json:"etas,omitempty"` +} + +// Route is one route within a DirectionsResponse. Apple names this type +// DirectionsResponse.Route. +// +// StepIndexes are indexes into DirectionsResponse.Steps, not steps themselves. +// Use DirectionsResponse.ResolveRoute to walk them safely; indexing directly +// panics on a malformed response. +// +// HasTolls is a pointer because Apple documents three states: true, false, and +// undefined meaning the route may or may not have tolls. +type Route struct { + Name string `json:"name,omitempty"` + DistanceMeters *int `json:"distanceMeters,omitempty"` + DurationSeconds *int `json:"durationSeconds,omitempty"` + HasTolls *bool `json:"hasTolls,omitempty"` + StepIndexes []int `json:"stepIndexes,omitempty"` + TransportType TransportType `json:"transportType,omitempty"` +} + +// Step is one step within a DirectionsResponse. Apple names this type +// DirectionsResponse.Step. +// +// StepPathIndex is an index into DirectionsResponse.StepPaths, not a path. +// TransportType is set only when it differs from the containing route's. +type Step struct { + DistanceMeters *int `json:"distanceMeters,omitempty"` + DurationSeconds *int `json:"durationSeconds,omitempty"` + Instructions string `json:"instructions,omitempty"` + StepPathIndex *int `json:"stepPathIndex,omitempty"` + TransportType TransportType `json:"transportType,omitempty"` +} + +// DirectionsResponse is the response from /v1/directions. +// +// The shape is flattened rather than nested: Steps and StepPaths are global +// across every route, and a route reaches its steps through Route.StepIndexes +// while a step reaches its path through Step.StepPathIndex. ResolveRoute walks +// those indexes with bounds checks. +// +// StepPaths is typed as a slice of polylines, each polyline a slice of points, +// which matches Apple's prose description ("each step path is a single polyline +// represented as an array of points"). Apple's machine-readable schema annotates +// the field as a flat array of Location, which contradicts that prose; the prose +// is followed here and the live probe in step 8 of the implementation plan +// confirms it. +type DirectionsResponse struct { + Origin *Place `json:"origin,omitempty"` + Destination *Place `json:"destination,omitempty"` + Routes []Route `json:"routes,omitempty"` + Steps []Step `json:"steps,omitempty"` + StepPaths [][]Location `json:"stepPaths,omitempty"` +} + +// SearchResultType filters which kinds of result /v1/search returns. +type SearchResultType string + +const ( + SearchResultTypePoi SearchResultType = "poi" + SearchResultTypeAddress SearchResultType = "address" + SearchResultTypePhysicalFeature SearchResultType = "physicalFeature" + SearchResultTypePointOfInterest SearchResultType = "pointOfInterest" +) + +// SearchACResultType filters which kinds of result /v1/searchAutocomplete +// returns. Unlike SearchResultType it has no address member. +type SearchACResultType string + +const ( + SearchACResultTypePoi SearchACResultType = "poi" + SearchACResultTypePhysicalFeature SearchACResultType = "physicalFeature" + SearchACResultTypePointOfInterest SearchACResultType = "pointOfInterest" +) + +// AddressCategory narrows which address results a search returns. Using it +// requires SearchResultTypeAddress in the request's ResultTypeFilter. +type AddressCategory string + +const ( + AddressCategoryCountry AddressCategory = "Country" + AddressCategorySubAdministrativeArea AddressCategory = "SubAdministrativeArea" + AddressCategoryLocality AddressCategory = "Locality" + AddressCategorySubLocality AddressCategory = "SubLocality" + AddressCategoryPostalCode AddressCategory = "PostalCode" +) + +// DirectionsAvoid names a feature to avoid when routing. Tolls is the only value +// Apple defines. +type DirectionsAvoid string + +const DirectionsAvoidTolls DirectionsAvoid = "Tolls" + +// TransportType is a mode of transportation. +// +// Apple's documentation truncates the list of valid values mid-sentence ("which +// is one of:" followed by nothing), so these three constants come from MapKit's +// equivalent enum rather than from the Server API reference. Step 8 of the +// implementation plan confirms the accepted set against the live API by sending +// a deliberately invalid value and reading the accepted set back out of +// ErrorResponse.Details. +type TransportType string + +const ( + TransportTypeAutomobile TransportType = "Automobile" + TransportTypeWalking TransportType = "Walking" + TransportTypeTransit TransportType = "Transit" +) diff --git a/applemaps/types_test.go b/applemaps/types_test.go new file mode 100644 index 00000000..9fc7fbf1 --- /dev/null +++ b/applemaps/types_test.go @@ -0,0 +1,268 @@ +package applemaps + +import ( + "encoding/json" + "testing" +) + +// The payloads below are Apple's own documented examples, copied verbatim, so a +// decode failure here means our structs disagree with the published schema +// rather than with a guess. + +const eiffelTowerSearchResponse = `{ + "displayMapRegion": { + "southLatitude": 48.856909736059606, + "westLongitude": 2.2924737352877855, + "northLatitude": 48.85963364504278, + "eastLongitude": 2.2965897526592016 + }, + "results": [ + { + "name": "Eiffel Tower", + "formattedAddressLines": ["5 Avenue Anatole France", "75007 Paris", "France"], + "structuredAddress": { + "administrativeArea": "Île-de-France", + "locality": "Paris", + "postCode": "75007", + "subLocality": "Tour Eiffel-Champs de Mars", + "thoroughfare": "Avenue Anatole France", + "subThoroughfare": "5", + "fullThoroughfare": "5 Avenue Anatole France", + "areasOfInterest": ["Eiffel Tower", "Parc Du Champ De Mars"], + "dependentLocalities": ["7th arr.", "Tour Eiffel-Champs de Mars"] + }, + "country": "France", + "countryCode": "FR", + "coordinate": {"latitude": 48.85827172505176, "longitude": 2.294531782785587}, + "poiCategory": "Landmark" + } + ] +}` + +func TestSearchResponseDecodesAppleExample(t *testing.T) { + var got SearchResponse + if err := json.Unmarshal([]byte(eiffelTowerSearchResponse), &got); err != nil { + t.Fatalf("decode: %v", err) + } + + if len(got.Results) != 1 { + t.Fatalf("results: got %d, want 1", len(got.Results)) + } + place := got.Results[0] + + // Promoted fields from the embedded Place must survive the flattened JSON. + if place.Name != "Eiffel Tower" { + t.Errorf("name: got %q, want %q", place.Name, "Eiffel Tower") + } + if place.Coordinate.Latitude != 48.85827172505176 { + t.Errorf("latitude: got %v", place.Coordinate.Latitude) + } + if place.CountryCode != "FR" { + t.Errorf("countryCode: got %q", place.CountryCode) + } + if place.PoiCategory != PoiCategoryLandmark { + t.Errorf("poiCategory: got %q, want %q", place.PoiCategory, PoiCategoryLandmark) + } + if got := len(place.FormattedAddressLines); got != 3 { + t.Errorf("formattedAddressLines: got %d, want 3", got) + } + if place.StructuredAddress == nil { + t.Fatal("structuredAddress: got nil") + } + if place.StructuredAddress.Locality != "Paris" { + t.Errorf("locality: got %q", place.StructuredAddress.Locality) + } + if got := len(place.StructuredAddress.DependentLocalities); got != 2 { + t.Errorf("dependentLocalities: got %d, want 2", got) + } + if got.DisplayMapRegion == nil { + t.Fatal("displayMapRegion: got nil") + } + if got.DisplayMapRegion.NorthLatitude != 48.85963364504278 { + t.Errorf("northLatitude: got %v", got.DisplayMapRegion.NorthLatitude) + } + + // Apple omits paginationInfo when a response is not paginated. That must + // stay distinguishable from a present-but-empty one, since SearchAll + // terminates on the absence of a next page token. + if got.PaginationInfo != nil { + t.Errorf("paginationInfo: got %+v, want nil when absent", got.PaginationInfo) + } +} + +const whiteHouseGeocodeResponse = `{ + "results": [ + { + "coordinate": {"latitude": 38.8976635, "longitude": -77.036574}, + "displayMapRegion": { + "southLatitude": 38.8931719235794, + "westLongitude": -77.04234524082925, + "northLatitude": 38.9021550764206, + "eastLongitude": -77.03080275917075 + }, + "name": "1600 Pennsylvania Ave NW", + "formattedAddressLines": ["1600 Pennsylvania Ave NW", "Washington, DC 20500", "United States"], + "structuredAddress": { + "administrativeArea": "District of Columbia", + "administrativeAreaCode": "DC", + "locality": "Washington", + "postCode": "20500", + "subLocality": "Washington Mall", + "thoroughfare": "Pennsylvania Ave NW", + "subThoroughfare": "1600", + "fullThoroughfare": "1600 Pennsylvania Ave NW", + "areasOfInterest": ["The White House", "President's Park"], + "dependentLocalities": ["Washington Mall"] + }, + "country": "United States", + "countryCode": "US" + } + ] +}` + +func TestPlaceResultsDecodesAppleExample(t *testing.T) { + var got PlaceResults + if err := json.Unmarshal([]byte(whiteHouseGeocodeResponse), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Results) != 1 { + t.Fatalf("results: got %d, want 1", len(got.Results)) + } + place := got.Results[0] + if place.Coordinate.Longitude != -77.036574 { + t.Errorf("longitude: got %v", place.Coordinate.Longitude) + } + if place.StructuredAddress.AdministrativeAreaCode != "DC" { + t.Errorf("administrativeAreaCode: got %q", place.StructuredAddress.AdministrativeAreaCode) + } +} + +func TestTokenResponseDecodes(t *testing.T) { + var got TokenResponse + if err := json.Unmarshal([]byte(`{"accessToken":"abc.def.ghi","expiresInSeconds":1800}`), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.AccessToken != "abc.def.ghi" { + t.Errorf("accessToken: got %q", got.AccessToken) + } + if got.ExpiresInSeconds != 1800 { + t.Errorf("expiresInSeconds: got %d, want 1800", got.ExpiresInSeconds) + } +} + +// A distance of zero and a toll-free route are real answers. If these fields +// were plain values rather than pointers, both would be indistinguishable from +// Apple having omitted them, and a caller could not tell "0 metres away" from +// "no distance reported". +func TestZeroValuesStayDistinguishableFromAbsentFields(t *testing.T) { + t.Run("present and zero", func(t *testing.T) { + var got EtaResponse + if err := json.Unmarshal([]byte(`{"etas":[{"distanceMeters":0,"expectedTravelTimeSeconds":0}]}`), &got); err != nil { + t.Fatalf("decode: %v", err) + } + eta := got.ETAs[0] + if eta.DistanceMeters == nil { + t.Fatal("distanceMeters: got nil, want pointer to 0") + } + if *eta.DistanceMeters != 0 { + t.Errorf("distanceMeters: got %d, want 0", *eta.DistanceMeters) + } + }) + + t.Run("absent", func(t *testing.T) { + var got EtaResponse + if err := json.Unmarshal([]byte(`{"etas":[{}]}`), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.ETAs[0].DistanceMeters != nil { + t.Error("distanceMeters: got non-nil, want nil when absent") + } + }) + + t.Run("hasTolls false versus undefined", func(t *testing.T) { + var explicit DirectionsResponse + if err := json.Unmarshal([]byte(`{"routes":[{"hasTolls":false}]}`), &explicit); err != nil { + t.Fatalf("decode: %v", err) + } + if explicit.Routes[0].HasTolls == nil { + t.Fatal("hasTolls: got nil, want pointer to false") + } + if *explicit.Routes[0].HasTolls { + t.Error("hasTolls: got true, want false") + } + + var undefined DirectionsResponse + if err := json.Unmarshal([]byte(`{"routes":[{}]}`), &undefined); err != nil { + t.Fatalf("decode: %v", err) + } + if undefined.Routes[0].HasTolls != nil { + t.Error("hasTolls: got non-nil, want nil when Apple leaves it undefined") + } + }) +} + +// Apple describes each step path as "a single polyline represented as an array +// of points", which makes stepPaths a list of polylines rather than a flat list +// of points. Its machine-readable schema says otherwise; this pins the prose +// reading that types.go documents. +func TestStepPathsDecodeAsPolylines(t *testing.T) { + const body = `{"stepPaths":[[{"latitude":1,"longitude":2},{"latitude":3,"longitude":4}],[{"latitude":5,"longitude":6}]]}` + var got DirectionsResponse + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.StepPaths) != 2 { + t.Fatalf("stepPaths: got %d polylines, want 2", len(got.StepPaths)) + } + if len(got.StepPaths[0]) != 2 { + t.Errorf("first polyline: got %d points, want 2", len(got.StepPaths[0])) + } + if got.StepPaths[1][0].Latitude != 5 { + t.Errorf("second polyline first point: got %v, want 5", got.StepPaths[1][0].Latitude) + } +} + +func TestPlacesResponseSurfacesPartialFailure(t *testing.T) { + const body = `{"results":[{"id":"good","name":"Somewhere"}],"errors":[{"id":"bad","errorCode":"NOT_FOUND"}]}` + var got PlacesResponse + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Results) != 1 || len(got.Errors) != 1 { + t.Fatalf("got %d results and %d errors, want 1 and 1", len(got.Results), len(got.Errors)) + } + if got.Errors[0].ErrorCode != "NOT_FOUND" { + t.Errorf("errorCode: got %q", got.Errors[0].ErrorCode) + } +} + +func TestAllPoiCategoriesIsCompleteAndUnique(t *testing.T) { + // Apple's PoiCategory reference lists 77 categories. + const want = 77 + if got := len(AllPoiCategories); got != want { + t.Errorf("AllPoiCategories: got %d, want %d", got, want) + } + + seen := make(map[PoiCategory]bool, len(AllPoiCategories)) + for _, c := range AllPoiCategories { + if seen[c] { + t.Errorf("duplicate category %q", c) + } + seen[c] = true + if c == "" { + t.Error("empty category in AllPoiCategories") + } + } +} + +func TestPoiCategoryValid(t *testing.T) { + if !PoiCategoryStore.Valid() { + t.Error("PoiCategoryStore should be valid") + } + if PoiCategory("Supermarket").Valid() { + t.Error(`"Supermarket" is not an Apple category and must not validate`) + } + if PoiCategory("").Valid() { + t.Error("empty category must not validate") + } +} diff --git a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md index 020b199f..580c47ce 100644 --- a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md +++ b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md @@ -100,7 +100,7 @@ applemaps/ auth.go TokenSource: ES256 JWT signing, /v1/token exchange, TTL cache client.go Client, Options, doJSON, backoff, 401-retry, typed 429 types.go all request/response structs - poicategory.go the 75 PoiCategory constants + poicategory.go the 77 PoiCategory constants geocode.go Geocode, ReverseGeocode search.go Search, SearchAll (pagination), SearchAutocomplete place.go Place, Places, AlternateIDs @@ -241,7 +241,7 @@ retired by the `Operational` read filters, and is refreshed only by ## Category mapping -Apple's `PoiCategory` is a fixed 75-value enum whose entire retail branch is +Apple's `PoiCategory` is a fixed 77-value enum whose entire retail branch is `Store`, plus `FoodMarket`, `Pharmacy`, `GasStation`, `Bakery`, `Cafe`, `Restaurant`. It has no `supermarket`, `department_store`, `clothing_store`, `electronics_store`, or `convenience_store`. diff --git a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md new file mode 100644 index 00000000..fd13016a --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md @@ -0,0 +1,154 @@ +# Apple Maps SDK — Phase 1 Implementation Plan + +Design: [2026-08-04-apple-maps-sdk-design.md](2026-08-04-apple-maps-sdk-design.md). +Section references below point there; no design decisions are restated or +re-opened in this document. + +Scope: the `applemaps/` package only. Nothing outside the new directory changes +except `.gitignore` and `go.mod`. Phase 2 (adapter, category map, fallback +routing, quota counter, config wiring) is a separate plan. + +Every step is test-first and independently verifiable. `go build ./... && go vet +./... && go test ./applemaps/...` must pass at the end of each step. + +## Step 0 — Guard the key, scaffold the package + +- Add `*.p8` to `.gitignore` **before any other file lands**. +- `mkdir applemaps`, add `doc.go` with the package comment. + +Verify: `git check-ignore -v test.p8` resolves to the new rule. + +## Step 1 — `types.go` + +All 22 objects and 5 enums from spec § "What Apple actually returns" and +§ "Endpoints". No behaviour, pure declarations. + +Enum constants: `PoiCategory` (77 values, own file `poicategory.go`), +`SearchResultType`, `SearchACResultType`, `AddressCategory`, `DirectionsAvoid`. + +`TransportType` is a string type with constants `Automobile`, `Walking`, +`Transit`, carrying a comment that Apple's documentation truncates the valid set +mid-sentence and that Step 8 confirms it empirically. + +Every field is a pointer or has an explicit `omitempty` decision: Apple marks +every response field optional, so a zero value must stay distinguishable from an +absent one wherever a caller could misread it — notably `Eta.distanceMeters` and +`Route.hasTolls`, where `0` and `false` are meaningful values. + +Verify: compiles; a round-trip test decodes the documented example payloads for +`SearchResponse`, `PlaceResults`, and `TokenResponse` without loss. + +## Step 2 — `auth.go` + +Per spec § "Authentication". Tests first, all against `httptest.Server`. + +Tests: + +1. Auth JWT carries `alg: ES256`, `kid`, `typ: JWT` in the header and + `iss`/`iat`/`exp` in the claims; signature verifies against the public key. +2. Exchange returns the access token and stores expiry from + `expiresInSeconds`. +3. A second call inside the validity window performs no HTTP request. +4. A call with under 5 minutes remaining triggers exactly one refresh. +5. 50 goroutines racing a cold `TokenSource` produce exactly one exchange + (assert on a request counter). +6. `401` on exchange surfaces as an auth error, not a retry loop. +7. Key parsing accepts the real PKCS#8 PEM shape; a malformed PEM errors + without panicking. + +Signing tests generate their own P-256 key via `ecdsa.GenerateKey`. No fixture +ever contains a real key. + +## Step 3 — `client.go` + +`Client`, `Options`, and the shared request path. + +Tests: + +1. Query encoding: comma-joined lists for the `*PoiCategories` and + `limitToCountries` params; `lat,lng` formatting for `searchLocation`, + `userLocation`, and `loc`; the 4-value `searchRegion` ordering + (north, east, south, west) exactly as spec § "Endpoints" states. +2. `Authorization: Bearer ` is set from the `TokenSource`. +3. `401` invalidates the token and retries once; a second `401` returns. + Assert the retry actually re-exchanged. +4. `429` decodes to a distinct `QuotaError` — callers must be able to tell quota + exhaustion from every other failure, since spec § "Quota guard" routes on it. +5. `5xx` retries with backoff up to a cap, then returns the last error. +6. `ErrorResponse` body is decoded into the returned error's message and + details; a non-JSON error body still yields a useful error. +7. Context cancellation aborts in flight. + +## Step 4 — `geocode.go` + +`Geocode(ctx, GeocodeRequest)` and `ReverseGeocode(ctx, lat, lng, opts)`. + +Tests: param encoding for both; `PlaceResults` decode; empty `results` returns a +typed not-found error rather than a zero `Place`. + +## Step 5 — `search.go` + +`Search`, `SearchAll`, `SearchAutocomplete`. + +`SearchAll` owns pagination only — it sets `enablePagination`, follows +`nextPageToken`, and stops on an absent token or at the caller's page cap. The +radius filtering and page cap described in spec § "Adapter onto the existing +seam" belong to Phase 2's adapter, not here; this package stays free of +`POI` concepts. + +Tests: single page; three pages followed via `nextPageToken`; termination on +absent token; page cap honoured and reported; `poiCategory` survives decode on +`SearchResponse.Place`. + +## Step 6 — `place.go` + +`Place(ctx, id, opts)`, `Places(ctx, ids, opts)`, `AlternateIDs(ctx, ids)`. + +Tests: path escaping for an id containing reserved characters; `ids` comma +joining; partial success — `PlacesResponse` carrying both `results` and `errors` +must surface both, never silently drop the errors. + +## Step 7 — `directions.go` + +`Directions` and `ETAs`. + +`DirectionsResponse` arrives flattened with index pointers: +`routes[].stepIndexes` index into top-level `steps[]`, and each +`steps[].stepPathIndex` indexes into top-level `stepPaths[]`. Expose a resolver +that walks a route into its steps and paths. + +Tests, and the reason this step is last: + +1. A well-formed multi-route response resolves to the right steps and paths. +2. **Out-of-range `stepIndexes` returns an error and does not panic.** +3. **Out-of-range `stepPathIndex` returns an error and does not panic.** +4. `destinations` pipe-joining for `/v1/etas`. +5. `departureDate`/`arrivalDate` ISO 8601 UTC formatting; setting both is + rejected locally, since spec § "Endpoints" records that Apple accepts only + one. + +Unchecked indexing here panics the server process on a malformed upstream +response. Bounds checks are the point of this step, not an afterthought. + +## Step 8 — Live probe + +Against the real API using `~/.config/applemaps/AuthKey_FUTFWSCQA4.p8`, a +throwaway `main` under `/private/tmp`, never committed: + +1. `/v1/token` — confirm the exchange and observed `expiresInSeconds`. +2. One call per endpoint; save responses as `testdata/` fixtures. +3. `/v1/etas` with a deliberately invalid `transportType`, to make Apple echo + the accepted set in `ErrorResponse.details`. Fold the result into the + `TransportType` constants and drop the caveat comment from Step 1. +4. Record any place where observed behaviour contradicts the documented schema. + +Budget: well under 20 calls against the 25,000/day quota. + +## Done when + +- `go build ./... && go vet ./... && go test ./applemaps/...` clean. +- No import of `iowrappers` or `POI` anywhere in `applemaps/` — the extractability + property from spec § "Package layout". Enforced by a test that greps the + package's own imports. +- `transportType` resolved from Step 8, not guessed. +- `*.p8` ignored; no key material in any committed file. From df7e8ee52f2a629ec7cb9657bcb1df5ceb3623ec Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Thu, 6 Aug 2026 23:42:47 -0700 Subject: [PATCH 03/18] feat(applemaps): client, all 10 endpoints, and live-probe corrections Completes Phase 1. Steps 3-8 of the plan. client.go separates two retry behaviours deliberately. A 401 is retried exactly once after invalidating the token, because the likely cause is a token revoked before its stated expiry and one fresh exchange either fixes it or proves the credentials wrong; a test asserts the retry actually re-exchanges rather than resending the rejected token. A 5xx retries with backoff. A 429 retries with neither, since a daily quota cannot be waited out inside one request. SearchAll owns pagination and nothing else -- no radius filter, no ranking -- so the package stays free of POI concepts and liftable into its own module. package_test.go enforces that mechanically by parsing every file's imports, because an accidental POI import compiles fine and would only surface later when someone tries to extract the package. ResolveRoute bounds-checks every index it follows. DirectionsResponse arrives flattened, so routes reach steps and steps reach polylines through indexes that all come from the network; trusting them would turn a truncated upstream response into a panic that kills the process. Nine cases cover out-of-range, negative, and missing arrays. Two corrections from probing the live API, both of which contradicted Apple's published documentation: ErrorResponse does not match its schema. Apple documents message and details at the top level, but the wire format nests them under an "error" key. Decoding only the documented shape silently produced empty error messages, losing the reason for every failure. UnmarshalJSON now accepts both forms. TransportType has four values, not the three MapKit implies. Cycling is accepted; Bicycle is rejected with HTTP 400. Apple's 400 does not enumerate valid values, so each candidate was probed individually and AllTransportTypes records the confirmed set. All four verified live with plausible travel times. The probe also confirmed stepPaths is an array of polylines, settling the contradiction between Apple's prose and its machine-readable schema in favour of the prose. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KJuZpNMJnG4RGqKnZzxF5k --- applemaps/client.go | 270 +++++++++++ applemaps/client_test.go | 382 ++++++++++++++++ applemaps/directions.go | 245 ++++++++++ applemaps/directions_test.go | 426 ++++++++++++++++++ applemaps/errors.go | 40 ++ applemaps/errors_test.go | 153 +++++++ applemaps/geocode.go | 103 +++++ applemaps/geocode_test.go | 180 ++++++++ applemaps/package_test.go | 47 ++ applemaps/place.go | 76 ++++ applemaps/place_test.go | 200 ++++++++ applemaps/search.go | 272 +++++++++++ applemaps/search_test.go | 336 ++++++++++++++ applemaps/types.go | 39 +- .../specs/2026-08-04-apple-maps-sdk-plan.md | 42 ++ 15 files changed, 2794 insertions(+), 17 deletions(-) create mode 100644 applemaps/client.go create mode 100644 applemaps/client_test.go create mode 100644 applemaps/directions.go create mode 100644 applemaps/directions_test.go create mode 100644 applemaps/errors_test.go create mode 100644 applemaps/geocode.go create mode 100644 applemaps/geocode_test.go create mode 100644 applemaps/package_test.go create mode 100644 applemaps/place.go create mode 100644 applemaps/place_test.go create mode 100644 applemaps/search.go create mode 100644 applemaps/search_test.go diff --git a/applemaps/client.go b/applemaps/client.go new file mode 100644 index 00000000..6ad018fd --- /dev/null +++ b/applemaps/client.go @@ -0,0 +1,270 @@ +package applemaps + +import ( + "context" + "crypto/ecdsa" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + // defaultMaxRetries is how many extra attempts a retryable failure gets. + defaultMaxRetries = 2 + + // retryBaseDelay is the first backoff interval; each subsequent retry + // doubles it. + retryBaseDelay = 200 * time.Millisecond + + // maxResponseBytes bounds a successful response body. Apple's largest + // responses are directions with full polylines, which stay well under this. + maxResponseBytes = 8 << 20 +) + +// Options configures a Client. +type Options struct { + // TeamID is the Apple Developer team ID (JWT iss). + TeamID string + // KeyID is the MapKit key ID (JWT kid). + KeyID string + // PrivateKey is the key from the .p8 file, as returned by ParsePrivateKey. + PrivateKey *ecdsa.PrivateKey + + // BaseURL defaults to DefaultBaseURL. + BaseURL string + // HTTPClient defaults to a client with a 15 second timeout. + HTTPClient *http.Client + // MaxRetries bounds retries of retryable failures. Zero means + // defaultMaxRetries; a negative value disables retrying. + MaxRetries int + // Lang is the BCP 47 language applied to requests that do not set one. + // Empty means Apple's default of en-US. + Lang string +} + +// Client calls the Apple Maps Server API. +// +// A Client is safe for concurrent use. +type Client struct { + tokens *TokenSource + baseURL string + httpClient *http.Client + maxRetries int + lang string + + // sleep is injectable so backoff is testable without real delays. + sleep func(context.Context, time.Duration) error +} + +// New returns a Client that manages its own TokenSource. +func New(opts Options) (*Client, error) { + tokens, err := NewTokenSource(TokenSourceConfig{ + TeamID: opts.TeamID, + KeyID: opts.KeyID, + PrivateKey: opts.PrivateKey, + BaseURL: opts.BaseURL, + HTTPClient: opts.HTTPClient, + }) + if err != nil { + return nil, err + } + return newWithTokenSource(tokens, opts), nil +} + +func newWithTokenSource(tokens *TokenSource, opts Options) *Client { + baseURL := opts.BaseURL + if baseURL == "" { + baseURL = DefaultBaseURL + } + httpClient := opts.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 15 * time.Second} + } + maxRetries := opts.MaxRetries + if maxRetries == 0 { + maxRetries = defaultMaxRetries + } + if maxRetries < 0 { + maxRetries = 0 + } + + return &Client{ + tokens: tokens, + baseURL: strings.TrimSuffix(baseURL, "/"), + httpClient: httpClient, + maxRetries: maxRetries, + lang: opts.Lang, + sleep: sleepContext, + } +} + +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// get issues an authenticated GET and decodes a JSON response into out. +// +// Two distinct retry behaviours apply, and they are deliberately not merged. A +// 401 is retried exactly once after invalidating the cached token, because the +// likely cause is a token revoked before its stated expiry and one fresh +// exchange either fixes it or proves the credentials wrong. A 5xx is retried +// with backoff, because the likely cause is transient. A 429 is retried by +// neither: the quota is daily, so no amount of waiting inside one request helps. +func (c *Client) get(ctx context.Context, path string, params url.Values, out any) error { + err := c.attempt(ctx, path, params, out) + + var authErr *AuthError + if errors.As(err, &authErr) { + c.tokens.Invalidate() + return c.attempt(ctx, path, params, out) + } + return err +} + +// attempt performs one logical request, retrying retryable status codes. +func (c *Client) attempt(ctx context.Context, path string, params url.Values, out any) error { + var lastErr error + + for i := 0; i <= c.maxRetries; i++ { + if i > 0 { + // Exponential backoff: 200ms, 400ms, 800ms... + delay := retryBaseDelay << (i - 1) + if err := c.sleep(ctx, delay); err != nil { + return err + } + } + + lastErr = c.once(ctx, path, params, out) + if lastErr == nil { + return nil + } + + var apiErr *APIError + if !errors.As(lastErr, &apiErr) || !retryable(apiErr.StatusCode) { + return lastErr + } + } + return lastErr +} + +// once performs a single HTTP round trip. +func (c *Client) once(ctx context.Context, path string, params url.Values, out any) error { + token, err := c.tokens.Token(ctx) + if err != nil { + return err + } + + endpoint := c.baseURL + path + if encoded := params.Encode(); encoded != "" { + endpoint += "?" + encoded + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("applemaps: build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("applemaps: %s: %w", path, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) + if readErr != nil { + return fmt.Errorf("applemaps: %s: HTTP %d and unreadable body: %w", path, resp.StatusCode, readErr) + } + return newAPIError(resp.StatusCode, body) + } + + if out == nil { + return nil + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return fmt.Errorf("applemaps: %s: read response: %w", path, err) + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("applemaps: %s: decode response: %w", path, err) + } + return nil +} + +// applyLang sets the lang parameter, preferring an explicit per-request value +// over the client default. Neither being set leaves the parameter off entirely +// so Apple applies its own default. +func (c *Client) applyLang(params url.Values, lang string) { + if lang == "" { + lang = c.lang + } + if lang != "" { + params.Set("lang", lang) + } +} + +// formatCoord renders a coordinate component the way Apple's examples do, with +// no trailing zeros and no exponent. +func formatCoord(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} + +// formatLocation renders a "latitude,longitude" pair, the form used by +// searchLocation, userLocation, loc, origin, and destination. +func formatLocation(lat, lng float64) string { + return formatCoord(lat) + "," + formatCoord(lng) +} + +// formatRegion renders a bounding box. +// +// The component order is north, east, south, west — not the south-west / +// north-east ordering that MapRegion's own field documentation describes. Apple +// specifies this ordering for the searchRegion query parameter specifically, and +// getting it wrong silently biases results toward the wrong area rather than +// producing an error. +func formatRegion(r MapRegion) string { + return strings.Join([]string{ + formatCoord(r.NorthLatitude), + formatCoord(r.EastLongitude), + formatCoord(r.SouthLatitude), + formatCoord(r.WestLongitude), + }, ",") +} + +// setCategories sets a comma-separated PoiCategory list, omitting the parameter +// entirely when the list is empty. +func setCategories(params url.Values, key string, categories []PoiCategory) { + if len(categories) == 0 { + return + } + parts := make([]string, len(categories)) + for i, c := range categories { + parts[i] = string(c) + } + params.Set(key, strings.Join(parts, ",")) +} + +// setStrings sets a comma-separated string list, omitting the parameter when the +// list is empty. +func setStrings(params url.Values, key string, values []string) { + if len(values) == 0 { + return + } + params.Set(key, strings.Join(values, ",")) +} diff --git a/applemaps/client_test.go b/applemaps/client_test.go new file mode 100644 index 00000000..3d156b0c --- /dev/null +++ b/applemaps/client_test.go @@ -0,0 +1,382 @@ +package applemaps + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + "time" +) + +// testClient wires a Client to a handler, with the token endpoint already +// answered so tests can focus on the endpoint under test. Backoff is stubbed out +// so retry tests do not spend real time. +func testClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == tokenPath { + fmt.Fprint(w, `{"accessToken":"test-access-token","expiresInSeconds":1800}`) + return + } + handler(w, r) + })) + t.Cleanup(srv.Close) + + client, err := New(Options{ + TeamID: "TEAM123456", + KeyID: "KEY7890123", + PrivateKey: testKey(t), + BaseURL: srv.URL, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + client.sleep = func(context.Context, time.Duration) error { return nil } + return client, srv +} + +func TestGetSetsBearerTokenFromTokenSource(t *testing.T) { + var gotAuth string + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + fmt.Fprint(w, `{}`) + }) + + var out struct{} + if err := client.get(context.Background(), "/v1/anything", nil, &out); err != nil { + t.Fatalf("get: %v", err) + } + if want := "Bearer test-access-token"; gotAuth != want { + t.Errorf("Authorization: got %q, want %q", gotAuth, want) + } +} + +func TestGetRetriesOnceAfter401(t *testing.T) { + var tokenCalls, endpointCalls atomic.Int64 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == tokenPath { + n := tokenCalls.Add(1) + fmt.Fprintf(w, `{"accessToken":"token-%d","expiresInSeconds":1800}`, n) + return + } + // First call rejects the token; the second accepts it. This is the + // revoked-early case the retry exists for. + if endpointCalls.Add(1) == 1 { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"message":"Invalid token"}`) + return + } + fmt.Fprint(w, `{"ok":true}`) + })) + defer srv.Close() + + client, err := New(Options{ + TeamID: "T", KeyID: "K", PrivateKey: testKey(t), BaseURL: srv.URL, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + client.sleep = func(context.Context, time.Duration) error { return nil } + + var out struct{ OK bool } + if err := client.get(context.Background(), "/v1/thing", nil, &out); err != nil { + t.Fatalf("get: %v", err) + } + if !out.OK { + t.Error("expected the retry's body to be decoded") + } + if got := endpointCalls.Load(); got != 2 { + t.Errorf("endpoint calls: got %d, want 2", got) + } + // The point of invalidating is that the retry uses a *freshly exchanged* + // token, not the rejected one. + if got := tokenCalls.Load(); got != 2 { + t.Errorf("token exchanges: got %d, want 2 — the 401 should have forced a re-exchange", got) + } +} + +func TestGetReturnsAfterSecond401(t *testing.T) { + var endpointCalls atomic.Int64 + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + endpointCalls.Add(1) + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"message":"Invalid token"}`) + }) + + err := client.get(context.Background(), "/v1/thing", nil, nil) + var authErr *AuthError + if !errors.As(err, &authErr) { + t.Fatalf("got %T (%v), want *AuthError", err, err) + } + // Exactly two: the original and one retry. A loop here would hammer Apple + // with a bad credential. + if got := endpointCalls.Load(); got != 2 { + t.Errorf("endpoint calls: got %d, want 2", got) + } +} + +func TestGetDoesNotRetryQuotaErrors(t *testing.T) { + var calls atomic.Int64 + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusTooManyRequests) + fmt.Fprint(w, `{"message":"Quota exceeded"}`) + }) + + err := client.get(context.Background(), "/v1/thing", nil, nil) + var quotaErr *QuotaError + if !errors.As(err, "aErr) { + t.Fatalf("got %T (%v), want *QuotaError", err, err) + } + if got := calls.Load(); got != 1 { + t.Errorf("calls: got %d, want 1 — a daily quota cannot be waited out mid-request", got) + } +} + +func TestGetRetriesServerErrorsUpToMax(t *testing.T) { + var calls atomic.Int64 + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"message":"boom"}`) + }) + + err := client.get(context.Background(), "/v1/thing", nil, nil) + if err == nil { + t.Fatal("want an error") + } + // One initial attempt plus defaultMaxRetries. + if want := int64(1 + defaultMaxRetries); calls.Load() != want { + t.Errorf("calls: got %d, want %d", calls.Load(), want) + } +} + +func TestGetSucceedsOnRetryAfterServerError(t *testing.T) { + var calls atomic.Int64 + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) == 1 { + w.WriteHeader(http.StatusBadGateway) + return + } + fmt.Fprint(w, `{"ok":true}`) + }) + + var out struct{ OK bool } + if err := client.get(context.Background(), "/v1/thing", nil, &out); err != nil { + t.Fatalf("get: %v", err) + } + if !out.OK { + t.Error("expected success on the second attempt") + } +} + +func TestGetDoesNotRetryClientErrors(t *testing.T) { + var calls atomic.Int64 + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"message":"bad parameter"}`) + }) + + err := client.get(context.Background(), "/v1/thing", nil, nil) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("got %T, want *APIError", err) + } + if apiErr.StatusCode != http.StatusBadRequest { + t.Errorf("status: got %d", apiErr.StatusCode) + } + if got := calls.Load(); got != 1 { + t.Errorf("calls: got %d, want 1 — a bad request will not fix itself", got) + } +} + +func TestMaxRetriesDisabled(t *testing.T) { + var calls atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == tokenPath { + fmt.Fprint(w, `{"accessToken":"t","expiresInSeconds":1800}`) + return + } + calls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + client, err := New(Options{ + TeamID: "T", KeyID: "K", PrivateKey: testKey(t), + BaseURL: srv.URL, MaxRetries: -1, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + if err := client.get(context.Background(), "/v1/thing", nil, nil); err == nil { + t.Fatal("want an error") + } + if got := calls.Load(); got != 1 { + t.Errorf("calls: got %d, want 1 with retries disabled", got) + } +} + +func TestGetHonoursContextCancellationDuringBackoff(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + // Restore real backoff so cancellation has something to interrupt. + client.sleep = sleepContext + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if err := client.get(ctx, "/v1/thing", nil, nil); err == nil { + t.Fatal("want an error for a cancelled context") + } +} + +func TestGetDecodeFailureIsReported(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{not json`) + }) + + var out struct{} + err := client.get(context.Background(), "/v1/thing", nil, &out) + if err == nil { + t.Fatal("want a decode error") + } +} + +func TestGetEncodesQueryParams(t *testing.T) { + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + fmt.Fprint(w, `{}`) + }) + + params := url.Values{} + params.Set("q", "eiffel tower") + setCategories(params, "includePoiCategories", []PoiCategory{PoiCategoryRestaurant, PoiCategoryCafe}) + setStrings(params, "limitToCountries", []string{"US", "CA"}) + params.Set("searchLocation", formatLocation(37.78, -122.42)) + + var out struct{} + if err := client.get(context.Background(), "/v1/search", params, &out); err != nil { + t.Fatalf("get: %v", err) + } + + // Spaces must survive as a value, not be split into extra params. + if got := gotQuery.Get("q"); got != "eiffel tower" { + t.Errorf("q: got %q", got) + } + if got := gotQuery.Get("includePoiCategories"); got != "Restaurant,Cafe" { + t.Errorf("includePoiCategories: got %q, want Restaurant,Cafe", got) + } + if got := gotQuery.Get("limitToCountries"); got != "US,CA" { + t.Errorf("limitToCountries: got %q, want US,CA", got) + } + if got := gotQuery.Get("searchLocation"); got != "37.78,-122.42" { + t.Errorf("searchLocation: got %q, want 37.78,-122.42", got) + } +} + +func TestFormatCoord(t *testing.T) { + tests := []struct { + in float64 + want string + }{ + {37.78, "37.78"}, + {-122.42, "-122.42"}, + {0, "0"}, + {48.85827172505176, "48.85827172505176"}, + // Must not become "1E-07": Apple parses decimal, not scientific notation. + {0.0000001, "0.0000001"}, + {-0.5, "-0.5"}, + } + for _, tc := range tests { + if got := formatCoord(tc.in); got != tc.want { + t.Errorf("formatCoord(%v): got %q, want %q", tc.in, got, tc.want) + } + } +} + +// Apple specifies searchRegion as north,east,south,west. MapRegion's own fields +// are documented as south-west / north-east corners, so the orderings differ and +// a mix-up would silently search the wrong box. +func TestFormatRegionUsesNorthEastSouthWestOrder(t *testing.T) { + got := formatRegion(MapRegion{ + NorthLatitude: 38, + EastLongitude: -122.1, + SouthLatitude: 37.5, + WestLongitude: -122.5, + }) + if want := "38,-122.1,37.5,-122.5"; got != want { + t.Errorf("formatRegion: got %q, want %q", got, want) + } +} + +func TestSetCategoriesAndStringsOmitEmpty(t *testing.T) { + params := url.Values{} + setCategories(params, "includePoiCategories", nil) + setCategories(params, "excludePoiCategories", []PoiCategory{}) + setStrings(params, "limitToCountries", nil) + + if len(params) != 0 { + t.Errorf("empty lists must not add parameters, got %v", params) + } +} + +func TestApplyLangPrefersRequestOverClientDefault(t *testing.T) { + client := &Client{lang: "en-US"} + + t.Run("request value wins", func(t *testing.T) { + params := url.Values{} + client.applyLang(params, "fr-FR") + if got := params.Get("lang"); got != "fr-FR" { + t.Errorf("lang: got %q, want fr-FR", got) + } + }) + + t.Run("falls back to client default", func(t *testing.T) { + params := url.Values{} + client.applyLang(params, "") + if got := params.Get("lang"); got != "en-US" { + t.Errorf("lang: got %q, want en-US", got) + } + }) + + t.Run("omitted when neither is set", func(t *testing.T) { + params := url.Values{} + (&Client{}).applyLang(params, "") + if _, ok := params["lang"]; ok { + t.Error("lang must be absent so Apple applies its own default") + } + }) +} + +func TestNewValidatesCredentials(t *testing.T) { + if _, err := New(Options{KeyID: "K", PrivateKey: testKey(t)}); err == nil { + t.Error("want an error when TeamID is missing") + } +} + +func TestNewAppliesDefaults(t *testing.T) { + client, err := New(Options{TeamID: "T", KeyID: "K", PrivateKey: testKey(t)}) + if err != nil { + t.Fatalf("New: %v", err) + } + if client.baseURL != DefaultBaseURL { + t.Errorf("baseURL: got %q, want %q", client.baseURL, DefaultBaseURL) + } + if client.maxRetries != defaultMaxRetries { + t.Errorf("maxRetries: got %d, want %d", client.maxRetries, defaultMaxRetries) + } + if client.httpClient == nil { + t.Error("httpClient should default to non-nil") + } +} diff --git a/applemaps/directions.go b/applemaps/directions.go new file mode 100644 index 00000000..8a3eb3a7 --- /dev/null +++ b/applemaps/directions.go @@ -0,0 +1,245 @@ +package applemaps + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +const ( + directionsPath = "/v1/directions" + etasPath = "/v1/etas" + + // MaxETADestinations is the number of destinations /v1/etas accepts in one + // call. Enforcing it locally turns what would be an opaque HTTP 400 into a + // clear error, and costs nothing against the quota. + MaxETADestinations = 10 +) + +// formatAppleTime renders a time the way Apple's date parameters require: ISO +// 8601 in UTC, for example 2020-09-15T16:42:00Z. A caller's local zone is +// converted rather than rejected. +func formatAppleTime(t time.Time) string { + return t.UTC().Format(time.RFC3339) +} + +// DirectionsRequest describes a /v1/directions call. +// +// Origin and Destination are each either an address or a "latitude,longitude" +// pair. Use FormatPoint to build the coordinate form. +type DirectionsRequest struct { + // Origin is the starting address or coordinate. Required. + Origin string + // Destination is the ending address or coordinate. Required. + Destination string + // TransportType selects the mode of transportation. + TransportType TransportType + // DepartureDate is the intended departure. Apple accepts either this or + // ArrivalDate, never both. + DepartureDate *time.Time + // ArrivalDate is the intended arrival. Apple accepts either this or + // DepartureDate, never both. + ArrivalDate *time.Time + // Avoid lists features to route around. Tolls is Apple's only value. + Avoid []DirectionsAvoid + // RequestsAlternateRoutes asks for additional routes where available. + RequestsAlternateRoutes bool + // Lang overrides the client's default language, which also localises the + // step instructions. + Lang string + // SearchLocation biases how Origin and Destination are interpreted. + SearchLocation *Location + // SearchRegion biases how Origin and Destination are interpreted. + SearchRegion *MapRegion + // UserLocation is used as a fallback bias when SearchLocation is unset. + UserLocation *Location +} + +// FormatPoint renders a coordinate for the Origin and Destination fields. +func FormatPoint(lat, lng float64) string { + return formatLocation(lat, lng) +} + +func (r DirectionsRequest) validate() error { + if r.Origin == "" { + return errors.New("applemaps: Directions requires Origin") + } + if r.Destination == "" { + return errors.New("applemaps: Directions requires Destination") + } + // Apple documents these as mutually exclusive. Rejecting the combination + // here gives a specific message instead of a generic 400, and saves a call. + if r.DepartureDate != nil && r.ArrivalDate != nil { + return errors.New("applemaps: Directions accepts DepartureDate or ArrivalDate, not both") + } + return nil +} + +func (r DirectionsRequest) params(c *Client) url.Values { + params := url.Values{} + params.Set("origin", r.Origin) + params.Set("destination", r.Destination) + + if r.TransportType != "" { + params.Set("transportType", string(r.TransportType)) + } + if r.DepartureDate != nil { + params.Set("departureDate", formatAppleTime(*r.DepartureDate)) + } + if r.ArrivalDate != nil { + params.Set("arrivalDate", formatAppleTime(*r.ArrivalDate)) + } + if len(r.Avoid) > 0 { + values := make([]string, len(r.Avoid)) + for i, a := range r.Avoid { + values[i] = string(a) + } + setStrings(params, "avoid", values) + } + if r.RequestsAlternateRoutes { + params.Set("requestsAlternateRoutes", "true") + } + c.applyLang(params, r.Lang) + if r.SearchLocation != nil { + params.Set("searchLocation", formatLocation(r.SearchLocation.Latitude, r.SearchLocation.Longitude)) + } + if r.SearchRegion != nil { + params.Set("searchRegion", formatRegion(*r.SearchRegion)) + } + if r.UserLocation != nil { + params.Set("userLocation", formatLocation(r.UserLocation.Latitude, r.UserLocation.Longitude)) + } + return params +} + +// Directions returns routes between two locations. +func (c *Client) Directions(ctx context.Context, req DirectionsRequest) (*DirectionsResponse, error) { + if err := req.validate(); err != nil { + return nil, err + } + + var resp DirectionsResponse + if err := c.get(ctx, directionsPath, req.params(c), &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// ETAsRequest describes a /v1/etas call. +type ETAsRequest struct { + // Origin is the starting coordinate. Required. + Origin Location + // Destinations are the coordinates to estimate arrival at. At least one and + // at most MaxETADestinations. + Destinations []Location + // TransportType selects the mode of transportation. + TransportType TransportType + // DepartureDate is the intended departure. Apple accepts either this or + // ArrivalDate, never both. Omitting both uses the current time. + DepartureDate *time.Time + // ArrivalDate is the intended arrival. + ArrivalDate *time.Time +} + +func (r ETAsRequest) validate() error { + if len(r.Destinations) == 0 { + return errors.New("applemaps: ETAs requires at least one destination") + } + if len(r.Destinations) > MaxETADestinations { + return fmt.Errorf("applemaps: ETAs accepts at most %d destinations, got %d", + MaxETADestinations, len(r.Destinations)) + } + if r.DepartureDate != nil && r.ArrivalDate != nil { + return errors.New("applemaps: ETAs accepts DepartureDate or ArrivalDate, not both") + } + return nil +} + +func (r ETAsRequest) params() url.Values { + params := url.Values{} + params.Set("origin", formatLocation(r.Origin.Latitude, r.Origin.Longitude)) + + // Apple separates ETA destinations with a vertical bar, unlike every other + // list parameter in this API, which uses commas — commas already separate + // each destination's own latitude and longitude. + destinations := make([]string, len(r.Destinations)) + for i, d := range r.Destinations { + destinations[i] = formatLocation(d.Latitude, d.Longitude) + } + params.Set("destinations", strings.Join(destinations, "|")) + + if r.TransportType != "" { + params.Set("transportType", string(r.TransportType)) + } + if r.DepartureDate != nil { + params.Set("departureDate", formatAppleTime(*r.DepartureDate)) + } + if r.ArrivalDate != nil { + params.Set("arrivalDate", formatAppleTime(*r.ArrivalDate)) + } + return params +} + +// ETAs returns estimated travel time and distance from one origin to up to +// MaxETADestinations destinations. +func (c *Client) ETAs(ctx context.Context, req ETAsRequest) ([]Eta, error) { + if err := req.validate(); err != nil { + return nil, err + } + + var resp EtaResponse + if err := c.get(ctx, etasPath, req.params(), &resp); err != nil { + return nil, err + } + return resp.ETAs, nil +} + +// ResolvedStep is a step paired with the polyline it traverses. +type ResolvedStep struct { + // Step is the step itself. + Step Step + // Path is the step's polyline. It is nil when Apple supplied no + // StepPathIndex for the step, which is not an error — the field is optional. + Path []Location +} + +// ResolveRoute returns the steps of one route, each paired with its polyline. +// +// A DirectionsResponse is flattened rather than nested: Steps and StepPaths are +// global across all routes, a route reaches its steps through Route.StepIndexes, +// and a step reaches its path through Step.StepPathIndex. Every one of those +// indexes comes from the network, so indexing with them directly would turn a +// malformed or truncated upstream response into a panic that takes down the +// calling process. This method bounds-checks each one and returns an error +// instead. +func (r *DirectionsResponse) ResolveRoute(routeIndex int) ([]ResolvedStep, error) { + if routeIndex < 0 || routeIndex >= len(r.Routes) { + return nil, fmt.Errorf("applemaps: route index %d out of range (%d routes)", routeIndex, len(r.Routes)) + } + route := r.Routes[routeIndex] + + resolved := make([]ResolvedStep, 0, len(route.StepIndexes)) + for _, stepIndex := range route.StepIndexes { + if stepIndex < 0 || stepIndex >= len(r.Steps) { + return nil, fmt.Errorf("applemaps: route %d references step index %d out of range (%d steps)", + routeIndex, stepIndex, len(r.Steps)) + } + step := r.Steps[stepIndex] + + var path []Location + if step.StepPathIndex != nil { + pathIndex := *step.StepPathIndex + if pathIndex < 0 || pathIndex >= len(r.StepPaths) { + return nil, fmt.Errorf("applemaps: step %d references step path index %d out of range (%d step paths)", + stepIndex, pathIndex, len(r.StepPaths)) + } + path = r.StepPaths[pathIndex] + } + + resolved = append(resolved, ResolvedStep{Step: step, Path: path}) + } + return resolved, nil +} diff --git a/applemaps/directions_test.go b/applemaps/directions_test.go new file mode 100644 index 00000000..eebbe821 --- /dev/null +++ b/applemaps/directions_test.go @@ -0,0 +1,426 @@ +package applemaps + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +func TestDirectionsEncodesParams(t *testing.T) { + var gotPath string + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + fmt.Fprint(w, `{"routes":[]}`) + }) + + departure := time.Date(2026, 9, 15, 16, 42, 0, 0, time.UTC) + _, err := client.Directions(context.Background(), DirectionsRequest{ + Origin: FormatPoint(37.7857, -122.4011), + Destination: "San Francisco City Hall, CA", + TransportType: TransportTypeAutomobile, + DepartureDate: &departure, + Avoid: []DirectionsAvoid{DirectionsAvoidTolls}, + RequestsAlternateRoutes: true, + Lang: "en-US", + SearchLocation: &Location{Latitude: 37.78, Longitude: -122.4}, + }) + if err != nil { + t.Fatalf("Directions: %v", err) + } + + if gotPath != directionsPath { + t.Errorf("path: got %q, want %q", gotPath, directionsPath) + } + checks := map[string]string{ + "origin": "37.7857,-122.4011", + "destination": "San Francisco City Hall, CA", + "transportType": "Automobile", + "departureDate": "2026-09-15T16:42:00Z", + "avoid": "Tolls", + "requestsAlternateRoutes": "true", + "lang": "en-US", + "searchLocation": "37.78,-122.4", + } + for key, want := range checks { + if got := gotQuery.Get(key); got != want { + t.Errorf("%s: got %q, want %q", key, got, want) + } + } + if _, present := gotQuery["arrivalDate"]; present { + t.Error("arrivalDate should be absent when unset") + } +} + +// Apple's date parameters are ISO 8601 in UTC. A caller working in a local zone +// should get a correct conversion, not a rejection or a wrong instant. +func TestDirectionsConvertsNonUTCTimes(t *testing.T) { + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + fmt.Fprint(w, `{"routes":[]}`) + }) + + // 09:42 at UTC-7 is 16:42 UTC. + zone := time.FixedZone("PDT", -7*3600) + arrival := time.Date(2026, 9, 15, 9, 42, 0, 0, zone) + + if _, err := client.Directions(context.Background(), DirectionsRequest{ + Origin: "a", Destination: "b", ArrivalDate: &arrival, + }); err != nil { + t.Fatalf("Directions: %v", err) + } + if got, want := gotQuery.Get("arrivalDate"), "2026-09-15T16:42:00Z"; got != want { + t.Errorf("arrivalDate: got %q, want %q", got, want) + } +} + +func TestDirectionsValidation(t *testing.T) { + now := time.Now() + tests := []struct { + name string + req DirectionsRequest + }{ + {"missing origin", DirectionsRequest{Destination: "b"}}, + {"missing destination", DirectionsRequest{Origin: "a"}}, + { + // Apple accepts one or the other. Catching it locally beats a + // generic 400 and does not spend a quota call. + name: "both dates", + req: DirectionsRequest{Origin: "a", Destination: "b", DepartureDate: &now, ArrivalDate: &now}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request should be sent for an invalid DirectionsRequest") + }) + if _, err := client.Directions(context.Background(), tc.req); err == nil { + t.Error("want a validation error") + } + }) + } +} + +// Every other list parameter in this API is comma-separated; ETA destinations +// are bar-separated, because commas already separate each pair's components. +func TestETAsPipeJoinsDestinations(t *testing.T) { + var gotPath string + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + fmt.Fprint(w, `{"etas":[{"distanceMeters":1200,"expectedTravelTimeSeconds":300,"staticTravelTimeSeconds":280,"transportType":"Automobile","destination":{"latitude":37.32,"longitude":-121.94}}]}`) + }) + + etas, err := client.ETAs(context.Background(), ETAsRequest{ + Origin: Location{Latitude: 37.331423, Longitude: -122.030503}, + Destinations: []Location{ + {Latitude: 37.32556561130194, Longitude: -121.94635203581443}, + {Latitude: 37.44176585512703, Longitude: -122.17259315798667}, + }, + TransportType: TransportTypeAutomobile, + }) + if err != nil { + t.Fatalf("ETAs: %v", err) + } + + if gotPath != etasPath { + t.Errorf("path: got %q, want %q", gotPath, etasPath) + } + if got, want := gotQuery.Get("origin"), "37.331423,-122.030503"; got != want { + t.Errorf("origin: got %q, want %q", got, want) + } + wantDestinations := "37.32556561130194,-121.94635203581443|37.44176585512703,-122.17259315798667" + if got := gotQuery.Get("destinations"); got != wantDestinations { + t.Errorf("destinations: got %q, want %q", got, wantDestinations) + } + + if len(etas) != 1 { + t.Fatalf("etas: got %d, want 1", len(etas)) + } + if etas[0].DistanceMeters == nil || *etas[0].DistanceMeters != 1200 { + t.Errorf("distanceMeters: got %v", etas[0].DistanceMeters) + } + if etas[0].TransportType != TransportTypeAutomobile { + t.Errorf("transportType: got %q", etas[0].TransportType) + } +} + +func TestETAsValidation(t *testing.T) { + now := time.Now() + tooMany := make([]Location, MaxETADestinations+1) + + tests := []struct { + name string + req ETAsRequest + }{ + {"no destinations", ETAsRequest{Origin: Location{}}}, + {"too many destinations", ETAsRequest{Destinations: tooMany}}, + { + name: "both dates", + req: ETAsRequest{ + Destinations: []Location{{}}, + DepartureDate: &now, + ArrivalDate: &now, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request should be sent for an invalid ETAsRequest") + }) + if _, err := client.ETAs(context.Background(), tc.req); err == nil { + t.Error("want a validation error") + } + }) + } + + t.Run("exactly the maximum is allowed", func(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"etas":[]}`) + }) + if _, err := client.ETAs(context.Background(), ETAsRequest{ + Destinations: make([]Location, MaxETADestinations), + }); err != nil { + t.Errorf("ETAs: %v", err) + } + }) +} + +// A realistic flattened response: two routes sharing a global step array, whose +// steps point into a global step-path array. +const twoRouteDirections = `{ + "origin": {"name":"Start","coordinate":{"latitude":37.78,"longitude":-122.40}}, + "destination": {"name":"End","coordinate":{"latitude":37.79,"longitude":-122.41}}, + "routes": [ + {"name":"Fast","distanceMeters":1000,"durationSeconds":300,"hasTolls":true,"stepIndexes":[0,1],"transportType":"Automobile"}, + {"name":"Scenic","distanceMeters":1500,"durationSeconds":500,"hasTolls":false,"stepIndexes":[2],"transportType":"Automobile"} + ], + "steps": [ + {"instructions":"Head north","distanceMeters":400,"stepPathIndex":0}, + {"instructions":"Turn left","distanceMeters":600,"stepPathIndex":1}, + {"instructions":"Take the scenic road","distanceMeters":1500,"stepPathIndex":2} + ], + "stepPaths": [ + [{"latitude":1,"longitude":1},{"latitude":2,"longitude":2}], + [{"latitude":3,"longitude":3}], + [{"latitude":4,"longitude":4},{"latitude":5,"longitude":5},{"latitude":6,"longitude":6}] + ] +}` + +func TestResolveRouteWalksIndexes(t *testing.T) { + var resp DirectionsResponse + if err := json.Unmarshal([]byte(twoRouteDirections), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + + t.Run("first route", func(t *testing.T) { + steps, err := resp.ResolveRoute(0) + if err != nil { + t.Fatalf("ResolveRoute: %v", err) + } + if len(steps) != 2 { + t.Fatalf("steps: got %d, want 2", len(steps)) + } + if steps[0].Step.Instructions != "Head north" { + t.Errorf("first instruction: got %q", steps[0].Step.Instructions) + } + if len(steps[0].Path) != 2 { + t.Errorf("first path: got %d points, want 2", len(steps[0].Path)) + } + if steps[1].Step.Instructions != "Turn left" { + t.Errorf("second instruction: got %q", steps[1].Step.Instructions) + } + if len(steps[1].Path) != 1 { + t.Errorf("second path: got %d points, want 1", len(steps[1].Path)) + } + }) + + t.Run("second route reaches a different step", func(t *testing.T) { + steps, err := resp.ResolveRoute(1) + if err != nil { + t.Fatalf("ResolveRoute: %v", err) + } + if len(steps) != 1 { + t.Fatalf("steps: got %d, want 1", len(steps)) + } + if steps[0].Step.Instructions != "Take the scenic road" { + t.Errorf("instruction: got %q", steps[0].Step.Instructions) + } + if len(steps[0].Path) != 3 { + t.Errorf("path: got %d points, want 3", len(steps[0].Path)) + } + }) +} + +// These are the cases that would panic and take the process down if the indexes +// were trusted. All of them arrive over the network, so none can be assumed +// well-formed. +func TestResolveRouteRejectsOutOfRangeIndexes(t *testing.T) { + tests := []struct { + name string + body string + routeIndex int + wantErrIs string + }{ + { + name: "route index too large", + body: `{"routes":[{"stepIndexes":[0]}],"steps":[{}]}`, + routeIndex: 5, + wantErrIs: "route index 5 out of range", + }, + { + name: "negative route index", + body: `{"routes":[{"stepIndexes":[0]}],"steps":[{}]}`, + routeIndex: -1, + wantErrIs: "route index -1 out of range", + }, + { + name: "no routes at all", + body: `{"routes":[]}`, + routeIndex: 0, + wantErrIs: "route index 0 out of range", + }, + { + name: "step index beyond the steps array", + body: `{"routes":[{"stepIndexes":[0,7]}],"steps":[{"instructions":"only one"}]}`, + routeIndex: 0, + wantErrIs: "step index 7 out of range", + }, + { + name: "negative step index", + body: `{"routes":[{"stepIndexes":[-2]}],"steps":[{}]}`, + routeIndex: 0, + wantErrIs: "step index -2 out of range", + }, + { + name: "steps array missing entirely", + body: `{"routes":[{"stepIndexes":[0]}]}`, + routeIndex: 0, + wantErrIs: "step index 0 out of range", + }, + { + name: "step path index beyond the stepPaths array", + body: `{"routes":[{"stepIndexes":[0]}],"steps":[{"stepPathIndex":9}],"stepPaths":[[]]}`, + routeIndex: 0, + wantErrIs: "step path index 9 out of range", + }, + { + name: "negative step path index", + body: `{"routes":[{"stepIndexes":[0]}],"steps":[{"stepPathIndex":-1}],"stepPaths":[[]]}`, + routeIndex: 0, + wantErrIs: "step path index -1 out of range", + }, + { + name: "stepPaths missing entirely", + body: `{"routes":[{"stepIndexes":[0]}],"steps":[{"stepPathIndex":0}]}`, + routeIndex: 0, + wantErrIs: "step path index 0 out of range", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var resp DirectionsResponse + if err := json.Unmarshal([]byte(tc.body), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + + // An explicit guard: a panic here is the exact failure this test + // exists to prevent, and without recovering it the message would be + // a stack trace rather than a named test failure. + defer func() { + if p := recover(); p != nil { + t.Fatalf("ResolveRoute panicked instead of erroring: %v", p) + } + }() + + _, err := resp.ResolveRoute(tc.routeIndex) + if err == nil { + t.Fatal("want an error") + } + if !strings.Contains(err.Error(), tc.wantErrIs) { + t.Errorf("error: got %q, want it to mention %q", err.Error(), tc.wantErrIs) + } + }) + } +} + +// Apple marks stepPathIndex optional, so its absence is normal rather than +// malformed and must not be an error. +func TestResolveRouteAllowsMissingStepPathIndex(t *testing.T) { + const body = `{"routes":[{"stepIndexes":[0]}],"steps":[{"instructions":"no path"}],"stepPaths":[]}` + var resp DirectionsResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + + steps, err := resp.ResolveRoute(0) + if err != nil { + t.Fatalf("ResolveRoute: %v", err) + } + if len(steps) != 1 { + t.Fatalf("steps: got %d, want 1", len(steps)) + } + if steps[0].Path != nil { + t.Errorf("path: got %v, want nil", steps[0].Path) + } +} + +func TestResolveRouteEmptyStepIndexes(t *testing.T) { + var resp DirectionsResponse + if err := json.Unmarshal([]byte(`{"routes":[{"name":"empty"}],"steps":[{}]}`), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + + steps, err := resp.ResolveRoute(0) + if err != nil { + t.Fatalf("ResolveRoute: %v", err) + } + if len(steps) != 0 { + t.Errorf("steps: got %d, want 0", len(steps)) + } +} + +func TestDirectionsDecodesRouteMetadata(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, twoRouteDirections) + }) + + resp, err := client.Directions(context.Background(), DirectionsRequest{Origin: "a", Destination: "b"}) + if err != nil { + t.Fatalf("Directions: %v", err) + } + + if len(resp.Routes) != 2 { + t.Fatalf("routes: got %d, want 2", len(resp.Routes)) + } + if resp.Origin == nil || resp.Origin.Name != "Start" { + t.Error("origin did not decode") + } + if resp.Destination == nil || resp.Destination.Name != "End" { + t.Error("destination did not decode") + } + + fast := resp.Routes[0] + if fast.HasTolls == nil || !*fast.HasTolls { + t.Error("first route should have tolls") + } + scenic := resp.Routes[1] + if scenic.HasTolls == nil || *scenic.HasTolls { + t.Error("second route should be explicitly toll-free, not undefined") + } + if fast.DistanceMeters == nil || *fast.DistanceMeters != 1000 { + t.Errorf("distanceMeters: got %v", fast.DistanceMeters) + } +} diff --git a/applemaps/errors.go b/applemaps/errors.go index 8fe12fd9..0b7c4a6f 100644 --- a/applemaps/errors.go +++ b/applemaps/errors.go @@ -12,6 +12,46 @@ import ( // response into memory. const maxErrorBodyBytes = 64 << 10 +// ErrorResponse is the body Apple returns with a non-2xx status. +// +// The wire format does not match Apple's published schema. The documented +// ErrorResponse object carries message and details at the top level, but the live +// API nests them under an "error" key: +// +// {"error":{"message":"transportType invalid","details":[]}} +// +// Both forms are accepted here. Decoding only the documented shape silently +// produced empty messages against the real service, which is how this was found. +type ErrorResponse struct { + Message string + Details []string +} + +// UnmarshalJSON accepts either the nested wire format or the flat documented one, +// preferring the nested form when both are somehow present. +func (e *ErrorResponse) UnmarshalJSON(data []byte) error { + var wire struct { + Error *struct { + Message string `json:"message"` + Details []string `json:"details"` + } `json:"error"` + Message string `json:"message"` + Details []string `json:"details"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + + if wire.Error != nil { + e.Message = wire.Error.Message + e.Details = wire.Error.Details + return nil + } + e.Message = wire.Message + e.Details = wire.Details + return nil +} + // APIError is a non-2xx response from the Apple Maps Server API. type APIError struct { StatusCode int diff --git a/applemaps/errors_test.go b/applemaps/errors_test.go new file mode 100644 index 00000000..cb523ccc --- /dev/null +++ b/applemaps/errors_test.go @@ -0,0 +1,153 @@ +package applemaps + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" +) + +// The nested shape is what the live API actually sends, despite Apple's published +// ErrorResponse documenting a flat one. Decoding only the documented form yielded +// empty error messages against the real service. +func TestErrorResponseDecodesNestedWireFormat(t *testing.T) { + const body = `{"error":{"message":"transportType invalid","details":["a","b"]}}` + + var got ErrorResponse + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Message != "transportType invalid" { + t.Errorf("message: got %q, want %q", got.Message, "transportType invalid") + } + if len(got.Details) != 2 { + t.Errorf("details: got %v, want 2 entries", got.Details) + } +} + +func TestErrorResponseDecodesFlatDocumentedFormat(t *testing.T) { + const body = `{"message":"Quota exceeded","details":["daily limit"]}` + + var got ErrorResponse + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Message != "Quota exceeded" { + t.Errorf("message: got %q", got.Message) + } + if len(got.Details) != 1 || got.Details[0] != "daily limit" { + t.Errorf("details: got %v", got.Details) + } +} + +func TestErrorResponseNestedWinsOverFlat(t *testing.T) { + const body = `{"message":"outer","error":{"message":"inner"}}` + + var got ErrorResponse + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Message != "inner" { + t.Errorf("message: got %q, want the nested value", got.Message) + } +} + +func TestErrorResponseEmptyDetailsArray(t *testing.T) { + // Exactly what the live API returned for an invalid transportType. + const body = `{"error":{"message":"transportType invalid","details":[]}}` + + var got ErrorResponse + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Message != "transportType invalid" { + t.Errorf("message: got %q", got.Message) + } + if len(got.Details) != 0 { + t.Errorf("details: got %v, want empty", got.Details) + } +} + +// An APIError's message is what an operator reads in a log line, so it has to +// carry the real reason regardless of which shape Apple used. +func TestNewAPIErrorExtractsNestedMessage(t *testing.T) { + err := newAPIError(http.StatusBadRequest, []byte(`{"error":{"message":"transportType invalid","details":[]}}`)) + + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("got %T, want *APIError", err) + } + if apiErr.Message != "transportType invalid" { + t.Errorf("message: got %q, want the nested message rather than the raw body", apiErr.Message) + } + if !strings.Contains(err.Error(), "transportType invalid") { + t.Errorf("Error(): got %q", err.Error()) + } +} + +func TestNewAPIErrorNestedQuotaAndAuth(t *testing.T) { + t.Run("nested 429 still classifies as quota", func(t *testing.T) { + err := newAPIError(http.StatusTooManyRequests, []byte(`{"error":{"message":"Quota exceeded"}}`)) + var quotaErr *QuotaError + if !errors.As(err, "aErr) { + t.Fatalf("got %T, want *QuotaError", err) + } + if quotaErr.Message != "Quota exceeded" { + t.Errorf("message: got %q", quotaErr.Message) + } + }) + + t.Run("nested 401 still classifies as auth", func(t *testing.T) { + err := newAPIError(http.StatusUnauthorized, []byte(`{"error":{"message":"Invalid token"}}`)) + var authErr *AuthError + if !errors.As(err, &authErr) { + t.Fatalf("got %T, want *AuthError", err) + } + if authErr.Message != "Invalid token" { + t.Errorf("message: got %q", authErr.Message) + } + }) +} + +func TestAPIErrorMessageFormatting(t *testing.T) { + withoutDetails := &APIError{StatusCode: 500, Message: "boom"} + if got, want := withoutDetails.Error(), "applemaps: HTTP 500: boom"; got != want { + t.Errorf("got %q, want %q", got, want) + } + + withDetails := &APIError{StatusCode: 400, Message: "bad", Details: []string{"x", "y"}} + if got, want := withDetails.Error(), "applemaps: HTTP 400: bad (x; y)"; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestNotFoundErrorMessage(t *testing.T) { + err := &NotFoundError{Query: "37.5,-122.5"} + if got, want := err.Error(), `applemaps: no results for "37.5,-122.5"`; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestRetryable(t *testing.T) { + tests := []struct { + status int + want bool + }{ + {http.StatusOK, false}, + {http.StatusBadRequest, false}, + {http.StatusUnauthorized, false}, + // A daily quota cannot be waited out inside one request; retrying only + // spends more of an exhausted budget. + {http.StatusTooManyRequests, false}, + {http.StatusInternalServerError, true}, + {http.StatusBadGateway, true}, + {http.StatusServiceUnavailable, true}, + {http.StatusGatewayTimeout, true}, + } + for _, tc := range tests { + if got := retryable(tc.status); got != tc.want { + t.Errorf("retryable(%d): got %v, want %v", tc.status, got, tc.want) + } + } +} diff --git a/applemaps/geocode.go b/applemaps/geocode.go new file mode 100644 index 00000000..35b398d3 --- /dev/null +++ b/applemaps/geocode.go @@ -0,0 +1,103 @@ +package applemaps + +import ( + "context" + "errors" + "net/url" +) + +const ( + geocodePath = "/v1/geocode" + reverseGeocodePath = "/v1/reverseGeocode" +) + +// GeocodeRequest describes a /v1/geocode call. +// +// SearchLocation, SearchRegion, and UserLocation are hints that bias results +// toward an area. Apple does not treat them as constraints, so a result can lie +// outside them; callers needing a hard geographic bound must filter the results +// themselves. +type GeocodeRequest struct { + // Q is the address to geocode. Required. + Q string + // LimitToCountries is a list of two-letter ISO 3166-1 codes. With two or + // more, Apple returns the best available results for some or all of them + // rather than everything matching in each. + LimitToCountries []string + // Lang overrides the client's default language for this request. + Lang string + // SearchLocation biases results toward a coordinate. + SearchLocation *Location + // SearchRegion biases results toward a bounding box. + SearchRegion *MapRegion + // UserLocation is the user's own coordinate, used as a fallback bias when + // SearchLocation is unset. + UserLocation *Location +} + +func (r GeocodeRequest) params(c *Client) url.Values { + params := url.Values{} + params.Set("q", r.Q) + setStrings(params, "limitToCountries", r.LimitToCountries) + c.applyLang(params, r.Lang) + if r.SearchLocation != nil { + params.Set("searchLocation", formatLocation(r.SearchLocation.Latitude, r.SearchLocation.Longitude)) + } + if r.SearchRegion != nil { + params.Set("searchRegion", formatRegion(*r.SearchRegion)) + } + if r.UserLocation != nil { + params.Set("userLocation", formatLocation(r.UserLocation.Latitude, r.UserLocation.Longitude)) + } + return params +} + +// Geocode resolves an address to one or more places. +// +// An address that matches nothing returns *NotFoundError rather than an empty +// slice, because Apple answers that case with HTTP 200 and an empty results +// array — indistinguishable from success unless it is turned into an error here. +func (c *Client) Geocode(ctx context.Context, req GeocodeRequest) ([]Place, error) { + if req.Q == "" { + return nil, errors.New("applemaps: Geocode requires Q") + } + + var resp PlaceResults + if err := c.get(ctx, geocodePath, req.params(c), &resp); err != nil { + return nil, err + } + if len(resp.Results) == 0 { + return nil, &NotFoundError{Query: req.Q} + } + return resp.Results, nil +} + +// ReverseGeocodeRequest describes a /v1/reverseGeocode call. Apple accepts only +// a coordinate and a language for this endpoint — none of the bias parameters +// apply. +type ReverseGeocodeRequest struct { + Latitude float64 + Longitude float64 + // Lang overrides the client's default language for this request. + Lang string +} + +// ReverseGeocode resolves a coordinate to one or more addresses. +// +// A coordinate that matches nothing — mid-ocean, for instance — returns +// *NotFoundError, for the same reason as Geocode. +func (c *Client) ReverseGeocode(ctx context.Context, req ReverseGeocodeRequest) ([]Place, error) { + params := url.Values{} + loc := formatLocation(req.Latitude, req.Longitude) + params.Set("loc", loc) + c.applyLang(params, req.Lang) + + var resp PlaceResults + if err := c.get(ctx, reverseGeocodePath, params, &resp); err != nil { + return nil, err + } + if len(resp.Results) == 0 { + return nil, &NotFoundError{Query: loc} + } + return resp.Results, nil +} diff --git a/applemaps/geocode_test.go b/applemaps/geocode_test.go new file mode 100644 index 00000000..0582048d --- /dev/null +++ b/applemaps/geocode_test.go @@ -0,0 +1,180 @@ +package applemaps + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "testing" +) + +func TestGeocodeEncodesParams(t *testing.T) { + var gotPath string + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + fmt.Fprint(w, whiteHouseGeocodeResponse) + }) + + places, err := client.Geocode(context.Background(), GeocodeRequest{ + Q: "1600 Pennsylvania Ave NW", + LimitToCountries: []string{"US", "CA"}, + Lang: "en-GB", + SearchLocation: &Location{Latitude: 38.9, Longitude: -77.03}, + SearchRegion: &MapRegion{NorthLatitude: 39, EastLongitude: -77, SouthLatitude: 38, WestLongitude: -78}, + UserLocation: &Location{Latitude: 40.7, Longitude: -74}, + }) + if err != nil { + t.Fatalf("Geocode: %v", err) + } + + if gotPath != geocodePath { + t.Errorf("path: got %q, want %q", gotPath, geocodePath) + } + checks := map[string]string{ + "q": "1600 Pennsylvania Ave NW", + "limitToCountries": "US,CA", + "lang": "en-GB", + "searchLocation": "38.9,-77.03", + "searchRegion": "39,-77,38,-78", + "userLocation": "40.7,-74", + } + for key, want := range checks { + if got := gotQuery.Get(key); got != want { + t.Errorf("%s: got %q, want %q", key, got, want) + } + } + + if len(places) != 1 { + t.Fatalf("places: got %d, want 1", len(places)) + } + if places[0].Coordinate.Latitude != 38.8976635 { + t.Errorf("latitude: got %v", places[0].Coordinate.Latitude) + } +} + +func TestGeocodeOmitsUnsetOptionalParams(t *testing.T) { + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + fmt.Fprint(w, whiteHouseGeocodeResponse) + }) + + if _, err := client.Geocode(context.Background(), GeocodeRequest{Q: "somewhere"}); err != nil { + t.Fatalf("Geocode: %v", err) + } + + for _, key := range []string{"limitToCountries", "searchLocation", "searchRegion", "userLocation", "lang"} { + if _, present := gotQuery[key]; present { + t.Errorf("%s should be absent when unset, got %q", key, gotQuery.Get(key)) + } + } +} + +// Apple answers an unresolvable address with HTTP 200 and an empty array. Left +// as-is that is indistinguishable from a successful lookup, so it becomes an +// error here. +func TestGeocodeEmptyResultsIsNotFound(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"results":[]}`) + }) + + _, err := client.Geocode(context.Background(), GeocodeRequest{Q: "nowhere at all"}) + var notFound *NotFoundError + if !errors.As(err, ¬Found) { + t.Fatalf("got %T (%v), want *NotFoundError", err, err) + } + if notFound.Query != "nowhere at all" { + t.Errorf("query: got %q", notFound.Query) + } +} + +func TestGeocodeRequiresQ(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request should be sent without Q") + }) + if _, err := client.Geocode(context.Background(), GeocodeRequest{}); err == nil { + t.Error("want an error when Q is empty") + } +} + +func TestReverseGeocode(t *testing.T) { + var gotPath string + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + fmt.Fprint(w, whiteHouseGeocodeResponse) + }) + + places, err := client.ReverseGeocode(context.Background(), ReverseGeocodeRequest{ + Latitude: 37.3316851, + Longitude: -122.0300674, + Lang: "fr-FR", + }) + if err != nil { + t.Fatalf("ReverseGeocode: %v", err) + } + + if gotPath != reverseGeocodePath { + t.Errorf("path: got %q, want %q", gotPath, reverseGeocodePath) + } + if got, want := gotQuery.Get("loc"), "37.3316851,-122.0300674"; got != want { + t.Errorf("loc: got %q, want %q", got, want) + } + if got := gotQuery.Get("lang"); got != "fr-FR" { + t.Errorf("lang: got %q", got) + } + if len(places) != 1 { + t.Errorf("places: got %d, want 1", len(places)) + } +} + +func TestReverseGeocodeEmptyResultsIsNotFound(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"results":[]}`) + }) + + // Mid-Pacific: a real coordinate that resolves to no address. + _, err := client.ReverseGeocode(context.Background(), ReverseGeocodeRequest{Latitude: 0, Longitude: -160}) + var notFound *NotFoundError + if !errors.As(err, ¬Found) { + t.Fatalf("got %T (%v), want *NotFoundError", err, err) + } + if notFound.Query != "0,-160" { + t.Errorf("query: got %q, want the coordinate", notFound.Query) + } +} + +func TestReverseGeocodeSendsOnlyLocAndLang(t *testing.T) { + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + fmt.Fprint(w, whiteHouseGeocodeResponse) + }) + + if _, err := client.ReverseGeocode(context.Background(), ReverseGeocodeRequest{Latitude: 1, Longitude: 2}); err != nil { + t.Fatalf("ReverseGeocode: %v", err) + } + + // Apple documents no bias parameters for this endpoint; sending them would + // be silently ignored at best. + if len(gotQuery) != 1 { + t.Errorf("want only loc, got %v", gotQuery) + } +} + +func TestGeocodePropagatesAPIErrors(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + fmt.Fprint(w, `{"message":"Quota exceeded"}`) + }) + + _, err := client.Geocode(context.Background(), GeocodeRequest{Q: "anywhere"}) + var quotaErr *QuotaError + if !errors.As(err, "aErr) { + t.Fatalf("got %T (%v), want *QuotaError", err, err) + } +} diff --git a/applemaps/package_test.go b/applemaps/package_test.go new file mode 100644 index 00000000..5ac3de54 --- /dev/null +++ b/applemaps/package_test.go @@ -0,0 +1,47 @@ +package applemaps + +import ( + "go/parser" + "go/token" + "strings" + "testing" +) + +// The package is meant to be liftable into its own module without a rewrite, so +// it must not reach back into this repository — not from its source files and not +// from its tests, since a test-only dependency would break extraction just as +// surely. +// +// This is checked mechanically rather than by convention because the failure is +// silent: an accidental POI or iowrappers import compiles fine and only shows up +// as pain much later, when someone tries to move the package. +func TestPackageDoesNotImportTheHostRepository(t *testing.T) { + const modulePath = "github.com/weihesdlegend/Vacation-planner" + + fset := token.NewFileSet() + packages, err := parser.ParseDir(fset, ".", nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse package directory: %v", err) + } + if len(packages) == 0 { + t.Fatal("parsed no packages; the test is not looking at the right directory") + } + + filesChecked := 0 + for _, pkg := range packages { + for filename, file := range pkg.Files { + filesChecked++ + for _, imported := range file.Imports { + path := strings.Trim(imported.Path.Value, `"`) + if strings.HasPrefix(path, modulePath) { + t.Errorf("%s imports %q; applemaps must stay free of repository dependencies", filename, path) + } + } + } + } + + // Guard against the check silently passing because nothing was parsed. + if filesChecked < 5 { + t.Errorf("only %d files checked, expected the whole package", filesChecked) + } +} diff --git a/applemaps/place.go b/applemaps/place.go new file mode 100644 index 00000000..9305fd51 --- /dev/null +++ b/applemaps/place.go @@ -0,0 +1,76 @@ +package applemaps + +import ( + "context" + "errors" + "net/url" +) + +const ( + placePath = "/v1/place" + alternateIDsPath = "/v1/place/alternateIds" +) + +// Place looks up a single place by its Apple place ID. +// +// The lookup returns no more data than a search result does: Apple's Place object +// carries no opening hours, rating, price level, or photo at any endpoint, so +// this is a way to refresh or resolve an ID rather than to enrich a place. +func (c *Client) Place(ctx context.Context, id, lang string) (*Place, error) { + if id == "" { + return nil, errors.New("applemaps: Place requires an id") + } + + params := url.Values{} + c.applyLang(params, lang) + + // Apple place IDs are opaque, so they can contain characters that would + // otherwise change the path's structure. PathEscape keeps an id with a slash + // or a question mark from being read as extra path segments or a query. + var resp Place + if err := c.get(ctx, placePath+"/"+url.PathEscape(id), params, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// Places looks up several places in one call. +// +// The response can partially succeed: PlacesResponse carries both Results and +// Errors, and a populated Errors does not mean Results is empty. Both are +// returned so a caller can act on the good records and still see which IDs +// failed — dropping either half silently loses information. +func (c *Client) Places(ctx context.Context, ids []string, lang string) (*PlacesResponse, error) { + if len(ids) == 0 { + return nil, errors.New("applemaps: Places requires at least one id") + } + + params := url.Values{} + setStrings(params, "ids", ids) + c.applyLang(params, lang) + + var resp PlacesResponse + if err := c.get(ctx, placePath, params, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// AlternateIDs returns the alternate place IDs for one or more place IDs. +// +// Apple place IDs are not stable forever; an ID that stops resolving may have an +// alternate that still does. Like Places, this can partially succeed. +func (c *Client) AlternateIDs(ctx context.Context, ids []string) (*AlternateIDsResponse, error) { + if len(ids) == 0 { + return nil, errors.New("applemaps: AlternateIDs requires at least one id") + } + + params := url.Values{} + setStrings(params, "ids", ids) + + var resp AlternateIDsResponse + if err := c.get(ctx, alternateIDsPath, params, &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/applemaps/place_test.go b/applemaps/place_test.go new file mode 100644 index 00000000..c682aff5 --- /dev/null +++ b/applemaps/place_test.go @@ -0,0 +1,200 @@ +package applemaps + +import ( + "context" + "fmt" + "net/http" + "net/url" + "testing" +) + +func TestPlaceLookupByID(t *testing.T) { + var gotPath string + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + fmt.Fprint(w, `{"id":"ABC123","name":"Somewhere","coordinate":{"latitude":1,"longitude":2}}`) + }) + + place, err := client.Place(context.Background(), "ABC123", "en-US") + if err != nil { + t.Fatalf("Place: %v", err) + } + + if want := placePath + "/ABC123"; gotPath != want { + t.Errorf("path: got %q, want %q", gotPath, want) + } + if got := gotQuery.Get("lang"); got != "en-US" { + t.Errorf("lang: got %q", got) + } + if place.ID != "ABC123" || place.Name != "Somewhere" { + t.Errorf("place: got %+v", place) + } +} + +// Apple place IDs are opaque. An unescaped slash or question mark would be read +// as extra path segments or as the start of a query, silently requesting +// something else entirely. +func TestPlaceEscapesIDInPath(t *testing.T) { + tests := []struct { + name string + id string + }{ + {"slash", "abc/def"}, + {"question mark", "abc?def"}, + {"hash", "abc#def"}, + {"space", "abc def"}, + {"percent", "abc%def"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var gotRawPath, gotEscaped string + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + // r.URL.Path is already decoded, so a correctly escaped id round + // trips back to its original form as a single segment. + gotRawPath = r.URL.Path + gotEscaped = r.URL.EscapedPath() + fmt.Fprint(w, `{"id":"x"}`) + }) + + if _, err := client.Place(context.Background(), tc.id, ""); err != nil { + t.Fatalf("Place: %v", err) + } + + if want := placePath + "/" + tc.id; gotRawPath != want { + t.Errorf("decoded path: got %q, want %q (escaped form was %q)", gotRawPath, want, gotEscaped) + } + }) + } +} + +func TestPlaceRequiresID(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request should be sent without an id") + }) + if _, err := client.Place(context.Background(), "", ""); err == nil { + t.Error("want an error for an empty id") + } +} + +func TestPlacesJoinsIDs(t *testing.T) { + var gotPath string + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + fmt.Fprint(w, `{"results":[{"id":"a"},{"id":"b"}]}`) + }) + + resp, err := client.Places(context.Background(), []string{"a", "b", "c"}, "") + if err != nil { + t.Fatalf("Places: %v", err) + } + + if gotPath != placePath { + t.Errorf("path: got %q, want %q", gotPath, placePath) + } + if got, want := gotQuery.Get("ids"), "a,b,c"; got != want { + t.Errorf("ids: got %q, want %q", got, want) + } + if len(resp.Results) != 2 { + t.Errorf("results: got %d, want 2", len(resp.Results)) + } +} + +// A batch lookup where some IDs fail is not a failed request. Both halves must +// reach the caller — dropping the errors hides which IDs are dead, and dropping +// the results throws away good data. +func TestPlacesSurfacesPartialSuccess(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{ + "results":[{"id":"good","name":"Real Place"}], + "errors":[{"id":"missing","errorCode":"NOT_FOUND"},{"id":"junk","errorCode":"MALFORMED"}] + }`) + }) + + resp, err := client.Places(context.Background(), []string{"good", "missing", "junk"}, "") + if err != nil { + t.Fatalf("Places: %v", err) + } + if len(resp.Results) != 1 { + t.Errorf("results: got %d, want 1", len(resp.Results)) + } + if len(resp.Errors) != 2 { + t.Fatalf("errors: got %d, want 2", len(resp.Errors)) + } + if resp.Errors[0].ID != "missing" || resp.Errors[0].ErrorCode != "NOT_FOUND" { + t.Errorf("first error: got %+v", resp.Errors[0]) + } +} + +func TestPlacesRequiresIDs(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request should be sent without ids") + }) + if _, err := client.Places(context.Background(), nil, ""); err == nil { + t.Error("want an error for no ids") + } + if _, err := client.Places(context.Background(), []string{}, ""); err == nil { + t.Error("want an error for an empty id slice") + } +} + +func TestAlternateIDs(t *testing.T) { + var gotPath string + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + fmt.Fprint(w, `{ + "results":[{"id":"a","alternateIds":["a1","a2"]}], + "errors":[{"id":"b","errorCode":"NOT_FOUND"}] + }`) + }) + + resp, err := client.AlternateIDs(context.Background(), []string{"a", "b"}) + if err != nil { + t.Fatalf("AlternateIDs: %v", err) + } + + if gotPath != alternateIDsPath { + t.Errorf("path: got %q, want %q", gotPath, alternateIDsPath) + } + if got, want := gotQuery.Get("ids"), "a,b"; got != want { + t.Errorf("ids: got %q, want %q", got, want) + } + if len(resp.Results) != 1 || len(resp.Results[0].AlternateIDs) != 2 { + t.Errorf("results: got %+v", resp.Results) + } + if len(resp.Errors) != 1 { + t.Errorf("errors: got %d, want 1", len(resp.Errors)) + } +} + +// AlternateIDs takes no lang parameter; sending one would be noise. +func TestAlternateIDsSendsOnlyIDs(t *testing.T) { + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + fmt.Fprint(w, `{"results":[]}`) + }) + client.lang = "en-US" + + if _, err := client.AlternateIDs(context.Background(), []string{"a"}); err != nil { + t.Fatalf("AlternateIDs: %v", err) + } + if len(gotQuery) != 1 { + t.Errorf("want only ids, got %v", gotQuery) + } +} + +func TestAlternateIDsRequiresIDs(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request should be sent without ids") + }) + if _, err := client.AlternateIDs(context.Background(), nil); err == nil { + t.Error("want an error for no ids") + } +} diff --git a/applemaps/search.go b/applemaps/search.go new file mode 100644 index 00000000..a84e9f61 --- /dev/null +++ b/applemaps/search.go @@ -0,0 +1,272 @@ +package applemaps + +import ( + "context" + "errors" + "fmt" + "net/url" +) + +const ( + searchPath = "/v1/search" + searchAutocompletePath = "/v1/searchAutocomplete" + + // DefaultMaxSearchPages bounds SearchAll when a caller passes no explicit + // limit. Each page is a billable call against the daily quota, so an + // unbounded walk of a broad query could spend a large share of it on one + // request. + DefaultMaxSearchPages = 5 +) + +// SearchRequest describes a /v1/search call. +// +// Apple requires Q: there is no way to search purely by category or by area. +// SearchLocation and SearchRegion only bias results and do not constrain them, +// and there is no radius parameter and no result limit. A caller that needs +// results within a fixed distance must filter them after the fact. +type SearchRequest struct { + // Q is the place to search for. Required. + Q string + // IncludePoiCategories restricts results to these categories. Apple's + // taxonomy is coarse — all general retail is PoiCategoryStore — so this + // narrows far less than it appears to. Carry fine distinctions in Q. + IncludePoiCategories []PoiCategory + // ExcludePoiCategories removes these categories from results. + ExcludePoiCategories []PoiCategory + // LimitToCountries is a list of two-letter ISO 3166-1 codes. + LimitToCountries []string + // ResultTypeFilter restricts which kinds of result come back. + ResultTypeFilter []SearchResultType + // IncludeAddressCategories requires SearchResultTypeAddress in + // ResultTypeFilter; Apple rejects it otherwise. + IncludeAddressCategories []AddressCategory + // ExcludeAddressCategories carries the same requirement. + ExcludeAddressCategories []AddressCategory + // Lang overrides the client's default language for this request. + Lang string + // SearchLocation biases results toward a coordinate. + SearchLocation *Location + // SearchRegion biases results toward a bounding box. + SearchRegion *MapRegion + // UserLocation is used as a fallback bias when SearchLocation is unset. + UserLocation *Location + // SearchRegionPriority indicates how strongly to weight SearchRegion. + SearchRegionPriority string + // EnablePagination asks Apple to return paginated results. SearchAll sets + // this itself. + EnablePagination bool + // PageToken requests a specific page. SearchAll manages this itself. + PageToken string +} + +func (r SearchRequest) params(c *Client) url.Values { + params := url.Values{} + params.Set("q", r.Q) + setCategories(params, "includePoiCategories", r.IncludePoiCategories) + setCategories(params, "excludePoiCategories", r.ExcludePoiCategories) + setStrings(params, "limitToCountries", r.LimitToCountries) + + if len(r.ResultTypeFilter) > 0 { + values := make([]string, len(r.ResultTypeFilter)) + for i, t := range r.ResultTypeFilter { + values[i] = string(t) + } + setStrings(params, "resultTypeFilter", values) + } + setAddressCategories(params, "includeAddressCategories", r.IncludeAddressCategories) + setAddressCategories(params, "excludeAddressCategories", r.ExcludeAddressCategories) + + c.applyLang(params, r.Lang) + if r.SearchLocation != nil { + params.Set("searchLocation", formatLocation(r.SearchLocation.Latitude, r.SearchLocation.Longitude)) + } + if r.SearchRegion != nil { + params.Set("searchRegion", formatRegion(*r.SearchRegion)) + } + if r.UserLocation != nil { + params.Set("userLocation", formatLocation(r.UserLocation.Latitude, r.UserLocation.Longitude)) + } + if r.SearchRegionPriority != "" { + params.Set("searchRegionPriority", r.SearchRegionPriority) + } + if r.EnablePagination { + params.Set("enablePagination", "true") + } + if r.PageToken != "" { + params.Set("pageToken", r.PageToken) + } + return params +} + +func setAddressCategories(params url.Values, key string, categories []AddressCategory) { + if len(categories) == 0 { + return + } + values := make([]string, len(categories)) + for i, c := range categories { + values[i] = string(c) + } + setStrings(params, key, values) +} + +// Search returns one page of results. +// +// Unlike Geocode, an empty result set is not an error: a search for places of a +// kind that genuinely do not exist nearby is a valid, informative answer, and +// callers scanning an area need to distinguish it from a failure. +func (c *Client) Search(ctx context.Context, req SearchRequest) (*SearchResponse, error) { + if req.Q == "" { + return nil, errors.New("applemaps: Search requires Q") + } + + var resp SearchResponse + if err := c.get(ctx, searchPath, req.params(c), &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// SearchAllResult is the outcome of a paginated search. +type SearchAllResult struct { + // Places is every result across the pages fetched. + Places []SearchPlace + // Pages is how many pages were fetched. + Pages int + // Truncated reports that the page limit was reached while Apple was still + // offering a next page, so Places is incomplete. Callers that care about + // completeness must check this — silently returning a short list would read + // as "that is all there is". + Truncated bool + // DisplayMapRegion is the region from the first page. + DisplayMapRegion *SearchMapRegion +} + +// SearchAll walks a paginated search and accumulates every result. +// +// maxPages bounds the walk; zero or negative means DefaultMaxSearchPages. This +// function owns pagination and nothing else — it applies no distance filter and +// no ranking, because Apple offers no radius parameter and any such policy +// belongs to the caller rather than to a transport client. +func (c *Client) SearchAll(ctx context.Context, req SearchRequest, maxPages int) (*SearchAllResult, error) { + if req.Q == "" { + return nil, errors.New("applemaps: SearchAll requires Q") + } + if maxPages <= 0 { + maxPages = DefaultMaxSearchPages + } + + req.EnablePagination = true + result := &SearchAllResult{} + + for { + page, err := c.Search(ctx, req) + if err != nil { + // A failure partway through still returns what was gathered, so a + // caller can decide between using a partial result and discarding + // it. Losing four good pages to one transient error would be worse. + if result.Pages > 0 { + return result, fmt.Errorf("applemaps: search page %d: %w", result.Pages+1, err) + } + return nil, err + } + + result.Pages++ + result.Places = append(result.Places, page.Results...) + if result.Pages == 1 { + result.DisplayMapRegion = page.DisplayMapRegion + } + + next := "" + if page.PaginationInfo != nil { + next = page.PaginationInfo.NextPageToken + } + if next == "" { + return result, nil + } + if result.Pages >= maxPages { + result.Truncated = true + return result, nil + } + req.PageToken = next + } +} + +// SearchAutocompleteRequest describes a /v1/searchAutocomplete call. +// +// ResultTypeFilter uses SearchACResultType rather than SearchResultType: this +// endpoint has no address member. +type SearchAutocompleteRequest struct { + // Q is the partial query to complete. Required. + Q string + // IncludePoiCategories restricts suggestions to these categories. + IncludePoiCategories []PoiCategory + // ExcludePoiCategories removes these categories from suggestions. + ExcludePoiCategories []PoiCategory + // LimitToCountries is a list of two-letter ISO 3166-1 codes. + LimitToCountries []string + // ResultTypeFilter restricts which kinds of suggestion come back. + ResultTypeFilter []SearchACResultType + // IncludeAddressCategories requires an address result type filter. + IncludeAddressCategories []AddressCategory + // ExcludeAddressCategories carries the same requirement. + ExcludeAddressCategories []AddressCategory + // Lang overrides the client's default language. It must be set here rather + // than appended to a returned CompletionURL, which Apple resolves in the + // language of the original request. + Lang string + // SearchLocation biases suggestions toward a coordinate. + SearchLocation *Location + // SearchRegion biases suggestions toward a bounding box. + SearchRegion *MapRegion + // UserLocation is used as a fallback bias when SearchLocation is unset. + UserLocation *Location + // SearchRegionPriority indicates how strongly to weight SearchRegion. + SearchRegionPriority string +} + +func (r SearchAutocompleteRequest) params(c *Client) url.Values { + params := url.Values{} + params.Set("q", r.Q) + setCategories(params, "includePoiCategories", r.IncludePoiCategories) + setCategories(params, "excludePoiCategories", r.ExcludePoiCategories) + setStrings(params, "limitToCountries", r.LimitToCountries) + + if len(r.ResultTypeFilter) > 0 { + values := make([]string, len(r.ResultTypeFilter)) + for i, t := range r.ResultTypeFilter { + values[i] = string(t) + } + setStrings(params, "resultTypeFilter", values) + } + setAddressCategories(params, "includeAddressCategories", r.IncludeAddressCategories) + setAddressCategories(params, "excludeAddressCategories", r.ExcludeAddressCategories) + + c.applyLang(params, r.Lang) + if r.SearchLocation != nil { + params.Set("searchLocation", formatLocation(r.SearchLocation.Latitude, r.SearchLocation.Longitude)) + } + if r.SearchRegion != nil { + params.Set("searchRegion", formatRegion(*r.SearchRegion)) + } + if r.UserLocation != nil { + params.Set("userLocation", formatLocation(r.UserLocation.Latitude, r.UserLocation.Longitude)) + } + if r.SearchRegionPriority != "" { + params.Set("searchRegionPriority", r.SearchRegionPriority) + } + return params +} + +// SearchAutocomplete returns suggestions for a partial query. An empty result +// set is not an error, for the same reason as Search. +func (c *Client) SearchAutocomplete(ctx context.Context, req SearchAutocompleteRequest) ([]AutocompleteResult, error) { + if req.Q == "" { + return nil, errors.New("applemaps: SearchAutocomplete requires Q") + } + + var resp SearchAutocompleteResponse + if err := c.get(ctx, searchAutocompletePath, req.params(c), &resp); err != nil { + return nil, err + } + return resp.Results, nil +} diff --git a/applemaps/search_test.go b/applemaps/search_test.go new file mode 100644 index 00000000..a9c10cb7 --- /dev/null +++ b/applemaps/search_test.go @@ -0,0 +1,336 @@ +package applemaps + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "sync/atomic" + "testing" +) + +func TestSearchEncodesParams(t *testing.T) { + var gotPath string + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + fmt.Fprint(w, eiffelTowerSearchResponse) + }) + + resp, err := client.Search(context.Background(), SearchRequest{ + Q: "supermarket", + IncludePoiCategories: []PoiCategory{PoiCategoryFoodMarket, PoiCategoryStore}, + ExcludePoiCategories: []PoiCategory{PoiCategoryGasStation}, + LimitToCountries: []string{"US"}, + ResultTypeFilter: []SearchResultType{SearchResultTypePoi, SearchResultTypeAddress}, + IncludeAddressCategories: []AddressCategory{AddressCategoryLocality, AddressCategoryPostalCode}, + ExcludeAddressCategories: []AddressCategory{AddressCategoryCountry}, + SearchLocation: &Location{Latitude: 37.78, Longitude: -122.42}, + SearchRegion: &MapRegion{NorthLatitude: 38, EastLongitude: -122.1, SouthLatitude: 37.5, WestLongitude: -122.5}, + UserLocation: &Location{Latitude: 37.7, Longitude: -122.4}, + SearchRegionPriority: "required", + }) + if err != nil { + t.Fatalf("Search: %v", err) + } + + if gotPath != searchPath { + t.Errorf("path: got %q, want %q", gotPath, searchPath) + } + checks := map[string]string{ + "q": "supermarket", + "includePoiCategories": "FoodMarket,Store", + "excludePoiCategories": "GasStation", + "limitToCountries": "US", + "resultTypeFilter": "poi,address", + "includeAddressCategories": "Locality,PostalCode", + "excludeAddressCategories": "Country", + "searchLocation": "37.78,-122.42", + "searchRegion": "38,-122.1,37.5,-122.5", + "userLocation": "37.7,-122.4", + "searchRegionPriority": "required", + } + for key, want := range checks { + if got := gotQuery.Get(key); got != want { + t.Errorf("%s: got %q, want %q", key, got, want) + } + } + + // Search must not set pagination on its own; only SearchAll does. + if _, present := gotQuery["enablePagination"]; present { + t.Error("enablePagination should be absent unless requested") + } + + if len(resp.Results) != 1 { + t.Fatalf("results: got %d, want 1", len(resp.Results)) + } + if resp.Results[0].PoiCategory != PoiCategoryLandmark { + t.Errorf("poiCategory: got %q, want Landmark", resp.Results[0].PoiCategory) + } +} + +// An area with none of the requested kind of place is a real answer, not a +// failure. Callers scanning a grid need to tell that apart from an error. +func TestSearchEmptyResultsIsNotAnError(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"results":[]}`) + }) + + resp, err := client.Search(context.Background(), SearchRequest{Q: "ski slope"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(resp.Results) != 0 { + t.Errorf("results: got %d, want 0", len(resp.Results)) + } +} + +func TestSearchRequiresQ(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request should be sent without Q") + }) + if _, err := client.Search(context.Background(), SearchRequest{}); err == nil { + t.Error("want an error when Q is empty") + } + if _, err := client.SearchAll(context.Background(), SearchRequest{}, 3); err == nil { + t.Error("SearchAll should also require Q") + } +} + +// pagedSearchServer serves numbered pages, handing out a next token until the +// last one. +func pagedSearchServer(t *testing.T, totalPages int) (*Client, *atomic.Int64, *[]string) { + t.Helper() + var calls atomic.Int64 + tokensSeen := make([]string, 0, totalPages) + + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + n := int(calls.Add(1)) + tokensSeen = append(tokensSeen, r.URL.Query().Get("pageToken")) + + next := "" + if n < totalPages { + next = fmt.Sprintf("token-%d", n+1) + } + fmt.Fprintf(w, `{ + "displayMapRegion":{"northLatitude":1,"eastLongitude":2,"southLatitude":3,"westLongitude":4}, + "results":[{"name":"place-%d","coordinate":{"latitude":1,"longitude":2}}], + "paginationInfo":{"nextPageToken":%q,"totalPageCount":%d,"totalResults":%d} + }`, n, next, totalPages, totalPages) + }) + return client, &calls, &tokensSeen +} + +func TestSearchAllFollowsPagination(t *testing.T) { + client, calls, tokensSeen := pagedSearchServer(t, 3) + + result, err := client.SearchAll(context.Background(), SearchRequest{Q: "cafe"}, 10) + if err != nil { + t.Fatalf("SearchAll: %v", err) + } + + if got := calls.Load(); got != 3 { + t.Errorf("requests: got %d, want 3", got) + } + if result.Pages != 3 { + t.Errorf("pages: got %d, want 3", result.Pages) + } + if len(result.Places) != 3 { + t.Fatalf("places: got %d, want 3", len(result.Places)) + } + if result.Truncated { + t.Error("truncated should be false when pagination ran to completion") + } + // The first page carries no token; each later page must send the token the + // previous response supplied. + want := []string{"", "token-2", "token-3"} + for i, w := range want { + if (*tokensSeen)[i] != w { + t.Errorf("page %d pageToken: got %q, want %q", i+1, (*tokensSeen)[i], w) + } + } + if result.DisplayMapRegion == nil || result.DisplayMapRegion.NorthLatitude != 1 { + t.Error("displayMapRegion should come from the first page") + } +} + +func TestSearchAllSetsEnablePagination(t *testing.T) { + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + fmt.Fprint(w, `{"results":[]}`) + }) + + if _, err := client.SearchAll(context.Background(), SearchRequest{Q: "cafe"}, 3); err != nil { + t.Fatalf("SearchAll: %v", err) + } + if got := gotQuery.Get("enablePagination"); got != "true" { + t.Errorf("enablePagination: got %q, want true", got) + } +} + +func TestSearchAllStopsWhenNoNextToken(t *testing.T) { + client, calls, _ := pagedSearchServer(t, 1) + + result, err := client.SearchAll(context.Background(), SearchRequest{Q: "cafe"}, 10) + if err != nil { + t.Fatalf("SearchAll: %v", err) + } + if got := calls.Load(); got != 1 { + t.Errorf("requests: got %d, want 1", got) + } + if result.Truncated { + t.Error("truncated should be false") + } +} + +// Silently returning a short list reads as "that is all there is". Truncation +// must be visible. +func TestSearchAllReportsTruncation(t *testing.T) { + client, calls, _ := pagedSearchServer(t, 10) + + result, err := client.SearchAll(context.Background(), SearchRequest{Q: "cafe"}, 3) + if err != nil { + t.Fatalf("SearchAll: %v", err) + } + if got := calls.Load(); got != 3 { + t.Errorf("requests: got %d, want 3 — the page cap must be honoured", got) + } + if !result.Truncated { + t.Error("truncated should be true when the cap was hit with pages remaining") + } + if len(result.Places) != 3 { + t.Errorf("places: got %d, want 3", len(result.Places)) + } +} + +func TestSearchAllDefaultsPageCap(t *testing.T) { + client, calls, _ := pagedSearchServer(t, 100) + + result, err := client.SearchAll(context.Background(), SearchRequest{Q: "cafe"}, 0) + if err != nil { + t.Fatalf("SearchAll: %v", err) + } + if got := calls.Load(); got != int64(DefaultMaxSearchPages) { + t.Errorf("requests: got %d, want %d", got, DefaultMaxSearchPages) + } + if !result.Truncated { + t.Error("truncated should be true") + } +} + +// Losing several good pages because the next one failed would be worse than +// handing back a partial result and saying so. +func TestSearchAllReturnsPartialResultOnLaterPageFailure(t *testing.T) { + var calls atomic.Int64 + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) == 1 { + fmt.Fprint(w, `{"results":[{"name":"first"}],"paginationInfo":{"nextPageToken":"t2"}}`) + return + } + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"message":"bad token"}`) + }) + + result, err := client.SearchAll(context.Background(), SearchRequest{Q: "cafe"}, 5) + if err == nil { + t.Fatal("want an error reporting the failed page") + } + if result == nil { + t.Fatal("want the partial result alongside the error") + } + if len(result.Places) != 1 || result.Places[0].Name != "first" { + t.Errorf("places: got %+v, want the one page that succeeded", result.Places) + } +} + +func TestSearchAllFirstPageFailureReturnsNoResult(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"message":"nope"}`) + }) + + result, err := client.SearchAll(context.Background(), SearchRequest{Q: "cafe"}, 5) + if err == nil { + t.Fatal("want an error") + } + if result != nil { + t.Errorf("want nil result when nothing was gathered, got %+v", result) + } +} + +func TestSearchAutocomplete(t *testing.T) { + var gotPath string + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + fmt.Fprint(w, `{"results":[ + {"completionUrl":"/v1/search?q=eiffel&metadata=abc", + "displayLines":["Eiffel Tower","Paris, France"], + "location":{"latitude":48.858,"longitude":2.294}, + "structuredAddress":{"locality":"Paris"}} + ]}`) + }) + + results, err := client.SearchAutocomplete(context.Background(), SearchAutocompleteRequest{ + Q: "eiffel", + ResultTypeFilter: []SearchACResultType{SearchACResultTypePoi, SearchACResultTypePhysicalFeature}, + LimitToCountries: []string{"FR"}, + SearchLocation: &Location{Latitude: 48.85, Longitude: 2.29}, + }) + if err != nil { + t.Fatalf("SearchAutocomplete: %v", err) + } + + if gotPath != searchAutocompletePath { + t.Errorf("path: got %q, want %q", gotPath, searchAutocompletePath) + } + if got, want := gotQuery.Get("resultTypeFilter"), "poi,physicalFeature"; got != want { + t.Errorf("resultTypeFilter: got %q, want %q", got, want) + } + if got := gotQuery.Get("q"); got != "eiffel" { + t.Errorf("q: got %q", got) + } + + if len(results) != 1 { + t.Fatalf("results: got %d, want 1", len(results)) + } + got := results[0] + if got.CompletionURL != "/v1/search?q=eiffel&metadata=abc" { + t.Errorf("completionUrl: got %q", got.CompletionURL) + } + if len(got.DisplayLines) != 2 { + t.Errorf("displayLines: got %d, want 2", len(got.DisplayLines)) + } + if got.Location == nil || got.Location.Latitude != 48.858 { + t.Error("location did not decode") + } + if got.StructuredAddress == nil || got.StructuredAddress.Locality != "Paris" { + t.Error("structuredAddress did not decode") + } +} + +func TestSearchAutocompleteRequiresQ(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request should be sent without Q") + }) + if _, err := client.SearchAutocomplete(context.Background(), SearchAutocompleteRequest{}); err == nil { + t.Error("want an error when Q is empty") + } +} + +func TestSearchPropagatesQuotaError(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + fmt.Fprint(w, `{"message":"Quota exceeded"}`) + }) + + _, err := client.Search(context.Background(), SearchRequest{Q: "cafe"}) + var quotaErr *QuotaError + if !errors.As(err, "aErr) { + t.Fatalf("got %T (%v), want *QuotaError", err, err) + } +} diff --git a/applemaps/types.go b/applemaps/types.go index 245b06f7..e807463e 100644 --- a/applemaps/types.go +++ b/applemaps/types.go @@ -128,12 +128,6 @@ type TokenResponse struct { ExpiresInSeconds int `json:"expiresInSeconds"` } -// ErrorResponse is the body Apple returns with a non-2xx status. -type ErrorResponse struct { - Message string `json:"message,omitempty"` - Details []string `json:"details,omitempty"` -} - // AutocompleteResult is a single suggestion from /v1/searchAutocomplete. // // CompletionURL is a relative URI into the search endpoint carrying opaque @@ -209,12 +203,11 @@ type Step struct { // while a step reaches its path through Step.StepPathIndex. ResolveRoute walks // those indexes with bounds checks. // -// StepPaths is typed as a slice of polylines, each polyline a slice of points, -// which matches Apple's prose description ("each step path is a single polyline -// represented as an array of points"). Apple's machine-readable schema annotates -// the field as a flat array of Location, which contradicts that prose; the prose -// is followed here and the live probe in step 8 of the implementation plan -// confirms it. +// StepPaths is a slice of polylines, each polyline a slice of points. Apple's +// machine-readable schema annotates the field as a flat array of Location, which +// contradicts its own prose description ("each step path is a single polyline +// represented as an array of points"). A live response settles it in favour of +// the prose: the field arrives as [[{lat,lng},...],[{lat,lng},...]]. type DirectionsResponse struct { Origin *Place `json:"origin,omitempty"` Destination *Place `json:"destination,omitempty"` @@ -264,15 +257,27 @@ const DirectionsAvoidTolls DirectionsAvoid = "Tolls" // TransportType is a mode of transportation. // // Apple's documentation truncates the list of valid values mid-sentence ("which -// is one of:" followed by nothing), so these three constants come from MapKit's -// equivalent enum rather than from the Server API reference. Step 8 of the -// implementation plan confirms the accepted set against the live API by sending -// a deliberately invalid value and reading the accepted set back out of -// ErrorResponse.Details. +// is one of:" followed by nothing), so these constants were established +// empirically against the live API instead: each was sent to /v1/etas and the +// response status recorded. Automobile, Walking, Transit, and Cycling are all +// accepted; "Bicycle" is rejected with HTTP 400 "transportType invalid", so +// Cycling is the spelling for that mode. +// +// Apple does not enumerate the accepted values in its 400 response, so extending +// this list means probing candidates one at a time. type TransportType string const ( TransportTypeAutomobile TransportType = "Automobile" TransportTypeWalking TransportType = "Walking" TransportTypeTransit TransportType = "Transit" + TransportTypeCycling TransportType = "Cycling" ) + +// AllTransportTypes lists every mode confirmed to be accepted. +var AllTransportTypes = []TransportType{ + TransportTypeAutomobile, + TransportTypeWalking, + TransportTypeTransit, + TransportTypeCycling, +} diff --git a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md index fd13016a..4bc8fdef 100644 --- a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md +++ b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md @@ -144,6 +144,48 @@ throwaway `main` under `/private/tmp`, never committed: Budget: well under 20 calls against the 25,000/day quota. +### Step 8 findings + +Run on 2026-08-06 with team `JRBD76VZ75` and key `FUTFWSCQA4`. Roughly 30 calls. +Every endpoint answered successfully. + +1. **`ErrorResponse` does not match its published schema.** Apple documents + `message` and `details` at the top level; the live API nests them: + + ```json + {"error":{"message":"transportType invalid","details":[]}} + ``` + + Decoding only the documented shape produced empty error messages, losing the + reason for every failure. `ErrorResponse.UnmarshalJSON` now accepts both forms, + preferring the nested one. This was the single real defect the probe caught. + +2. **`TransportType` has four values, not three.** `Automobile`, `Walking`, + `Transit`, and `Cycling` are all accepted; `Bicycle` is rejected with HTTP 400. + `Cycling` was missing from the MapKit-derived guess. Apple's 400 response does + not enumerate the accepted set, so each candidate had to be probed + individually — `AllTransportTypes` records the confirmed list. + +3. **`stepPaths` is an array of polylines**, confirming the prose over the + machine-readable schema: `[[{lat,lng}],[{lat,lng},{lat,lng},…]]`. A real + San Francisco to Cupertino route returned 1 route, 15 steps, and 15 step + paths, and `ResolveRoute` walked all 15. + +4. **`FAILED_INVALID_ID`** is the `errorCode` for an unknown place ID. A batch + lookup of one good and one bogus ID returned 1 result and 1 error together, + confirming the partial-success handling. + +5. **The category mapping looks better than feared for grocery.** All ten results + for `q=supermarket` near Cupertino came back as `FoodMarket`, not `Store` — + Safeway, Hankook Supermarket, 99 Ranch Market. That is one query in one metro + area, so it is encouraging rather than conclusive, but it is evidence for the + design's approach of carrying specificity in `q`. + +Not established: the observed `expiresInSeconds`. The probe's raw `/v1/token` +call presented the access token rather than a freshly signed auth JWT and so +returned 401. The value is documented as 1800 and the decode is covered by test, +but it has not been seen on the wire. + ## Done when - `go build ./... && go vet ./... && go test ./applemaps/...` clean. From c993339f1a5dc2fe1d55ff2428cedc5f4212175e Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Fri, 7 Aug 2026 08:35:25 -0700 Subject: [PATCH 04/18] fix(applemaps): pagination was broken against the live API Answering "have we tested this" honestly: the unit suite passed and every endpoint had been called live, but SearchAll had never been exercised against Apple -- only Search, one page. Probing it found two more undocumented rules, either of which broke every multi-page search. First, enablePagination is rejected once a pageToken is present: "Cannot specify parameter [enablePagination] in search request by pageToken". It opts into pagination and belongs to the first request only. Second, after fixing that, Apple rejected q the same way -- a page request may carry no other parameter at all, because the token encodes the whole original query. The fix is structural rather than a comment. PageToken is gone from SearchRequest, replaced by SearchPage(ctx, token), so a request that mixes a token with a query is now unrepresentable rather than merely discouraged. The deeper problem was the test double. It accepted any parameter combination, making it more permissive than the service it stood in for, so it could not have caught either bug -- the suite was green while production was broken. It now rejects a pageToken request carrying anything else, using Apple's own status and message. Reverting the fix makes four tests fail; before, none did. Also closes two coverage gaps that were hiding behind stubs: the backoff schedule is now asserted to double from 200ms, and sleepContext itself is tested for both elapsing and aborting on cancellation. It had 0% coverage because every test replaced it. Verified live: 3 pages, 60 places, no duplicates across pages, Truncated correctly set against Apple's totalPageCount of 5. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KJuZpNMJnG4RGqKnZzxF5k --- applemaps/client_test.go | 57 +++++++++ applemaps/search.go | 65 +++++++--- applemaps/search_test.go | 113 ++++++++++++++++-- .../specs/2026-08-04-apple-maps-sdk-plan.md | 38 ++++++ 4 files changed, 251 insertions(+), 22 deletions(-) diff --git a/applemaps/client_test.go b/applemaps/client_test.go index 3d156b0c..d0187cdc 100644 --- a/applemaps/client_test.go +++ b/applemaps/client_test.go @@ -225,6 +225,63 @@ func TestMaxRetriesDisabled(t *testing.T) { } } +// The backoff schedule itself was previously only stubbed out, never asserted. +func TestBackoffDelaysDouble(t *testing.T) { + var delays []time.Duration + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + client.sleep = func(_ context.Context, d time.Duration) error { + delays = append(delays, d) + return nil + } + + if err := client.get(context.Background(), "/v1/thing", nil, nil); err == nil { + t.Fatal("want an error") + } + + // Two retries after the initial attempt, so two sleeps: 200ms then 400ms. + want := []time.Duration{retryBaseDelay, 2 * retryBaseDelay} + if len(delays) != len(want) { + t.Fatalf("sleeps: got %v, want %v", delays, want) + } + for i := range want { + if delays[i] != want[i] { + t.Errorf("sleep %d: got %v, want %v", i+1, delays[i], want[i]) + } + } +} + +func TestSleepContext(t *testing.T) { + t.Run("returns after the delay elapses", func(t *testing.T) { + start := time.Now() + if err := sleepContext(context.Background(), 20*time.Millisecond); err != nil { + t.Fatalf("sleepContext: %v", err) + } + if elapsed := time.Since(start); elapsed < 20*time.Millisecond { + t.Errorf("returned after %v, want at least 20ms", elapsed) + } + }) + + t.Run("aborts when the context is cancelled mid-sleep", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(10 * time.Millisecond) + cancel() + }() + + start := time.Now() + err := sleepContext(ctx, 30*time.Second) + if err == nil { + t.Fatal("want an error when the context is cancelled") + } + // The point is that a cancelled request does not sit out a long backoff. + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("waited %v before returning; cancellation should be immediate", elapsed) + } + }) +} + func TestGetHonoursContextCancellationDuringBackoff(t *testing.T) { client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) diff --git a/applemaps/search.go b/applemaps/search.go index a84e9f61..2219e860 100644 --- a/applemaps/search.go +++ b/applemaps/search.go @@ -52,11 +52,17 @@ type SearchRequest struct { UserLocation *Location // SearchRegionPriority indicates how strongly to weight SearchRegion. SearchRegionPriority string - // EnablePagination asks Apple to return paginated results. SearchAll sets - // this itself. + // EnablePagination asks Apple to return paginated results, populating + // SearchResponse.PaginationInfo. It belongs on the first request of a + // sequence only; subsequent pages are fetched with SearchPage. EnablePagination bool - // PageToken requests a specific page. SearchAll manages this itself. - PageToken string +} + +func (r SearchRequest) validate() error { + if r.Q == "" { + return errors.New("applemaps: Search requires Q") + } + return nil } func (r SearchRequest) params(c *Client) url.Values { @@ -92,9 +98,6 @@ func (r SearchRequest) params(c *Client) url.Values { if r.EnablePagination { params.Set("enablePagination", "true") } - if r.PageToken != "" { - params.Set("pageToken", r.PageToken) - } return params } @@ -115,8 +118,8 @@ func setAddressCategories(params url.Values, key string, categories []AddressCat // kind that genuinely do not exist nearby is a valid, informative answer, and // callers scanning an area need to distinguish it from a failure. func (c *Client) Search(ctx context.Context, req SearchRequest) (*SearchResponse, error) { - if req.Q == "" { - return nil, errors.New("applemaps: Search requires Q") + if err := req.validate(); err != nil { + return nil, err } var resp SearchResponse @@ -126,6 +129,31 @@ func (c *Client) Search(ctx context.Context, req SearchRequest) (*SearchResponse return &resp, nil } +// SearchPage fetches a subsequent page of a paginated search using a token from +// a previous response's PaginationInfo.NextPageToken. +// +// It deliberately takes nothing but the token. Apple rejects a page request +// carrying any other parameter — "Cannot specify parameter [q] in search request +// by pageToken", and likewise for enablePagination — because the token already +// encodes the original query. Neither restriction is documented; both were found +// by calling the live API. Expressing pagination as its own method rather than a +// field on SearchRequest makes the illegal request unrepresentable instead of +// merely discouraged. +func (c *Client) SearchPage(ctx context.Context, pageToken string) (*SearchResponse, error) { + if pageToken == "" { + return nil, errors.New("applemaps: SearchPage requires a page token") + } + + params := url.Values{} + params.Set("pageToken", pageToken) + + var resp SearchResponse + if err := c.get(ctx, searchPath, params, &resp); err != nil { + return nil, err + } + return &resp, nil +} + // SearchAllResult is the outcome of a paginated search. type SearchAllResult struct { // Places is every result across the pages fetched. @@ -147,6 +175,9 @@ type SearchAllResult struct { // function owns pagination and nothing else — it applies no distance filter and // no ranking, because Apple offers no radius parameter and any such policy // belongs to the caller rather than to a transport client. +// +// The first page is a full query; every later page is a bare token request via +// SearchPage, because Apple accepts no other parameter alongside a page token. func (c *Client) SearchAll(ctx context.Context, req SearchRequest, maxPages int) (*SearchAllResult, error) { if req.Q == "" { return nil, errors.New("applemaps: SearchAll requires Q") @@ -157,9 +188,16 @@ func (c *Client) SearchAll(ctx context.Context, req SearchRequest, maxPages int) req.EnablePagination = true result := &SearchAllResult{} + nextToken := "" for { - page, err := c.Search(ctx, req) + var page *SearchResponse + var err error + if nextToken == "" { + page, err = c.Search(ctx, req) + } else { + page, err = c.SearchPage(ctx, nextToken) + } if err != nil { // A failure partway through still returns what was gathered, so a // caller can decide between using a partial result and discarding @@ -176,18 +214,17 @@ func (c *Client) SearchAll(ctx context.Context, req SearchRequest, maxPages int) result.DisplayMapRegion = page.DisplayMapRegion } - next := "" + nextToken = "" if page.PaginationInfo != nil { - next = page.PaginationInfo.NextPageToken + nextToken = page.PaginationInfo.NextPageToken } - if next == "" { + if nextToken == "" { return result, nil } if result.Pages >= maxPages { result.Truncated = true return result, nil } - req.PageToken = next } } diff --git a/applemaps/search_test.go b/applemaps/search_test.go index a9c10cb7..ae667247 100644 --- a/applemaps/search_test.go +++ b/applemaps/search_test.go @@ -101,15 +101,36 @@ func TestSearchRequiresQ(t *testing.T) { // pagedSearchServer serves numbered pages, handing out a next token until the // last one. -func pagedSearchServer(t *testing.T, totalPages int) (*Client, *atomic.Int64, *[]string) { +// +// It enforces Apple's rule that a pageToken request may carry no other +// parameter, answering a violation with the same HTTP 400 the live API returns. +// +// An earlier version of this double accepted anything, which let two real bugs +// pass the entire suite while failing against Apple on page 2: SearchAll left +// enablePagination set on every page, and then still sent q. A fake more +// permissive than the service it stands in for tests nothing — both rules are +// undocumented and were found only by calling the real API. +func pagedSearchServer(t *testing.T, totalPages int) (*Client, *atomic.Int64, *[]url.Values) { t.Helper() var calls atomic.Int64 - tokensSeen := make([]string, 0, totalPages) + queriesSeen := make([]url.Values, 0, totalPages) client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { - n := int(calls.Add(1)) - tokensSeen = append(tokensSeen, r.URL.Query().Get("pageToken")) + query := r.URL.Query() + queriesSeen = append(queriesSeen, query) + + if query.Get("pageToken") != "" { + for key := range query { + if key == "pageToken" { + continue + } + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"error":{"message":"Cannot specify parameter [%s] in search request by pageToken","details":[]}}`, key) + return + } + } + n := int(calls.Add(1)) next := "" if n < totalPages { next = fmt.Sprintf("token-%d", n+1) @@ -120,11 +141,11 @@ func pagedSearchServer(t *testing.T, totalPages int) (*Client, *atomic.Int64, *[ "paginationInfo":{"nextPageToken":%q,"totalPageCount":%d,"totalResults":%d} }`, n, next, totalPages, totalPages) }) - return client, &calls, &tokensSeen + return client, &calls, &queriesSeen } func TestSearchAllFollowsPagination(t *testing.T) { - client, calls, tokensSeen := pagedSearchServer(t, 3) + client, calls, queriesSeen := pagedSearchServer(t, 3) result, err := client.SearchAll(context.Background(), SearchRequest{Q: "cafe"}, 10) if err != nil { @@ -147,8 +168,8 @@ func TestSearchAllFollowsPagination(t *testing.T) { // previous response supplied. want := []string{"", "token-2", "token-3"} for i, w := range want { - if (*tokensSeen)[i] != w { - t.Errorf("page %d pageToken: got %q, want %q", i+1, (*tokensSeen)[i], w) + if got := (*queriesSeen)[i].Get("pageToken"); got != w { + t.Errorf("page %d pageToken: got %q, want %q", i+1, got, w) } } if result.DisplayMapRegion == nil || result.DisplayMapRegion.NorthLatitude != 1 { @@ -156,6 +177,82 @@ func TestSearchAllFollowsPagination(t *testing.T) { } } +// A page request must be a bare token and nothing else: not q, not +// enablePagination, not the search location. Sending anything more is an HTTP +// 400 from Apple, and both restrictions are undocumented. +func TestSearchAllSendsOnlyThePageTokenAfterTheFirstPage(t *testing.T) { + client, _, queriesSeen := pagedSearchServer(t, 3) + + if _, err := client.SearchAll(context.Background(), SearchRequest{ + Q: "cafe", + SearchLocation: &Location{Latitude: 37.78, Longitude: -122.42}, + Lang: "en-US", + }, 10); err != nil { + t.Fatalf("SearchAll: %v", err) + } + + queries := *queriesSeen + if len(queries) != 3 { + t.Fatalf("requests: got %d, want 3", len(queries)) + } + + // The first page is a full query and opts into pagination. + if got := queries[0].Get("enablePagination"); got != "true" { + t.Errorf("page 1 enablePagination: got %q, want true", got) + } + if got := queries[0].Get("q"); got != "cafe" { + t.Errorf("page 1 q: got %q, want cafe", got) + } + if _, present := queries[0]["pageToken"]; present { + t.Error("page 1 must not send a pageToken") + } + + // Every later page carries the token alone. + for i, query := range queries[1:] { + page := i + 2 + if query.Get("pageToken") == "" { + t.Errorf("page %d should send a pageToken", page) + } + if len(query) != 1 { + t.Errorf("page %d sent %v; a page request must carry pageToken alone", page, query) + } + } +} + +func TestSearchPageSendsOnlyTheToken(t *testing.T) { + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + fmt.Fprint(w, `{"results":[{"name":"next page place"}]}`) + }) + // A client-wide default language must not leak into a page request either. + client.lang = "en-US" + + resp, err := client.SearchPage(context.Background(), "tok-abc") + if err != nil { + t.Fatalf("SearchPage: %v", err) + } + + if got := gotQuery.Get("pageToken"); got != "tok-abc" { + t.Errorf("pageToken: got %q", got) + } + if len(gotQuery) != 1 { + t.Errorf("sent %v; want pageToken alone", gotQuery) + } + if len(resp.Results) != 1 || resp.Results[0].Name != "next page place" { + t.Errorf("results: got %+v", resp.Results) + } +} + +func TestSearchPageRequiresToken(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("no request should be sent without a token") + }) + if _, err := client.SearchPage(context.Background(), ""); err == nil { + t.Error("want an error for an empty page token") + } +} + func TestSearchAllSetsEnablePagination(t *testing.T) { var gotQuery url.Values client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md index 4bc8fdef..13b2f114 100644 --- a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md +++ b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md @@ -186,6 +186,44 @@ call presented the access token rather than a freshly signed auth JWT and so returned 401. The value is documented as 1800 and the decode is covered by test, but it has not been seen on the wire. +### Step 8 follow-up: pagination, probed separately + +The first probe called `Search` (one page) and never exercised `SearchAll`, so +the only real algorithm in the package had synthetic coverage only. Probing it +against the live API found two further undocumented rules, both of which had +made `SearchAll` fail on page 2 every time: + +6. **`enablePagination` is rejected once a `pageToken` is present.** Apple: + `Cannot specify parameter [enablePagination] in search request by pageToken`. + The flag opts into pagination and belongs to the first request only. + +7. **A page request may carry no other parameter at all.** After fixing 6, Apple + returned `Cannot specify parameter [q] in search request by pageToken`. The + token encodes the entire original query, so a follow-up page is + `GET /v1/search?pageToken=…` and nothing else — no `q`, no `searchLocation`, + no `lang`. + +Neither rule appears in Apple's documentation. The fix was structural rather than +documentary: `PageToken` was removed from `SearchRequest` and replaced with a +`SearchPage(ctx, token)` method, which makes the illegal request +unrepresentable instead of merely discouraged. + +The test double was the deeper problem. It accepted any combination of +parameters, so it was more permissive than the service it stood in for and could +not have caught either bug. It now rejects a `pageToken` request carrying +anything else, with Apple's own message and status. Reverting the fix makes four +tests fail; before, none did. + +Verified live afterwards: a restaurant search near San Francisco walked 3 pages +for 60 places with no duplicates across pages and `Truncated` correctly set +against Apple's reported `totalPageCount` of 5. Pages hold 20 results. + +One observation for the design's open item on recall: `q=Golden Gate Bridge` +returned 23 results across 3 pages. Apple treats `searchLocation` as a weak hint +and returns loosely related places, so precision differs markedly from a Google +radius search. That reinforces measuring the recall delta before making Apple +primary. + ## Done when - `go build ./... && go vet ./... && go test ./applemaps/...` clean. From fbd12d00495d424019c13cf639f0f706fd386cb4 Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Fri, 7 Aug 2026 16:17:31 -0700 Subject: [PATCH 05/18] docs: Phase 2 plan derived from offerbee's actual field usage Audited all five geo-service call sites in ~/code/offerbee rather than working from the design's assumptions. Three of the design's decisions were wrong and are corrected here. Hours must be left empty for Apple places, not filled with POI.DefaultOpeningHours as the design said. offerbee's isOpenAtParts returns "unknown" only when hours are not exactly 7 entries, and both consumers keep unknown places. Seven copies of the "8:30 am - 9:30 pm" placeholder instead parse as a real window, so a place open until midnight gets filtered out at 22:00 because our invented hours claimed it closed at 21:30. Empty degrades honestly; defaults lie. The category map becomes an allowlist rather than a best-effort table. cardRewards.ts deliberately maps hardware_store, electronics_store, convenience_store and others to no reward, because crediting a department-store or grocery bonus there is worse than the base rate. Apple collapses all of them into Store, and its q matching is loose -- measured at 23 loosely related results for q=Golden Gate Bridge -- so tagging results with the requested type would credit a grocery bonus to a convenience store on a card that explicitly excludes them. That is wrong money advice, not a ranking regression. Apple now serves only the 15 types with an unambiguous equivalent; everything else routes to Google. Requests carrying localTime route to Google outright, since open-now filtering is meaningless without real hours. Also: offerbee consumes five endpoints, not the two the design named, and priceLevel is read nowhere in the entire codebase -- so the one Apple gap the design worried about costs nothing, while the gap it dismissed (hours) is load-bearing in both paths. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KJuZpNMJnG4RGqKnZzxF5k --- .../specs/2026-08-04-apple-maps-sdk-design.md | 28 ++- .../2026-08-07-apple-maps-phase-2-plan.md | 216 ++++++++++++++++++ 2 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-07-apple-maps-phase-2-plan.md diff --git a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md index 580c47ce..37400124 100644 --- a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md +++ b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md @@ -23,6 +23,13 @@ at `POST /v1/nearby-places` and `POST /v1/nearby-places-by-category`. For those use cases the load-bearing fields are a place's **coordinate, name, address, and category** — not rating, price level, opening hours, or photos. +> **Superseded in part.** Auditing offerbee's call sites found five consumed +> endpoints rather than two, and found that opening hours *are* load-bearing — +> both consumers filter on them. Price level, by contrast, is read nowhere at all. +> See [the Phase 2 plan](2026-08-07-apple-maps-phase-2-plan.md), whose +> corrections take precedence over this section and over the category mapping +> table below. + ## What Apple actually returns Verified against Apple's published schema, not assumed. @@ -231,8 +238,14 @@ downstream caller changes shape: search tagged the place with (often the SEARCHED type, not the actual one)", so this matches existing semantics rather than fighting them. 6. Fill unavailable fields explicitly, never by accidental zero value: - `Status = POI.Operational`, `Hours = POI.DefaultOpeningHours` for all seven - days, `Rating` / `PriceLevel` / `UserRatingsTotal` / `Photo` left zero. + `Status = POI.Operational`, `Hours` left **empty**, `Rating` / `PriceLevel` / + `UserRatingsTotal` / `Photo` left zero. + + This step originally specified `Hours = POI.DefaultOpeningHours`. That was + wrong: offerbee treats hours of the wrong length as "unknown, keep", but seven + entries of the `"8:30 am – 9:30 pm"` placeholder parse as a real window and + produce a confident open/closed answer from invented data. Empty hours degrade + honestly; filled defaults lie. See Correction 1 in the Phase 2 plan. Because Apple reports no business status, the closure-persistence behaviour added in commit `03f799c` stays Google-only. An Apple-sourced record can never be @@ -254,6 +267,17 @@ Mitigation: **do not read `poiCategory` off the response to determine type.** Carry specificity in the free-text `q`, use `includePoiCategories` only as a coarse narrowing filter, and tag results with the requested type. +> **Superseded.** The best-effort table below is replaced by an allowlist in the +> Phase 2 plan. Tagging results with the requested type is unsafe for ambiguous +> retail: offerbee maps `hardware_store`, `electronics_store`, +> `convenience_store` and others deliberately to *no reward*, because crediting a +> department-store or grocery bonus there is worse than the base rate. Since +> Apple's `q` matching is loose — measured at 23 loosely related results for +> `q=Golden Gate Bridge` — a `q=supermarket` search returning a convenience store +> would be tagged `supermarket` and credited a bonus the card excludes. Apple now +> serves only types with an unambiguous Apple equivalent; everything else routes +> to Google. + Table lives in the adapter, not in `applemaps/`, since it is Google-taxonomy specific: diff --git a/docs/superpowers/specs/2026-08-07-apple-maps-phase-2-plan.md b/docs/superpowers/specs/2026-08-07-apple-maps-phase-2-plan.md new file mode 100644 index 00000000..20ccb4ba --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-apple-maps-phase-2-plan.md @@ -0,0 +1,216 @@ +# Apple Maps Phase 2 — Adapter and Routing + +Design: [2026-08-04-apple-maps-sdk-design.md](2026-08-04-apple-maps-sdk-design.md). +Phase 1 (the `applemaps` package) is complete; see +[the Phase 1 plan](2026-08-04-apple-maps-sdk-plan.md). + +Scope: the `iowrappers` adapter, the category allowlist, provider routing, the +quota counter, and config wiring. + +This plan is derived from what `~/code/offerbee` actually reads over the wire, +not from the design's assumptions. Where the two disagree, offerbee wins and the +design is corrected. + +## What offerbee actually consumes + +Five endpoints, not the two the design named. + +| Endpoint | Consumer | Fields read | +|---|---|---| +| `POST /v1/nearby-places` | `nearby.ts:147` | `lat`, `lng`, `hours` | +| `POST /v1/nearby-places-by-category` | `bestCard.ts:366` | `placeId`, `name`, `address`, `lat`, `lng`, `hours`, `url`, `rating`, `locationType` | +| `POST /v1/place-search` | `bestCard.ts:508`, `placeSearch.ts` | as above, plus `category`, `insertable` | +| `POST /v1/place-search/confirm` | `bestCard.ts` | `place`, `category`, `alreadyCached` | +| `POST /v1/create-token` | operational | — | + +Categories requested: `Eatery`, `Shopping`, `Lodging`, `Wellness`, `Visit` +(`cardRewards.ts` `NEARBY_CATEGORIES`). + +### Field priorities, measured + +- **`locationType` — load-bearing and safety-critical.** See below. +- **`hours` — load-bearing.** Filters in both the brand and category paths. +- **`rating` — read**, and passed through to the UI in three places. +- **`priceLevel` — read nowhere in offerbee.** Confirmed by grep across + `packages` and `apps`. Apple's missing price level therefore costs nothing, and + the design's concern about it was misplaced. + +## Correction 1: Apple-sourced places must carry empty Hours + +`POI.CreatePlace` fills `POI.DefaultOpeningHours` (`"8:30 am – 9:30 pm"`) into +every weekday a source left blank (`POI/places.go:383`). The design said to do +that for Apple places. That is wrong, and worse than leaving the field alone. + +offerbee's `isOpenAtParts` returns `null` — meaning "unknown" — only when +`hours.length !== 7` (`placeHours.ts:110`). Both consumers treat `null` as +"keep": `bestCard.ts:404` drops a place only on `=== false`, and `nearby.ts:163` +keeps on `!== false`. But seven entries of `"8:30 am – 9:30 pm"` parse to a real +range, so a fabricated window yields a confident `true` or `false` instead of +`null`. A place open until midnight would be filtered out at 22:00 because our +invented hours said it closed at 21:30. + +So the adapter must leave `Hours` empty for Apple-sourced places. `POI` already +distinguishes the two cases via `HasRealOpeningHours` (`POI/places.go:180`), +which exists precisely because filled defaults make emptiness undetectable. + +This makes hours-less Apple places degrade to "unknown, keep" downstream rather +than to a confident lie. + +## Correction 2: restrict Apple to types with a 1:1 mapping + +`cardRewards.ts:574` keys card rewards on exact lowercase Google place type +strings, and deliberately maps many to `null`, with reasons in the source: + +```ts +convenience_store: null, // grocery bonuses explicitly exclude convenience stores +hardware_store: null, // "mis-crediting a department-store bonus here is worse +electronics_store: null, // than the base rate" +book_store: null, jewelry_store: null, pet_store: null, florist: null, ... +supermarket: GROCERY, +store: DEPARTMENT, +``` + +Apple's `PoiCategoryStore` collapses `supermarket`, `shopping_mall`, +`clothing_store`, `hardware_store`, `electronics_store`, `furniture_store`, +`book_store`, `shoe_store`, `jewelry_store`, `pet_store`, `bicycle_store`, and +`florist` into one value. `PoiCategoryFoodMarket` covers both supermarkets and +specialty grocers. + +The design's plan — tag results with the *requested* type — is unsafe given a +measured fact from the Phase 1 probe: Apple's `q` matching is loose, with +`q=Golden Gate Bridge` returning 23 loosely related places. Searching +`q=supermarket`, receiving a convenience store, and tagging it `supermarket` +credits a grocery bonus that the card explicitly excludes. That is wrong +financial advice, and it is the exact error offerbee's explicit `null`s were +written to prevent. + +The category map is therefore an **allowlist**, not a best-effort table. Apple +serves a Google type only where Apple has an unambiguous equivalent: + +| `POI.LocationType` | Apple `q` | `includePoiCategories` | +|---|---|---| +| `cafe` | cafe | `Cafe` | +| `restaurant` | restaurant | `Restaurant` | +| `bakery` | bakery | `Bakery` | +| `pharmacy` | pharmacy | `Pharmacy` | +| `gas_station` | gas station | `GasStation` | +| `lodging` | hotel | `Hotel` | +| `movie_theater` | movie theater | `MovieTheater` | +| `zoo` | zoo | `Zoo` | +| `aquarium` | aquarium | `Aquarium` | +| `museum` | museum | `Museum` | +| `stadium` | stadium | `Stadium` | +| `bowling_alley` | bowling alley | `Bowling` | +| `amusement_park` | amusement park | `AmusementPark` | +| `gym` | gym | `FitnessCenter` | +| `spa` | spa | `Spa` | + +Every other type — all ambiguous retail, plus `bar`, `night_club`, +`meal_takeaway`, `meal_delivery`, `art_gallery`, `tourist_attraction`, +`beauty_salon`, `hair_care`, `park` — routes to Google. A type absent from the +allowlist is not an error; it is a routing decision. + +Two consequences worth stating plainly. `Shopping` is almost entirely outside the +allowlist, so it stays on Google. And Apple covers 5 of the 9 types in +`ENTERTAINMENT_PLACE_TYPES` (`cardRewards.ts:210`), missing `art_gallery`, +`tourist_attraction`, `museum` is covered, so `Visit` is partially servable. + +## Correction 3: hours-filtered requests route to Google + +Any request carrying `localTime` needs real opening hours to mean anything, and +Apple has none. Such requests route to Google regardless of the allowlist. + +Because both providers share one Redis keyspace (per the design's cache-identity +decision), an Apple-sourced place written on a no-`localTime` request can later be +read from cache by a `localTime` request. With Correction 1 in place that place +carries empty hours, so it degrades to "unknown, keep" rather than to a +fabricated window — acceptable, and the reason Correction 1 is a prerequisite +rather than a nicety. + +## Step 1 — `iowrappers/apple_maps_client.go` + +`AppleMapsClient` implementing `SearchClient` (`iowrappers/maps_client.go:19`). + +- `Geocode` — `/v1/geocode`, filling `GeocodeQuery` from `structuredAddress` +- `ReverseGeocode` — `/v1/reverseGeocode` +- `NearbySearch` — allowlist lookup, `SearchAll`, then haversine filter to + `req.Radius` + +Place conversion, explicitly rather than by zero value: + +| `POI.Place` field | Apple source | +|---|---| +| `ID` | `"apple:" + place.ID` | +| `Name` | `name` | +| `FormattedAddress` | `formattedAddressLines` joined | +| `Location` | `coordinate` | +| `LocationType` | the **requested** allowlisted type | +| `Types` | the requested type only | +| `Status` | `POI.Operational` (Apple reports no closures) | +| `Hours` | **left empty** — Correction 1 | +| `Rating`, `UserRatingsTotal`, `PriceLevel`, `Photo` | zero | +| `URL` | empty; Apple exposes no place URL | + +`POI.CreatePlace` cannot be reused, since filling defaults is exactly what +Correction 1 forbids. The adapter constructs `POI.Place` directly. + +Tests: allowlist is exhaustive over `POI.LocationType` and every entry round +trips; a non-allowlisted type is reported as unservable rather than guessed; +radius filter drops out-of-range results; `Hours` is empty and +`HasRealOpeningHours` is false; IDs carry the `apple:` prefix. + +## Step 2 — routing + +```go +type FallbackSearchClient struct { + primary, secondary SearchClient + canServe func(*PlaceSearchRequest) bool +} +``` + +Routes to `secondary` (Google) when `canServe` is false — a non-allowlisted type, +or a request carrying a local time — and falls back on error, `*applemaps.QuotaError`, +or an empty result. Every fallback logs its reason so the real Apple hit rate is +measurable rather than assumed. + +Tests: allowlisted type with no local time goes to Apple; non-allowlisted goes to +Google without an Apple call; local-time request goes to Google; each fallback +trigger works; a success does not fall back. + +## Step 3 — quota counter + +Redis `INCR` on `applemaps:quota:`, 48-hour expiry, pre-emptive +fallback above a configurable threshold (default 90%). The 25,000 daily calls are +shared with MapKit JS, and offerbee's iOS app ships under the same team +(`JRBD76VZ75`), so the budget is not ours alone. + +Tests: counter increments per outbound call; threshold routes to Google; the key +expires; a Redis failure does not block the request. + +## Step 4 — config wiring + +`main.go` gains the four Apple env vars from the design's Credentials section, +plus `APPLE_MAPS_ENABLED` defaulting to false. `PoiSearcher` builds the +`FallbackSearchClient` only when the credentials are present and valid, so a +missing key degrades to Google-only rather than failing startup. + +## Done when + +- `go build ./... && go vet ./... && go test ./...` clean +- Every offerbee-consumed field either sourced from Apple or explicitly left in + its "unknown" state, never fabricated +- `priceLevel` confirmed unread, so left at zero without concern +- Google still primary by default; Apple enabled by config +- A table in this document recording, per offerbee endpoint, which provider serves + it after this change + +## Open + +1. Apple Maps terms of service on caching and on mixing providers, carried over + from the design. Still unresolved, still a pre-production gate. +2. Whether offerbee's native app uses MapKit JS, which would draw on the same + 25,000 daily calls. `apps/native` has no MapKit reference found so far, but + this was not exhaustively checked. +3. `rating` has no Apple source at all. Apple-sourced places will surface in + offerbee's UI without a rating while Google-sourced ones have it, which is a + visible inconsistency in the merchant sheet rather than a correctness problem. From d1cf865b7c8054ef8f8e74e16b492895a5097274 Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Mon, 10 Aug 2026 15:41:31 -0700 Subject: [PATCH 06/18] fix(applemaps): correct four defects found auditing against Apple's schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of these are silent: they produce wrong data or spend quota without ever surfacing an error. Autocomplete coordinates decoded to (0, 0). /v1/searchAutocomplete spells a coordinate {"lat","lng"}; every other endpoint uses {"latitude","longitude"}, and Apple documents only the one Location type. encoding/json drops unknown keys, so each suggestion arrived as a non-nil zero coordinate — a real point off the coast of Ghana that a caller cannot distinguish from an answer. Location now decodes either spelling, preferring the documented one. The old fixture hardcoded the wrong shape, so the suite had been asserting the bug was correct behaviour. AddressCategory was missing AdministrativeArea. Apple's reference page renders six values as five, running the second into the Country bullet; the /v1/search parameter documentation confirms it independently by using the value in its own example. The state/province level was inexpressible. StructuredAddress dropped subAdministrativeArea. Absent from Apple's published schema, present in live responses — Apple's own searchAutocomplete example returns "San Francisco County" on most results. This is the admin-area-2 slot the Phase 2 geocode adapter needs. A concurrent 401 burst spent one token exchange per goroutine. Invalidate cleared unconditionally, so each goroutine's 401 discarded the token the previous one had just fetched: N exchanges against a 25,000/day quota shared with a production app, and retries racing over which token they held. Invalidation is now generation-guarded, so a 401 can only discard the token that drew it. Also, three cases that Apple answers with an opaque 400, now caught locally for the same reason MaxETADestinations already is: - Directions rejects TransportTypeTransit, which is valid for ETAs only. This matches MapKit, which gives transit travel times but no transit turn-by-turn. - SearchRegionPriority is a typed enum rather than a bare string; Apple accepts exactly "default" and "required". - The address-category filters validate their SearchResultTypeAddress prerequisite. SearchAutocompleteRequest gains a validate and rejects them outright, since SearchACResultType has no address member and so no such request can be legal. Each of the three decode and concurrency defects was confirmed to reproduce before being fixed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015WGSuRPA8YK814rwbCpStj --- applemaps/auth.go | 52 ++++++++++- applemaps/client.go | 51 +++++----- applemaps/client_test.go | 73 +++++++++++++++ applemaps/directions.go | 11 ++- applemaps/directions_test.go | 28 ++++++ applemaps/search.go | 43 +++++++-- applemaps/search_test.go | 89 +++++++++++++++++- applemaps/types.go | 93 ++++++++++++++++++- applemaps/types_test.go | 85 +++++++++++++++++ .../specs/2026-08-04-apple-maps-sdk-design.md | 25 ++++- 10 files changed, 504 insertions(+), 46 deletions(-) diff --git a/applemaps/auth.go b/applemaps/auth.go index 16a4cea5..ae9a7da6 100644 --- a/applemaps/auth.go +++ b/applemaps/auth.go @@ -108,6 +108,10 @@ type TokenSource struct { mu sync.Mutex token string expiry time.Time + // generation identifies the current cached token. It increments on every + // successful exchange so a caller holding a token that failed can ask for it + // to be discarded without discarding whatever replaced it. + generation uint64 } // NewTokenSource validates the credentials and returns a TokenSource. It makes @@ -150,23 +154,60 @@ func NewTokenSource(cfg TokenSourceConfig) (*TokenSource, error) { // endpoint — a 50-goroutine cold start would otherwise spend 50 calls to learn // the same token. func (ts *TokenSource) Token(ctx context.Context) (string, error) { + token, _, err := ts.tokenWithGeneration(ctx) + return token, err +} + +// tokenWithGeneration returns a valid access token along with the generation that +// identifies it, for callers that may need to invalidate exactly that token. +func (ts *TokenSource) tokenWithGeneration(ctx context.Context) (string, uint64, error) { ts.mu.Lock() defer ts.mu.Unlock() if ts.token != "" && ts.now().Before(ts.expiry.Add(-tokenRefreshMargin)) { - return ts.token, nil + return ts.token, ts.generation, nil + } + token, err := ts.exchangeLocked(ctx) + if err != nil { + return "", 0, err } - return ts.exchangeLocked(ctx) + return token, ts.generation, nil } -// Invalidate discards the cached token so the next Token call re-exchanges. The -// client calls this after a 401, which is how a token revoked before its stated -// expiry is recovered from. +// Invalidate discards the cached token unconditionally, so the next Token call +// re-exchanges. func (ts *TokenSource) Invalidate() { ts.mu.Lock() defer ts.mu.Unlock() + ts.clearLocked() +} + +// invalidateGeneration discards the cached token only if it is still the one +// identified by generation. +// +// The unconditional Invalidate is wrong on the client's 401 path. A burst of N +// in-flight requests sharing one revoked token all get 401 and all want a refresh; +// serialised by the mutex, each would clear the token the previous goroutine had +// just fetched and exchange again — N calls against a quota shared with a +// production app, and a retry left holding a token another goroutine already +// discarded. Checking the generation makes every 401 after the first a no-op, +// because the refresh they were asking for has already happened. +func (ts *TokenSource) invalidateGeneration(generation uint64) { + ts.mu.Lock() + defer ts.mu.Unlock() + if ts.generation != generation { + return + } + ts.clearLocked() +} + +// clearLocked drops the cached token. Callers must hold ts.mu. +func (ts *TokenSource) clearLocked() { ts.token = "" ts.expiry = time.Time{} + // The generation advances so an invalidateGeneration racing on the token just + // dropped does not go on to clear its replacement. + ts.generation++ } // authJWT builds and signs the short-lived JWT that /v1/token accepts. @@ -227,5 +268,6 @@ func (ts *TokenSource) exchangeLocked(ctx context.Context) (string, error) { ts.token = parsed.AccessToken ts.expiry = ts.now().Add(time.Duration(parsed.ExpiresInSeconds) * time.Second) + ts.generation++ return ts.token, nil } diff --git a/applemaps/client.go b/applemaps/client.go index 6ad018fd..74d1520d 100644 --- a/applemaps/client.go +++ b/applemaps/client.go @@ -124,47 +124,54 @@ func sleepContext(ctx context.Context, d time.Duration) error { // with backoff, because the likely cause is transient. A 429 is retried by // neither: the quota is daily, so no amount of waiting inside one request helps. func (c *Client) get(ctx context.Context, path string, params url.Values, out any) error { - err := c.attempt(ctx, path, params, out) + generation, err := c.attempt(ctx, path, params, out) var authErr *AuthError if errors.As(err, &authErr) { - c.tokens.Invalidate() - return c.attempt(ctx, path, params, out) + // Only the token that actually drew the 401 is discarded. Under a + // concurrent burst every request holds the same revoked token, and + // clearing unconditionally would make each one throw away the refresh the + // last one just paid for. + c.tokens.invalidateGeneration(generation) + _, err = c.attempt(ctx, path, params, out) } return err } -// attempt performs one logical request, retrying retryable status codes. -func (c *Client) attempt(ctx context.Context, path string, params url.Values, out any) error { +// attempt performs one logical request, retrying retryable status codes. It also +// reports the token generation the last round trip used, so a 401 can be traced +// back to the exact token that failed. +func (c *Client) attempt(ctx context.Context, path string, params url.Values, out any) (uint64, error) { var lastErr error + var generation uint64 for i := 0; i <= c.maxRetries; i++ { if i > 0 { // Exponential backoff: 200ms, 400ms, 800ms... delay := retryBaseDelay << (i - 1) if err := c.sleep(ctx, delay); err != nil { - return err + return generation, err } } - lastErr = c.once(ctx, path, params, out) + generation, lastErr = c.once(ctx, path, params, out) if lastErr == nil { - return nil + return generation, nil } var apiErr *APIError if !errors.As(lastErr, &apiErr) || !retryable(apiErr.StatusCode) { - return lastErr + return generation, lastErr } } - return lastErr + return generation, lastErr } -// once performs a single HTTP round trip. -func (c *Client) once(ctx context.Context, path string, params url.Values, out any) error { - token, err := c.tokens.Token(ctx) +// once performs a single HTTP round trip, reporting the token generation it used. +func (c *Client) once(ctx context.Context, path string, params url.Values, out any) (uint64, error) { + token, generation, err := c.tokens.tokenWithGeneration(ctx) if err != nil { - return err + return 0, err } endpoint := c.baseURL + path @@ -174,37 +181,37 @@ func (c *Client) once(ctx context.Context, path string, params url.Values, out a req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { - return fmt.Errorf("applemaps: build request: %w", err) + return generation, fmt.Errorf("applemaps: build request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { - return fmt.Errorf("applemaps: %s: %w", path, err) + return generation, fmt.Errorf("applemaps: %s: %w", path, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) if readErr != nil { - return fmt.Errorf("applemaps: %s: HTTP %d and unreadable body: %w", path, resp.StatusCode, readErr) + return generation, fmt.Errorf("applemaps: %s: HTTP %d and unreadable body: %w", path, resp.StatusCode, readErr) } - return newAPIError(resp.StatusCode, body) + return generation, newAPIError(resp.StatusCode, body) } if out == nil { - return nil + return generation, nil } body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { - return fmt.Errorf("applemaps: %s: read response: %w", path, err) + return generation, fmt.Errorf("applemaps: %s: read response: %w", path, err) } if err := json.Unmarshal(body, out); err != nil { - return fmt.Errorf("applemaps: %s: decode response: %w", path, err) + return generation, fmt.Errorf("applemaps: %s: decode response: %w", path, err) } - return nil + return generation, nil } // applyLang sets the lang parameter, preferring an explicit per-request value diff --git a/applemaps/client_test.go b/applemaps/client_test.go index d0187cdc..0025961f 100644 --- a/applemaps/client_test.go +++ b/applemaps/client_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "sync" "sync/atomic" "testing" "time" @@ -101,6 +102,78 @@ func TestGetRetriesOnceAfter401(t *testing.T) { } } +// A burst of concurrent requests sharing one revoked token must cost one extra +// token exchange between them, not one each. +// +// Invalidation used to be unconditional, so each goroutine's 401 cleared the token +// the previous goroutine had just fetched: N goroutines meant N exchanges, each +// discarding a valid token. Against a 25,000-per-day quota shared with a +// production app that is a real cost, and the retries were left racing over which +// token they held. +func TestConcurrent401sExchangeTokenOnce(t *testing.T) { + var tokenCalls atomic.Int64 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == tokenPath { + n := tokenCalls.Add(1) + // Hold the response so the goroutines genuinely overlap rather than + // finishing one after another. + time.Sleep(20 * time.Millisecond) + fmt.Fprintf(w, `{"accessToken":"token-%d","expiresInSeconds":1800}`, n) + return + } + // The first token is rejected; anything later is accepted. This is the + // revoked-early case, hit by every goroutine at once. + if r.Header.Get("Authorization") == "Bearer token-1" { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"message":"Invalid token"}`) + return + } + fmt.Fprint(w, `{"ok":true}`) + })) + defer srv.Close() + + client, err := New(Options{ + TeamID: "T", KeyID: "K", PrivateKey: testKey(t), BaseURL: srv.URL, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + client.sleep = func(context.Context, time.Duration) error { return nil } + + // Prime the cache so every goroutine starts holding the same doomed token, + // which is the situation the guard exists for. + if _, err := client.tokens.Token(context.Background()); err != nil { + t.Fatalf("priming exchange: %v", err) + } + + const goroutines = 50 + var wg sync.WaitGroup + errs := make([]error, goroutines) + + wg.Add(goroutines) + for i := range goroutines { + go func() { + defer wg.Done() + var out struct{ OK bool } + if errs[i] = client.get(context.Background(), "/v1/thing", nil, &out); errs[i] == nil && !out.OK { + errs[i] = errors.New("retry body did not decode") + } + }() + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("goroutine %d: %v", i, err) + } + } + // One priming exchange plus exactly one refresh shared by all 50. + if got := tokenCalls.Load(); got != 2 { + t.Errorf("token exchanges: got %d, want 2", got) + } +} + func TestGetReturnsAfterSecond401(t *testing.T) { var endpointCalls atomic.Int64 client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { diff --git a/applemaps/directions.go b/applemaps/directions.go index 8a3eb3a7..2e9ee58c 100644 --- a/applemaps/directions.go +++ b/applemaps/directions.go @@ -35,7 +35,9 @@ type DirectionsRequest struct { Origin string // Destination is the ending address or coordinate. Required. Destination string - // TransportType selects the mode of transportation. + // TransportType selects the mode of transportation. Apple accepts + // DirectionsTransportTypes here — every mode except TransportTypeTransit, + // which is valid only for ETAs. TransportType TransportType // DepartureDate is the intended departure. Apple accepts either this or // ArrivalDate, never both. @@ -75,6 +77,13 @@ func (r DirectionsRequest) validate() error { if r.DepartureDate != nil && r.ArrivalDate != nil { return errors.New("applemaps: Directions accepts DepartureDate or ArrivalDate, not both") } + // /v1/directions documents Automobile, Walking, and Cycling only, matching + // MapKit, which gives transit travel times but no transit turn-by-turn. The + // error names the endpoint that does serve transit, since a caller reaching + // for it wants a travel time and can get one. + if r.TransportType == TransportTypeTransit { + return errors.New("applemaps: Directions does not support TransportTypeTransit; use ETAs for transit travel times") + } return nil } diff --git a/applemaps/directions_test.go b/applemaps/directions_test.go index eebbe821..84ee010b 100644 --- a/applemaps/directions_test.go +++ b/applemaps/directions_test.go @@ -95,6 +95,13 @@ func TestDirectionsValidation(t *testing.T) { name: "both dates", req: DirectionsRequest{Origin: "a", Destination: "b", DepartureDate: &now, ArrivalDate: &now}, }, + { + // /v1/directions documents Automobile, Walking, and Cycling only. + // Transit is valid for ETAs alone, matching MapKit, which gives + // transit travel times but no transit turn-by-turn. + name: "transit", + req: DirectionsRequest{Origin: "a", Destination: "b", TransportType: TransportTypeTransit}, + }, } for _, tc := range tests { @@ -109,6 +116,27 @@ func TestDirectionsValidation(t *testing.T) { } } +// Transit is rejected for Directions but must stay available for ETAs, which is +// the whole point of splitting the two lists. +func TestETAsAcceptsTransit(t *testing.T) { + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + fmt.Fprint(w, `{"etas":[]}`) + }) + + if _, err := client.ETAs(context.Background(), ETAsRequest{ + Origin: Location{Latitude: 37.33, Longitude: -122.03}, + Destinations: []Location{{Latitude: 37.32, Longitude: -121.94}}, + TransportType: TransportTypeTransit, + }); err != nil { + t.Fatalf("ETAs with Transit: %v", err) + } + if got := gotQuery.Get("transportType"); got != "Transit" { + t.Errorf("transportType: got %q, want %q", got, "Transit") + } +} + // Every other list parameter in this API is comma-separated; ETA destinations // are bar-separated, because commas already separate each pair's components. func TestETAsPipeJoinsDestinations(t *testing.T) { diff --git a/applemaps/search.go b/applemaps/search.go index 2219e860..21e0be33 100644 --- a/applemaps/search.go +++ b/applemaps/search.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "slices" ) const ( @@ -51,7 +52,7 @@ type SearchRequest struct { // UserLocation is used as a fallback bias when SearchLocation is unset. UserLocation *Location // SearchRegionPriority indicates how strongly to weight SearchRegion. - SearchRegionPriority string + SearchRegionPriority SearchRegionPriority // EnablePagination asks Apple to return paginated results, populating // SearchResponse.PaginationInfo. It belongs on the first request of a // sequence only; subsequent pages are fetched with SearchPage. @@ -62,6 +63,14 @@ func (r SearchRequest) validate() error { if r.Q == "" { return errors.New("applemaps: Search requires Q") } + // Apple rejects the address-category filters unless the result type filter + // admits addresses. Catching it here costs nothing and names the missing + // value, which Apple's 400 does not. + if len(r.IncludeAddressCategories) > 0 || len(r.ExcludeAddressCategories) > 0 { + if !slices.Contains(r.ResultTypeFilter, SearchResultTypeAddress) { + return errors.New("applemaps: address categories require SearchResultTypeAddress in ResultTypeFilter") + } + } return nil } @@ -93,7 +102,7 @@ func (r SearchRequest) params(c *Client) url.Values { params.Set("userLocation", formatLocation(r.UserLocation.Latitude, r.UserLocation.Longitude)) } if r.SearchRegionPriority != "" { - params.Set("searchRegionPriority", r.SearchRegionPriority) + params.Set("searchRegionPriority", string(r.SearchRegionPriority)) } if r.EnablePagination { params.Set("enablePagination", "true") @@ -179,8 +188,8 @@ type SearchAllResult struct { // The first page is a full query; every later page is a bare token request via // SearchPage, because Apple accepts no other parameter alongside a page token. func (c *Client) SearchAll(ctx context.Context, req SearchRequest, maxPages int) (*SearchAllResult, error) { - if req.Q == "" { - return nil, errors.New("applemaps: SearchAll requires Q") + if err := req.validate(); err != nil { + return nil, err } if maxPages <= 0 { maxPages = DefaultMaxSearchPages @@ -258,7 +267,25 @@ type SearchAutocompleteRequest struct { // UserLocation is used as a fallback bias when SearchLocation is unset. UserLocation *Location // SearchRegionPriority indicates how strongly to weight SearchRegion. - SearchRegionPriority string + SearchRegionPriority SearchRegionPriority +} + +// validate rejects, without spending a call, the combinations Apple answers with a +// 400. +// +// The address-category filters are unsatisfiable on this endpoint rather than +// merely unset: Apple requires an address result type alongside them, and +// SearchACResultType has no address member, so there is no request that both uses +// them and is legal. +func (r SearchAutocompleteRequest) validate() error { + if r.Q == "" { + return errors.New("applemaps: SearchAutocomplete requires Q") + } + if len(r.IncludeAddressCategories) > 0 || len(r.ExcludeAddressCategories) > 0 { + return errors.New("applemaps: SearchAutocomplete does not support address categories; " + + "Apple requires an address result type with them and searchAutocomplete has none") + } + return nil } func (r SearchAutocompleteRequest) params(c *Client) url.Values { @@ -289,7 +316,7 @@ func (r SearchAutocompleteRequest) params(c *Client) url.Values { params.Set("userLocation", formatLocation(r.UserLocation.Latitude, r.UserLocation.Longitude)) } if r.SearchRegionPriority != "" { - params.Set("searchRegionPriority", r.SearchRegionPriority) + params.Set("searchRegionPriority", string(r.SearchRegionPriority)) } return params } @@ -297,8 +324,8 @@ func (r SearchAutocompleteRequest) params(c *Client) url.Values { // SearchAutocomplete returns suggestions for a partial query. An empty result // set is not an error, for the same reason as Search. func (c *Client) SearchAutocomplete(ctx context.Context, req SearchAutocompleteRequest) ([]AutocompleteResult, error) { - if req.Q == "" { - return nil, errors.New("applemaps: SearchAutocomplete requires Q") + if err := req.validate(); err != nil { + return nil, err } var resp SearchAutocompleteResponse diff --git a/applemaps/search_test.go b/applemaps/search_test.go index ae667247..7007090b 100644 --- a/applemaps/search_test.go +++ b/applemaps/search_test.go @@ -364,11 +364,15 @@ func TestSearchAutocomplete(t *testing.T) { client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotQuery = r.URL.Query() + // Keys copied from Apple's own documented /v1/searchAutocomplete response. + // This endpoint spells the coordinate "lat"/"lng" rather than the + // "latitude"/"longitude" every other endpoint uses, and returns a + // subAdministrativeArea that Apple's StructuredAddress schema omits. fmt.Fprint(w, `{"results":[ {"completionUrl":"/v1/search?q=eiffel&metadata=abc", "displayLines":["Eiffel Tower","Paris, France"], - "location":{"latitude":48.858,"longitude":2.294}, - "structuredAddress":{"locality":"Paris"}} + "location":{"lat":48.858,"lng":2.294}, + "structuredAddress":{"locality":"Paris","subAdministrativeArea":"Paris"}} ]}`) }) @@ -402,11 +406,17 @@ func TestSearchAutocomplete(t *testing.T) { if len(got.DisplayLines) != 2 { t.Errorf("displayLines: got %d, want 2", len(got.DisplayLines)) } - if got.Location == nil || got.Location.Latitude != 48.858 { - t.Error("location did not decode") + if got.Location == nil { + t.Fatal("location did not decode") + } + if got.Location.Latitude != 48.858 || got.Location.Longitude != 2.294 { + t.Errorf("location: got %+v, want {48.858 2.294}", *got.Location) } if got.StructuredAddress == nil || got.StructuredAddress.Locality != "Paris" { - t.Error("structuredAddress did not decode") + t.Fatal("structuredAddress did not decode") + } + if got.StructuredAddress.SubAdministrativeArea != "Paris" { + t.Errorf("subAdministrativeArea: got %q, want %q", got.StructuredAddress.SubAdministrativeArea, "Paris") } } @@ -419,6 +429,75 @@ func TestSearchAutocompleteRequiresQ(t *testing.T) { } } +// Apple rejects the address-category filters unless the result type filter admits +// addresses. Catching it locally names the missing value; Apple's 400 does not. +func TestSearchRejectsAddressCategoriesWithoutAddressResultType(t *testing.T) { + var sent atomic.Int64 + client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + sent.Add(1) + fmt.Fprint(w, `{"results":[]}`) + }) + + _, err := client.Search(context.Background(), SearchRequest{ + Q: "paris", + ResultTypeFilter: []SearchResultType{SearchResultTypePoi}, + IncludeAddressCategories: []AddressCategory{AddressCategoryPostalCode}, + }) + if err == nil { + t.Fatal("want an error when address categories lack an address result type") + } + if got := sent.Load(); got != 0 { + t.Errorf("requests sent: got %d, want 0 — validation must not spend a call", got) + } + + // The same request is legal once the address type is present. + if _, err := client.Search(context.Background(), SearchRequest{ + Q: "paris", + ResultTypeFilter: []SearchResultType{SearchResultTypePoi, SearchResultTypeAddress}, + IncludeAddressCategories: []AddressCategory{AddressCategoryPostalCode}, + }); err != nil { + t.Fatalf("Search with an address result type: %v", err) + } + if got := sent.Load(); got != 1 { + t.Errorf("requests sent: got %d, want 1", got) + } +} + +// SearchACResultType has no address member, so there is no autocomplete request +// that uses the address-category filters and is legal. +func TestSearchAutocompleteRejectsAddressCategories(t *testing.T) { + client, _ := testClient(t, func(http.ResponseWriter, *http.Request) { + t.Error("no request should be sent for a combination Apple cannot satisfy") + }) + + _, err := client.SearchAutocomplete(context.Background(), SearchAutocompleteRequest{ + Q: "eiffel", + ExcludeAddressCategories: []AddressCategory{AddressCategoryCountry}, + }) + if err == nil { + t.Error("want an error when autocomplete is given address categories") + } +} + +func TestSearchRegionPriorityIsSentVerbatim(t *testing.T) { + var gotQuery url.Values + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + fmt.Fprint(w, `{"results":[]}`) + }) + + if _, err := client.Search(context.Background(), SearchRequest{ + Q: "cafe", + SearchRegion: &MapRegion{NorthLatitude: 38, EastLongitude: -122.1, SouthLatitude: 37.5, WestLongitude: -122.5}, + SearchRegionPriority: SearchRegionPriorityRequired, + }); err != nil { + t.Fatalf("Search: %v", err) + } + if got := gotQuery.Get("searchRegionPriority"); got != "required" { + t.Errorf("searchRegionPriority: got %q, want %q", got, "required") + } +} + func TestSearchPropagatesQuotaError(t *testing.T) { client, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTooManyRequests) diff --git a/applemaps/types.go b/applemaps/types.go index e807463e..1859202d 100644 --- a/applemaps/types.go +++ b/applemaps/types.go @@ -1,5 +1,7 @@ package applemaps +import "encoding/json" + // Apple documents every response field as optional. Where a Go zero value would // be indistinguishable from an absent field AND the zero value is itself // meaningful — a distance of 0 metres, a route with no tolls — the field is a @@ -13,6 +15,51 @@ type Location struct { Longitude float64 `json:"longitude"` } +// UnmarshalJSON accepts both spellings Apple uses for a coordinate. +// +// Every endpoint sends "latitude"/"longitude" except /v1/searchAutocomplete, whose +// location object is {"lat":...,"lng":...}. Apple's schema documents one Location +// type and gives no hint of the second spelling; only the endpoint's own example +// response shows it. +// +// Decoding just the documented spelling is silently wrong rather than an error: +// unknown keys are ignored, so every suggestion's coordinate becomes a non-nil +// (0, 0) — a real point in the Gulf of Guinea that a caller cannot tell apart from +// an answer. Accepting either spelling here keeps that failure out of every caller, +// and costs nothing on the endpoints that use the long form. +// +// There is no matching MarshalJSON. Only decoding is tolerant; the package never +// sends a Location as a JSON body, and emitting whichever spelling was last read +// would be worse than emitting the documented one. +func (l *Location) UnmarshalJSON(data []byte) error { + // Pointers distinguish an absent key from a present zero, so a coordinate + // legitimately at 0 does not read as missing and trigger the fallback. + var wire struct { + Latitude *float64 `json:"latitude"` + Longitude *float64 `json:"longitude"` + Lat *float64 `json:"lat"` + Lng *float64 `json:"lng"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + + // The documented spelling wins if a response somehow carries both. + switch { + case wire.Latitude != nil: + l.Latitude = *wire.Latitude + case wire.Lat != nil: + l.Latitude = *wire.Lat + } + switch { + case wire.Longitude != nil: + l.Longitude = *wire.Longitude + case wire.Lng != nil: + l.Longitude = *wire.Lng + } + return nil +} + // MapRegion is a rectangular region expressed as its south-west and north-east // corners. type MapRegion struct { @@ -28,9 +75,16 @@ type MapRegion struct { type SearchMapRegion = MapRegion // StructuredAddress describes the individual components of a place's address. +// +// SubAdministrativeArea is absent from Apple's published StructuredAddress schema, +// which lists ten fields. Live responses carry eleven: Apple's own +// /v1/searchAutocomplete example returns "subAdministrativeArea":"San Francisco +// County" on most of its results. Omitting it here discarded the county silently, +// since encoding/json drops unknown keys without complaint. type StructuredAddress struct { AdministrativeArea string `json:"administrativeArea,omitempty"` AdministrativeAreaCode string `json:"administrativeAreaCode,omitempty"` + SubAdministrativeArea string `json:"subAdministrativeArea,omitempty"` AreasOfInterest []string `json:"areasOfInterest,omitempty"` DependentLocalities []string `json:"dependentLocalities,omitempty"` FullThoroughfare string `json:"fullThoroughfare,omitempty"` @@ -238,16 +292,35 @@ const ( // AddressCategory narrows which address results a search returns. Using it // requires SearchResultTypeAddress in the request's ResultTypeFilter. +// +// Apple's AddressCategory page renders six values as five, running the second into +// the first bullet: "Country: Countries and regions. AdministrativeArea The primary +// administrative divisions of countries or regions." The /v1/search parameter +// documentation settles it independently by using the missing value in its own +// example, excludeAddressCategories=Country,AdministrativeArea. type AddressCategory string const ( AddressCategoryCountry AddressCategory = "Country" + AddressCategoryAdministrativeArea AddressCategory = "AdministrativeArea" AddressCategorySubAdministrativeArea AddressCategory = "SubAdministrativeArea" AddressCategoryLocality AddressCategory = "Locality" AddressCategorySubLocality AddressCategory = "SubLocality" AddressCategoryPostalCode AddressCategory = "PostalCode" ) +// SearchRegionPriority says how strongly a request's SearchRegion should be +// weighted. Apple accepts exactly two values, and rejects anything else with a 400. +type SearchRegionPriority string + +const ( + // SearchRegionPriorityDefault treats the region as a hint, which is Apple's + // behaviour when the parameter is absent. + SearchRegionPriorityDefault SearchRegionPriority = "default" + // SearchRegionPriorityRequired confines results to the region. + SearchRegionPriorityRequired SearchRegionPriority = "required" +) + // DirectionsAvoid names a feature to avoid when routing. Tolls is the only value // Apple defines. type DirectionsAvoid string @@ -265,19 +338,33 @@ const DirectionsAvoidTolls DirectionsAvoid = "Tolls" // // Apple does not enumerate the accepted values in its 400 response, so extending // this list means probing candidates one at a time. +// +// The set is not the same on both endpoints — see TransportTypeTransit. type TransportType string const ( TransportTypeAutomobile TransportType = "Automobile" TransportTypeWalking TransportType = "Walking" - TransportTypeTransit TransportType = "Transit" - TransportTypeCycling TransportType = "Cycling" + // TransportTypeTransit works on /v1/etas but not on /v1/directions, which + // documents only Automobile, Walking, and Cycling. That matches MapKit, which + // gives transit travel times but no transit turn-by-turn. Directions rejects + // it locally rather than spending a call to learn the same thing. + TransportTypeTransit TransportType = "Transit" + TransportTypeCycling TransportType = "Cycling" ) -// AllTransportTypes lists every mode confirmed to be accepted. +// AllTransportTypes lists every mode confirmed to be accepted by /v1/etas. +// Directions accepts all but TransportTypeTransit. var AllTransportTypes = []TransportType{ TransportTypeAutomobile, TransportTypeWalking, TransportTypeTransit, TransportTypeCycling, } + +// DirectionsTransportTypes lists the modes /v1/directions accepts. +var DirectionsTransportTypes = []TransportType{ + TransportTypeAutomobile, + TransportTypeWalking, + TransportTypeCycling, +} diff --git a/applemaps/types_test.go b/applemaps/types_test.go index 9fc7fbf1..39b20e80 100644 --- a/applemaps/types_test.go +++ b/applemaps/types_test.go @@ -222,6 +222,91 @@ func TestStepPathsDecodeAsPolylines(t *testing.T) { } } +// /v1/searchAutocomplete spells a coordinate "lat"/"lng" while every other +// endpoint spells it "latitude"/"longitude". Apple documents one Location type and +// never mentions the second spelling; only that endpoint's example response shows +// it. Decoding just the documented form is silently wrong rather than an error, +// because encoding/json drops unknown keys — the coordinate becomes (0, 0), which +// is a real point off the coast of Ghana. +func TestLocationDecodesBothWireSpellings(t *testing.T) { + tests := []struct { + name string + body string + wantLat float64 + wantLong float64 + }{ + { + name: "documented spelling", + body: `{"latitude":48.858,"longitude":2.294}`, + wantLat: 48.858, + wantLong: 2.294, + }, + { + name: "searchAutocomplete spelling", + body: `{"lat":37.785743713378906,"lng":-122.40109252929688}`, + wantLat: 37.785743713378906, + wantLong: -122.40109252929688, + }, + { + // A coordinate legitimately at zero must not read as an absent key and + // fall through to the other spelling. + name: "explicit zero in the documented spelling", + body: `{"latitude":0,"longitude":0,"lat":1,"lng":1}`, + wantLat: 0, + wantLong: 0, + }, + { + name: "documented spelling wins when both are present", + body: `{"latitude":48.858,"longitude":2.294,"lat":1,"lng":1}`, + wantLat: 48.858, + wantLong: 2.294, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got Location + if err := json.Unmarshal([]byte(tt.body), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Latitude != tt.wantLat || got.Longitude != tt.wantLong { + t.Errorf("got %+v, want {%v %v}", got, tt.wantLat, tt.wantLong) + } + }) + } +} + +// The tolerant decoder must not disturb the endpoints that nest a Location, since +// those all use the documented spelling. +func TestNestedLocationsStillDecode(t *testing.T) { + const body = `{ + "etas":[{"destination":{"latitude":37.32,"longitude":-121.94},"distanceMeters":1200}] + }` + var got EtaResponse + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.ETAs) != 1 || got.ETAs[0].Destination == nil { + t.Fatalf("etas did not decode: %+v", got) + } + if got.ETAs[0].Destination.Latitude != 37.32 { + t.Errorf("destination latitude: got %v, want 37.32", got.ETAs[0].Destination.Latitude) + } +} + +// Apple's StructuredAddress schema lists ten fields; live responses carry +// subAdministrativeArea as an eleventh. +func TestStructuredAddressDecodesSubAdministrativeArea(t *testing.T) { + const body = `{"administrativeArea":"California","subAdministrativeArea":"San Francisco County","locality":"San Francisco"}` + var got StructuredAddress + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.SubAdministrativeArea != "San Francisco County" { + t.Errorf("subAdministrativeArea: got %q, want %q", got.SubAdministrativeArea, "San Francisco County") + } +} + func TestPlacesResponseSurfacesPartialFailure(t *testing.T) { const body = `{"results":[{"id":"good","name":"Somewhere"}],"errors":[{"id":"bad","errorCode":"NOT_FOUND"}]}` var got PlacesResponse diff --git a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md index 37400124..4e28b647 100644 --- a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md +++ b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md @@ -50,8 +50,29 @@ Verified against Apple's published schema, not assumed. `SearchResponse.Place` extends `Place` with exactly one field: `poiCategory`. `StructuredAddress`: `administrativeArea`, `administrativeAreaCode`, -`areasOfInterest`, `dependentLocalities`, `fullThoroughfare`, `locality`, -`postCode`, `subLocality`, `subThoroughfare`, `thoroughfare`. +`subAdministrativeArea`, `areasOfInterest`, `dependentLocalities`, +`fullThoroughfare`, `locality`, `postCode`, `subLocality`, `subThoroughfare`, +`thoroughfare`. + +> `subAdministrativeArea` is **not** in Apple's published `StructuredAddress` +> schema, which lists the other ten. Live responses carry it — Apple's own +> `/v1/searchAutocomplete` example returns `"subAdministrativeArea":"San Francisco +> County"` on most results. This section originally transcribed the schema and so +> inherited the omission; the county was being decoded away silently. + +`AutocompleteResult.location` is `{"lat":…,"lng":…}`, not the +`{"latitude":…,"longitude":…}` that `Location` uses everywhere else. Apple documents +one `Location` type and never mentions the second spelling. `applemaps.Location` +accepts both on decode. + +`AddressCategory` has six values, not the five its reference page appears to list: +Apple's markup runs `AdministrativeArea` into the `Country` bullet. The `/v1/search` +parameter documentation confirms it by using the value in its own example. + +`TransportType` is not uniform across endpoints. `/v1/etas` takes `Automobile`, +`Walking`, `Transit`, and `Cycling`; `/v1/directions` documents the same set minus +`Transit`, matching MapKit, which gives transit travel times but no transit +turn-by-turn. ### Fields Apple does not provide, at any tier From adcf88c4bb1c6c61bb71c6325adc82f3ce7819af Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Mon, 10 Aug 2026 16:35:19 -0700 Subject: [PATCH 07/18] fix(applemaps): address major findings from PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A malformed token TTL caused unbounded re-exchange. exchangeLocked trusted expiresInSeconds as given. A response missing the field, or stating a lifetime at or below tokenRefreshMargin, put expiry inside the refresh margin, so every subsequent call found the cached token stale and exchanged again — one degraded response shape turned into permanent double consumption of a quota shared with MapKit JS. The TTL is now sanitised at exchange time: a non-positive value falls back to the 1800 seconds Apple is observed to issue, and a lifetime too short for the fixed margin gets half of itself instead, so the token stays cacheable. The refresh instant is stored rather than derived on each read, since the margin now depends on the lifetime of that particular token. Invalidation ordering gains a deterministic test. The concurrent test asserts an exchange count across 50 goroutines, which cannot guarantee which interleaving it exercised; this pins the case directly — two callers take one token, the first invalidates and refreshes, and the second's late invalidation naming the discarded token must leave the replacement alone. The remaining findings are in the phase plans, which describe work not yet written: - The Phase 2 adapter would have stopped paginating on the first page that contributed nothing after the radius filter. searchLocation and searchRegion are hints, not constraints, so a later page can hold in-radius results that an earlier empty one says nothing about. Paginate to the cap, then filter. - Price-constrained searches must route to Google. "priceLevel is read nowhere in offerbee" settled the response side only; on the request side matching/matcher.go:83 carries PriceLevel into the search, where nearby_search.go turns it into a provider-applied filter for pricey eateries and filterPlacesOnPriceLevel demands an exact match. Apple places carry level zero, so a non-zero price filter discards every one of them — the call is spent and the results are thrown away. canServe now rejects it. - The quota counter observes only our own calls, while the 25,000 is per team and shared with MapKit JS, so a threshold on our count alone measures nothing reliable. It now requires an explicit external-consumption allowance, and counts at the HTTP transport boundary so retries and /v1/token exchanges are included rather than only logical searches. - The live probe step said to commit raw Apple responses as testdata fixtures. Apple's terms on caching and on mixing providers are an open question in this same document, so the step now records observed response shape and builds fixtures from Apple's published examples, which is what was actually done. Also corrects the TransportType checklist, which omitted Cycling and the directions/ETAs split. Both behavioural changes were confirmed to reproduce before being fixed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015WGSuRPA8YK814rwbCpStj --- applemaps/auth.go | 38 ++++- applemaps/auth_test.go | 132 ++++++++++++++++++ .../specs/2026-08-04-apple-maps-sdk-design.md | 44 ++++-- .../specs/2026-08-04-apple-maps-sdk-plan.md | 25 +++- .../2026-08-07-apple-maps-phase-2-plan.md | 52 +++++-- 5 files changed, 267 insertions(+), 24 deletions(-) diff --git a/applemaps/auth.go b/applemaps/auth.go index ae9a7da6..a7072e7e 100644 --- a/applemaps/auth.go +++ b/applemaps/auth.go @@ -33,6 +33,10 @@ const ( // treated as stale. Apple issues 30-minute tokens, so 5 minutes leaves room // for a slow request to complete on a token that was valid when it started. tokenRefreshMargin = 5 * time.Minute + + // defaultTokenTTL stands in when a token response states no usable lifetime. + // Apple is observed to issue 1800 seconds. + defaultTokenTTL = 30 * time.Minute ) // ParsePrivateKey parses the ECDSA private key from an Apple Maps .p8 file. @@ -108,6 +112,11 @@ type TokenSource struct { mu sync.Mutex token string expiry time.Time + // refreshAt is when the cached token stops being handed out. It is computed + // once, at exchange time, rather than derived from expiry on every read, + // because the margin it subtracts depends on the lifetime Apple stated for + // that particular token. + refreshAt time.Time // generation identifies the current cached token. It increments on every // successful exchange so a caller holding a token that failed can ask for it // to be discarded without discarding whatever replaced it. @@ -164,7 +173,7 @@ func (ts *TokenSource) tokenWithGeneration(ctx context.Context) (string, uint64, ts.mu.Lock() defer ts.mu.Unlock() - if ts.token != "" && ts.now().Before(ts.expiry.Add(-tokenRefreshMargin)) { + if ts.token != "" && ts.now().Before(ts.refreshAt) { return ts.token, ts.generation, nil } token, err := ts.exchangeLocked(ctx) @@ -205,6 +214,7 @@ func (ts *TokenSource) invalidateGeneration(generation uint64) { func (ts *TokenSource) clearLocked() { ts.token = "" ts.expiry = time.Time{} + ts.refreshAt = time.Time{} // The generation advances so an invalidateGeneration racing on the token just // dropped does not go on to clear its replacement. ts.generation++ @@ -266,8 +276,32 @@ func (ts *TokenSource) exchangeLocked(ctx context.Context) (string, error) { return "", errors.New("applemaps: token response contained no access token") } + now := ts.now() + ttl := time.Duration(parsed.ExpiresInSeconds) * time.Second + + // A response that states no usable lifetime must not be taken at face value. + // Trusting it would put expiry at or before now, which is inside the refresh + // margin, so every subsequent call would treat the token as stale and + // exchange again — turning one malformed field into a permanent doubling of + // quota consumption on a budget shared with MapKit JS. Apple issues 1800 + // seconds; assume that and let the 401 path correct us if the token really + // was shorter-lived. + if ttl <= 0 { + ttl = defaultTokenTTL + } + + // A stated lifetime shorter than twice the margin would leave no window to + // hand the token out in. Halving it keeps the token cacheable while still + // refreshing early, rather than subtracting a fixed margin that the lifetime + // cannot cover. + margin := tokenRefreshMargin + if margin > ttl/2 { + margin = ttl / 2 + } + ts.token = parsed.AccessToken - ts.expiry = ts.now().Add(time.Duration(parsed.ExpiresInSeconds) * time.Second) + ts.expiry = now.Add(ttl) + ts.refreshAt = now.Add(ttl - margin) ts.generation++ return ts.token, nil } diff --git a/applemaps/auth_test.go b/applemaps/auth_test.go index 177be6be..17f3328a 100644 --- a/applemaps/auth_test.go +++ b/applemaps/auth_test.go @@ -204,6 +204,138 @@ func TestTokenRefreshesInsideMargin(t *testing.T) { } } +// A token response that states no usable lifetime must not be taken at face +// value. Trusting it would put expiry at or before now — inside the refresh +// margin — so every later call would find the cached token stale and exchange +// again, turning one malformed field into permanent double quota consumption. +func TestTokenTTLIsSanitised(t *testing.T) { + tests := []struct { + name string + expiresInSeconds int + wantTTL time.Duration + wantReuseWindow time.Duration + }{ + { + name: "absent lifetime falls back to the observed default", + expiresInSeconds: 0, + wantTTL: defaultTokenTTL, + wantReuseWindow: defaultTokenTTL - tokenRefreshMargin, + }, + { + name: "negative lifetime falls back too", + expiresInSeconds: -1, + wantTTL: defaultTokenTTL, + wantReuseWindow: defaultTokenTTL - tokenRefreshMargin, + }, + { + // Too short for the fixed margin, so the margin halves rather than + // leaving no window at all. + name: "lifetime shorter than twice the margin keeps half of itself", + expiresInSeconds: 120, + wantTTL: 120 * time.Second, + wantReuseWindow: 60 * time.Second, + }, + { + name: "a normal lifetime is used as stated", + expiresInSeconds: 1800, + wantTTL: 1800 * time.Second, + wantReuseWindow: 1800*time.Second - tokenRefreshMargin, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv, calls := tokenServer(t, "tok", tc.expiresInSeconds) + ts := newTestTokenSource(t, srv.URL, testKey(t)) + + start := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC) + current := start + ts.now = func() time.Time { return current } + + if _, err := ts.Token(context.Background()); err != nil { + t.Fatalf("Token: %v", err) + } + if want := start.Add(tc.wantTTL); !ts.expiry.Equal(want) { + t.Errorf("expiry: got %v, want %v", ts.expiry, want) + } + + // Just inside the reuse window the token is served from cache. + current = start.Add(tc.wantReuseWindow - time.Second) + if _, err := ts.Token(context.Background()); err != nil { + t.Fatalf("cached Token: %v", err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("exchanges inside the window: got %d, want 1", got) + } + + // Just past it, exactly one refresh happens. + current = start.Add(tc.wantReuseWindow + time.Second) + if _, err := ts.Token(context.Background()); err != nil { + t.Fatalf("refreshed Token: %v", err) + } + if got := calls.Load(); got != 2 { + t.Errorf("exchanges past the window: got %d, want 2", got) + } + }) + } +} + +// Invalidation is generation-checked so a request that fails on an old token +// cannot evict the token that has already replaced it. The concurrent version of +// this lives in client_test.go; this pins the ordering deterministically, since +// the racing test cannot guarantee which interleaving it exercised. +func TestInvalidateGenerationIgnoresAStaleGeneration(t *testing.T) { + srv, calls := tokenServer(t, "tok", 1800) + ts := newTestTokenSource(t, srv.URL, testKey(t)) + + start := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC) + ts.now = func() time.Time { return start } + + // Two callers take the same token, so both hold the same generation. + first, firstGen, err := ts.tokenWithGeneration(context.Background()) + if err != nil { + t.Fatalf("first token: %v", err) + } + _, secondGen, err := ts.tokenWithGeneration(context.Background()) + if err != nil { + t.Fatalf("second token: %v", err) + } + if firstGen != secondGen { + t.Fatalf("generations: got %d and %d, want the same cached token", firstGen, secondGen) + } + + // The first caller's request 401s and it invalidates, forcing a refresh. + ts.invalidateGeneration(firstGen) + refreshed, refreshedGen, err := ts.tokenWithGeneration(context.Background()) + if err != nil { + t.Fatalf("refresh: %v", err) + } + if refreshedGen == firstGen { + t.Fatal("a refresh must advance the generation") + } + if calls.Load() != 2 { + t.Fatalf("exchanges: got %d, want 2", calls.Load()) + } + + // The second caller's 401 arrives late, naming the token already discarded. + // It must be a no-op rather than throwing away the replacement. + ts.invalidateGeneration(secondGen) + + after, afterGen, err := ts.tokenWithGeneration(context.Background()) + if err != nil { + t.Fatalf("token after the late invalidation: %v", err) + } + if afterGen != refreshedGen || after != refreshed { + t.Errorf("late invalidation evicted the newer token: got generation %d, want %d", afterGen, refreshedGen) + } + if got := calls.Load(); got != 2 { + t.Errorf("exchanges: got %d, want 2 — the late invalidation must not force another", got) + } + if first == "" { + t.Error("expected a non-empty first token") + } +} + // A cold TokenSource hit by many goroutines must spend one quota call, not one // per goroutine. func TestConcurrentTokenCallsExchangeOnce(t *testing.T) { diff --git a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md index 4e28b647..58abc7a6 100644 --- a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md +++ b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-design.md @@ -249,11 +249,18 @@ downstream caller changes shape: `enablePagination=true`. The bbox is the coordinate offset by `req.Radius` in each cardinal direction: `±radius/111320` degrees latitude, and `±radius/(111320·cos(lat))` degrees longitude. -3. Page until a page yields zero results inside the radius, or 5 pages have been - fetched. The cap bounds quota consumption per search; when it is hit, log the - truncation rather than returning silently short. -4. **Haversine-filter every result to `req.Radius`.** Apple's location params are - hints, so unfiltered results can be arbitrarily far away. +3. Page until Apple offers no `nextPageToken`, or 5 pages have been fetched. The + cap bounds quota consumption per search; when it is hit, log the truncation + rather than returning silently short. + + Pagination must **not** stop on a page that contributes nothing after the + radius filter. `searchLocation` and `searchRegion` are hints, not constraints, + so Apple's ordering carries no guarantee that a page with no in-radius result + is followed only by more of the same. Stopping there would drop results that + the next page held. Paginate first, filter afterwards. +4. **Haversine-filter every result to `req.Radius`,** once pagination is done. + Apple's location params are hints, so unfiltered results can be arbitrarily + far away. 5. Tag each place's `LocationType` with the **requested** type. `POI/places.go:80` already documents `LocationType` as "the single type a search tagged the place with (often the SEARCHED type, not the actual one)", @@ -399,10 +406,29 @@ The 25,000 daily calls are per team and **shared with MapKit JS**, and once exhausted Apple returns `429` on every endpoint including `/v1/token`. Waiting for that cliff would make the whole service fail over at an unpredictable moment. -Instead: a Redis counter keyed `applemaps:quota:`, incremented per -outbound Apple call, expiring after 48 hours. Above a configurable threshold -(default 90%), route to Google pre-emptively. The threshold is ours to tune; the -cliff is not. +Instead: a Redis counter keyed `applemaps:quota:`, expiring after 48 +hours. Above a configurable threshold (default 90%), route to Google +pre-emptively. The threshold is ours to tune; the cliff is not. + +Two things about that counter have to be right, or the threshold guards a number +that does not mean what it says. + +**Increment at the HTTP transport boundary, not per logical search.** One +`NearbySearch` can cost several calls: up to 5 pages, a retry per 5xx, and a +`/v1/token` exchange whenever the cached token has gone stale. Apple charges for +every one of those. Counting logical operations would under-report by a factor +that varies with the failure rate — worst exactly when the budget is tightest. + +**The counter can only ever see our own traffic.** The 25,000 is per team and +shared with MapKit JS, so anything else shipping under team `JRBD76VZ75` spends +from the same budget invisibly. A threshold applied to our count alone is +therefore an under-estimate of true consumption, not a measure of it. The +threshold must be applied to our count **plus a configured allowance for external +consumption**, and that allowance stated explicitly rather than assumed to be +zero. Sizing it needs the Maps developer dashboard, which reports true team-wide +usage; until someone reads it, the allowance is a guess and the threshold should +be set conservatively. This is why open question 2 — whether the native app uses +MapKit JS — is a prerequisite for the quota guard rather than a curiosity. ## Testing diff --git a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md index 13b2f114..e09195cc 100644 --- a/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md +++ b/docs/superpowers/specs/2026-08-04-apple-maps-sdk-plan.md @@ -27,8 +27,11 @@ Enum constants: `PoiCategory` (77 values, own file `poicategory.go`), `SearchResultType`, `SearchACResultType`, `AddressCategory`, `DirectionsAvoid`. `TransportType` is a string type with constants `Automobile`, `Walking`, -`Transit`, carrying a comment that Apple's documentation truncates the valid set -mid-sentence and that Step 8 confirms it empirically. +`Transit`, and `Cycling`, carrying a comment that Apple's documentation truncates +the valid set mid-sentence and that Step 8 confirms it empirically. `Cycling` is +the spelling Apple accepts; `Bicycle` is rejected with HTTP 400. The set is not +uniform across endpoints — `Transit` works on `/v1/etas` but not on +`/v1/directions`, so `DirectionsRequest` rejects it locally. Every field is a pointer or has an explicit `omitempty` decision: Apple marks every response field optional, so a zero value must stay distinguishable from an @@ -72,6 +75,14 @@ Tests: 2. `Authorization: Bearer ` is set from the `TokenSource`. 3. `401` invalidates the token and retries once; a second `401` returns. Assert the retry actually re-exchanged. + Invalidation is generation-checked, so this needs a concurrent case too: + several requests holding the *same* stale token all get `401`, and between + them they must cost one refresh rather than one each. A late invalidation + naming a token that has already been replaced must be a no-op — an + unconditional clear would let each `401` discard the refresh the previous one + paid for. Cover it both racing (many goroutines, assert the exchange count) + and deterministically (two generations by hand, assert the newer token + survives), since the racing test cannot guarantee which interleaving it hit. 4. `429` decodes to a distinct `QuotaError` — callers must be able to tell quota exhaustion from every other failure, since spec § "Quota guard" routes on it. 5. `5xx` retries with backoff up to a cap, then returns the last error. @@ -136,7 +147,15 @@ Against the real API using `~/.config/applemaps/AuthKey_FUTFWSCQA4.p8`, a throwaway `main` under `/private/tmp`, never committed: 1. `/v1/token` — confirm the exchange and observed `expiresInSeconds`. -2. One call per endpoint; save responses as `testdata/` fixtures. +2. One call per endpoint. **Do not commit the responses.** Record the observed + *shape* — key names, nesting, which documented fields are absent and which + undocumented ones appear — and build fixtures from Apple's own published + example payloads or from synthetic data matching that shape. Apple's terms + restrict persistent caching of its data and its combination with other + providers, and that question is still open (see Open questions), so raw live + responses must not enter the repository ahead of it. If live fixtures ever + become necessary, they need legal sign-off plus stated scrubbing and retention + rules first. 3. `/v1/etas` with a deliberately invalid `transportType`, to make Apple echo the accepted set in `ErrorResponse.details`. Fold the result into the `TransportType` constants and drop the caveat comment from Step 1. diff --git a/docs/superpowers/specs/2026-08-07-apple-maps-phase-2-plan.md b/docs/superpowers/specs/2026-08-07-apple-maps-phase-2-plan.md index 20ccb4ba..01796bb4 100644 --- a/docs/superpowers/specs/2026-08-07-apple-maps-phase-2-plan.md +++ b/docs/superpowers/specs/2026-08-07-apple-maps-phase-2-plan.md @@ -31,9 +31,11 @@ Categories requested: `Eatery`, `Shopping`, `Lodging`, `Wellness`, `Visit` - **`locationType` — load-bearing and safety-critical.** See below. - **`hours` — load-bearing.** Filters in both the brand and category paths. - **`rating` — read**, and passed through to the UI in three places. -- **`priceLevel` — read nowhere in offerbee.** Confirmed by grep across - `packages` and `apps`. Apple's missing price level therefore costs nothing, and - the design's concern about it was misplaced. +- **`priceLevel` — rendered nowhere in offerbee,** confirmed by grep across + `packages` and `apps`. That is narrower than it first looks: it settles the + *response* side only. Price is still an input on the request side of this + service, where it filters, so Apple's missing price level does cost something. + See Correction 4. ## Correction 1: Apple-sourced places must carry empty Hours @@ -127,6 +129,33 @@ carries empty hours, so it degrades to "unknown, keep" rather than to a fabricated window — acceptable, and the reason Correction 1 is a prerequisite rather than a nicety. +## Correction 4: price-constrained requests route to Google + +`priceLevel` being unread by offerbee (measured above) said only that nothing +*renders* it. It is still an active part of the **search contract** on this side, +and the design's "Apple's missing price level costs nothing" was scoped too +narrowly. + +`matching/matcher.go:83` copies `req.PriceLevel` into `PlaceSearchRequest`, and +two things downstream act on it: + +- `iowrappers/nearby_search.go:94-112` — when `POI.PriceyEatery` holds (an eatery + at level 3 or above), the price becomes Google's `MinPrice`/`MaxPrice`, a filter + applied by the provider. Apple has no equivalent parameter, so an Apple-served + request would return unfiltered results under a request that asked for filtered + ones. +- `matching/matcher.go:128` — `filterPlacesOnPriceLevel` keeps a place only when + `place.PriceLevel == level` exactly. Apple-sourced places carry level zero by + Step 1's explicit-zero rule, so **every** Apple place is discarded whenever a + non-zero price filter runs. The Apple call is spent and its results thrown away. + +So this is not a degraded-field problem but a wasted-call-and-wrong-answer one. +`canServe` returns false for any request with a non-zero `PriceLevel`, alongside +the non-allowlisted types and the `localTime` case. + +Tests: a non-zero `PriceLevel` routes to Google with no Apple call; zero still +reaches Apple when the type is allowlisted. + ## Step 1 — `iowrappers/apple_maps_client.go` `AppleMapsClient` implementing `SearchClient` (`iowrappers/maps_client.go:19`). @@ -169,13 +198,15 @@ type FallbackSearchClient struct { ``` Routes to `secondary` (Google) when `canServe` is false — a non-allowlisted type, -or a request carrying a local time — and falls back on error, `*applemaps.QuotaError`, -or an empty result. Every fallback logs its reason so the real Apple hit rate is -measurable rather than assumed. +a request carrying a local time, or a request carrying a non-zero `PriceLevel` — +and falls back on error, `*applemaps.QuotaError`, or an empty result. Every +fallback logs its reason so the real Apple hit rate is measurable rather than +assumed. -Tests: allowlisted type with no local time goes to Apple; non-allowlisted goes to -Google without an Apple call; local-time request goes to Google; each fallback -trigger works; a success does not fall back. +Tests: allowlisted type with no local time and no price constraint goes to Apple; +non-allowlisted goes to Google without an Apple call; local-time request goes to +Google; price-constrained request goes to Google; each fallback trigger works; a +success does not fall back. ## Step 3 — quota counter @@ -199,7 +230,8 @@ missing key degrades to Google-only rather than failing startup. - `go build ./... && go vet ./... && go test ./...` clean - Every offerbee-consumed field either sourced from Apple or explicitly left in its "unknown" state, never fabricated -- `priceLevel` confirmed unread, so left at zero without concern +- `priceLevel` left at zero on Apple places, and price-constrained requests never + reach Apple in the first place - Google still primary by default; Apple enabled by config - A table in this document recording, per offerbee endpoint, which provider serves it after this change From be08142568cafdd32891df0e3708cde39887155e Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Tue, 11 Aug 2026 18:31:51 -0700 Subject: [PATCH 08/18] =?UTF-8?q?docs:=20Phase=202=20design=20=E2=80=94=20?= =?UTF-8?q?Apple=20serves=20geocoding,=20not=20place=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier Phase 2 plan had Apple serving NearbySearch behind a category allowlist. That does not survive the requirement that opening hours stay intact, and the obstacle is structural rather than fixable: Apple's Place carries no hours at any tier, and the usual remedy of backfilling from Google Place Details needs a Google place_id that an Apple result cannot supply. There is no join key, and name-plus-coordinate matching was already ruled out for data feeding card-reward decisions. An Apple-sourced place would be hours-less permanently. The cost case was also weaker than assumed. Google's spend on that path is dominated by PlaceDetailedSearch, one call per place, not by the Nearby Search call. Substituting Apple removes the cheap half and keeps none of the expensive half. Geocoding has neither problem. The mapping is 1:1 and ReverseGeocode runs on every nearby scan — on a warm place cache it is the only Google call left. So Apple serves Geocode and ReverseGeocode; place search stays on Google entirely. No Apple-sourced place ever enters Redis, which moots the shared-keyspace risks the SDK design carried. Three live probes, 26 calls, settled the field mapping: - administrativeAreaCode is conditional, present where a country has conventional subdivision abbreviations (DC, CA, NSW, ON) and absent where it does not (France, Germany, Japan). That is Google's ShortName semantics, so the adapter prefers the code and falls back to administrativeArea. Mapping straight from the code would have emptied the field for every non-abbreviating country. - Forward-geocoding an administrative area returns an empty locality, so the adapter must never overwrite a caller's GeocodeQuery field with an empty Apple value. Google can clobber freely; Apple cannot. - Apple takes a single free-text q where Google takes structured components. Flattening the query holds up: Paris TX and Paris France resolve correctly and distinctly, case is irrelevant, and underspecified input resolves by ranking the way Google's does. Also settles expiresInSeconds at 1800, observed on the wire for the first time — the Phase 1 probe's raw /v1/token call presented the access token instead of a signed auth JWT and got a 401. Architecture keeps one search seam. AppleGeocodeRouter implements SearchClient in full, routing geocoding to Apple with fallback and delegating NearbySearch to Google unconditionally, so "Apple does geocoding only" is a property of one type rather than a rule spread across PoiSearcher. GetMapsClient() is deleted; its three callers are served by construction-time config and a narrow PlaceDetailsClient capability that Apple genuinely lacks. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015WGSuRPA8YK814rwbCpStj --- ...-11-apple-maps-phase-2-geocoding-design.md | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-apple-maps-phase-2-geocoding-design.md diff --git a/docs/superpowers/specs/2026-08-11-apple-maps-phase-2-geocoding-design.md b/docs/superpowers/specs/2026-08-11-apple-maps-phase-2-geocoding-design.md new file mode 100644 index 00000000..f40aa015 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-apple-maps-phase-2-geocoding-design.md @@ -0,0 +1,361 @@ +# Apple Maps Phase 2 — Geocoding + +Phase 1 (the `applemaps` package) is complete: ten endpoints, token lifecycle, +typed errors, tests. Nothing imports it. This design says what does. + +Supersedes most of [the earlier Phase 2 plan](2026-08-07-apple-maps-phase-2-plan.md); +see [What this supersedes](#what-this-supersedes). Builds on +[the SDK design](2026-08-04-apple-maps-sdk-design.md). + +## Why + +Google Places is the only provider for place lookup today. Apple's quota is +25,000 calls per day per developer team, free, so any request Apple can serve +correctly is one we stop paying for. + +"Correctly" turned out to be the whole design question. + +## Scope: Apple serves geocoding, not place search + +Apple serves `Geocode` and `ReverseGeocode`. Place search stays on Google +entirely. + +The earlier plan had Apple serving `NearbySearch` behind a category allowlist. +That does not survive the requirement that opening hours stay intact, and the +reason is structural rather than fixable: + +- Apple's `Place` carries no opening hours at any endpoint or tier. Phase 1 + established this against the published schema and confirmed it live. +- The usual remedy — return the place, then buy details for the missing fields — + is unavailable. Google's Place Details is keyed on a Google `place_id`, and an + Apple result gives `apple:`. There is no join key. The SDK design ruled + out name-plus-coordinate fuzzy matching, correctly, for data feeding + card-reward decisions. + +So an Apple-sourced place would arrive with empty hours permanently, not until +some later pass filled them in. + +The cost argument also came out weaker than the earlier plan assumed. Google's +spend on the place path is dominated by `PlaceDetailedSearch` — one call per +place, capped by `DetailsLimit` — not by the Nearby Search call. Substituting +Apple removes the cheap half and keeps none of the expensive half, because the +details call cannot follow an Apple ID. + +Geocoding has none of these problems. The mapping is 1:1, verified live (below). +`ReverseGeocode` runs on every nearby scan through `processLocation`, and its own +comment at `iowrappers/poi_searcher.go:151` records that on a warm place cache it +was the only Google call left. That is the saving worth having, and it carries no +field deficiency at all. + +One consequence worth stating plainly: **no Apple-sourced place ever enters +Redis.** The shared-keyspace risks the SDK design worried about — Apple records +diluting the geo buckets, the planner's `filterPlacesOnTime` dropping hours-less +places, `PlaceScore` flooring them at zero — are all moot, because there are no +Apple places. + +## What changes + +Three new files, one changed struct. + +``` +iowrappers/apple_maps_client.go AppleMapsClient — applemaps.Client → Geocoder +iowrappers/apple_geocode_router.go AppleGeocodeRouter — routing and fallback +iowrappers/apple_quota.go QuotaCounter — Redis INCR and threshold +``` + +### `SearchClient` splits + +`SearchClient`'s first two methods become a named interface, so the Apple adapter +implements only what it can do: + +```go +type Geocoder interface { + Geocode(context.Context, *GeocodeQuery) (float64, float64, error) + ReverseGeocode(context.Context, float64, float64) (*GeocodeQuery, error) +} + +type SearchClient interface { + Geocoder + NearbySearch(context.Context, *PlaceSearchRequest) ([]POI.Place, error) +} +``` + +`*MapsClient` satisfies both unchanged. No call site moves. + +### `AppleGeocodeRouter` + +```go +type AppleGeocodeRouter struct { + apple Geocoder // *AppleMapsClient + google SearchClient // *MapsClient + quota *QuotaCounter +} +``` + +It implements `SearchClient` in full. `Geocode` and `ReverseGeocode` try Apple +and fall back to Google. `NearbySearch` delegates to Google unconditionally — +there is no condition under which Apple serves it, so there is no `canServe` +predicate and no allowlist. + +Putting the policy in one named type is the point: "Apple does geocoding only" +is a property of this struct, not a rule spread across `PoiSearcher`. + +### `PoiSearcher` keeps one search seam + +```go +type PoiSearcher struct { + searcher SearchClient // *MapsClient, or AppleGeocodeRouter wrapping it + details PlaceDetailsClient // migrations only + redisClient *RedisClient +} +``` + +All three provider call sites — `Geocode:139`, `ReverseGeocode:159`, and the cold +search at `:348` — go through `searcher`. With Apple disabled, `searcher` *is* +`*MapsClient` and the router is not in the path at all. + +`GetMapsClient()` is deleted. Its three callers are handled without it: + +| Caller | Today | After | +|---|---|---| +| `CreatePoiSearcher:77` | `SetCachedPlaceLookup` after construction | set inside the constructor, before the client is stored | +| `planner.go:196` | `SetDetailedSearchFields` poked in post-hoc | `detailedSearchFields` passed to `CreatePoiSearcher` | +| `data_migrations.go:146` | reaches through for `PlaceDetailedSearch` | uses the `details` field | + +`details` survives as a second field because Place Details is a capability Apple +does not have at any tier, used by exactly two migration handlers. Splitting by +capability is honest; splitting by vendor would not be. + +```go +type PlaceDetailsClient interface { + PlaceDetailedSearch(context.Context, string, []string) (maps.PlaceDetailsResult, error) +} +``` + +## Field mapping + +`GeocodeQuery` has three fields, and Google fills them from +`geocodingResultsToGeocodeQuery` (`iowrappers/transformers.go:12`): + +| Field | Google | Apple | +|---|---|---| +| `City` | `locality` **LongName** | `structuredAddress.locality` | +| `AdminAreaLevelOne` | `administrative_area_level_1` **ShortName** | `administrativeAreaCode`, falling back to `administrativeArea` | +| `Country` | `country` **LongName** | `Place.Country` | + +Two rules are not obvious and both were established by live probe rather than +from the schema. + +**`administrativeAreaCode` is conditional.** It is present where a country has +conventional subdivision abbreviations and absent where it does not: + +| Query | `administrativeArea` | `administrativeAreaCode` | +|---|---|---| +| Washington, DC | District of Columbia | `DC` | +| Cupertino, CA | California | `CA` | +| Sydney, Australia | New South Wales | `NSW` | +| Toronto, Canada | Ontario | `ON` | +| Paris, France | Île-de-France | *(empty)* | +| Berlin, Germany | Berlin | *(empty)* | +| Tokyo, Japan | Tokyo | *(empty)* | + +That is exactly Google's `ShortName` semantics, which returns an abbreviation +where one exists and the long name otherwise. So the fallback reproduces Google +in both branches. Mapping straight from `administrativeAreaCode` would empty the +field for every non-abbreviating country. + +**Never overwrite a caller's field with an empty Apple value.** Forward-geocoding +`"Tokyo, Tokyo, Japan"` returns a match on the prefecture rather than a locality, +so `structuredAddress.locality` comes back empty. `MapsClient.Geocode` mutates +its `*GeocodeQuery` in place and `PoiSearcher.Geocode:144` writes the mutated +query to the cache, so a blank `City` would reach the `geocode:cities` hash field. +Google can clobber freely because its component matching always yields a locality; +Apple cannot. The adapter assigns a field only when Apple returned something. + +Reverse geocode never showed this — Cupertino, Paris, Shibuya, Berlin, Singapore, +and Central all returned a locality — and `planner.go:791` reverse-geocodes every +forward result and overwrites all three fields from it. The rule is belt and +braces for the cache write. + +### Query construction + +Google's forward geocode takes structured components — `ComponentLocality`, +`ComponentCountry`, `ComponentAdministrativeArea` — matched per field. Apple takes +a single free-text `q`. The adapter joins the non-empty `GeocodeQuery` fields with +`", "`. + +Probed, and the flattening holds: + +| `q` | Result | +|---|---| +| `Paris, TX, United States` | 33.6601, −95.5554 — Texas | +| `Paris, Île-de-France, France` | 48.8568, 2.3511 — France | +| `Paris, ÎLE-DE-FRANCE, France` | identical coordinate | +| `Springfield, IL, United States` | 39.7984, −89.6494 — Illinois | +| `Toronto, ON, Canada` | 43.6516, −79.3831 — Ontario | +| `Paris, United States` | 33.6601, −95.5554 — Paris, TX | +| `Springfield, United States` | 37.2071, −93.2924 — Springfield, MO | + +Apple honours the admin and country terms, and case is irrelevant — the +uppercased form `GeocodeQuery.String()` produces returns the same coordinate. +Underspecified input resolves by Apple's ranking, which is what Google does with +the same input. + +## Fallback + +`AppleGeocodeRouter` falls back to Google on: + +- any transport or API error, +- `*applemaps.QuotaError`, +- `*applemaps.NotFoundError` — Apple answers an unmatched geocode with HTTP 200 + and an empty array, which the SDK already converts, +- the quota counter being over threshold, checked before the call. + +Every fallback logs its reason at info level, so the real Apple hit rate is +measurable rather than assumed. A fallback costs one Apple call plus one Google +call; silent fallback would make that invisible. + +## Quota guard + +A Redis counter keyed `applemaps:quota:`, expiring after 48 hours. +Above a threshold, skip Apple and go straight to Google. + +Two properties the counter has to have, or the threshold guards a number that +does not mean what it says: + +**Increment at the HTTP transport boundary.** One logical geocode can cost more +than one call: a `/v1/token` exchange when the cached token has gone stale, plus +a retry per 5xx. Apple charges for each. Counting logical operations would +under-report by a factor that varies with the failure rate, worst exactly when the +budget is tightest. + +**Account for traffic the counter cannot see.** The 25,000 is per team and shared +with MapKit JS, so anything else shipping under team `JRBD76VZ75` spends from the +same budget invisibly. The threshold applies to our count **plus a configured +external-consumption allowance**, stated explicitly rather than assumed zero. +Sizing it needs the Maps developer dashboard, which reports true team-wide usage. +Until someone reads it the allowance is a guess, so the default threshold is +conservative. + +A Redis failure never blocks a request: the counter's error path falls through to +Google. + +Expected volume is low. Both geocode methods are cache-first — `geocode:cities` +for forward, an ~8 km cell cache for reverse — so Apple sees misses only. + +## Config and rollout + +`APPLE_MAPS_ENABLED` defaults to false. `CreatePoiSearcher` gains the Apple +credentials and `detailedSearchFields`: + +| Variable | Meaning | +|---|---| +| `APPLE_MAPS_ENABLED` | off by default | +| `APPLE_MAPS_TEAM_ID` | 10-character team ID, JWT `iss` | +| `APPLE_MAPS_KEY_ID` | 10-character key ID, JWT `kid` | +| `APPLE_MAPS_PRIVATE_KEY` | `.p8` contents, PEM or base64-encoded PEM | +| `APPLE_MAPS_QUOTA_THRESHOLD` | fraction of the daily budget, default 0.9 | +| `APPLE_MAPS_EXTERNAL_ALLOWANCE` | calls per day assumed spent by MapKit JS | + +Missing or invalid credentials degrade to Google-only rather than failing +startup: a bad key should not take the service down. + +## Cache and terms + +Apple's terms on caching its data are unresolved, and this design does persist +Apple-derived data — geocode results, not places. Two facts matter for the gate: + +- The reverse-geocode cache already expires after 30 days + (`ReverseGeocodeExpiration`), and its comment says the bound exists because + Google's terms allow only temporary caching. The same bound plausibly satisfies + Apple. +- The forward-geocode cache does **not** expire. `SetGeocode` writes to the + `geocode:cities` hash with no TTL, so entries are permanent. + +`APPLE_MAPS_ENABLED=false` means nothing Apple-derived is written until someone +deliberately turns it on. Resolving the terms is a precondition for enabling, not +for merging. If they require bounded caching, giving `geocode:cities` a TTL is the +change — one that would arguably be right for the Google data already in it. + +## Testing + +Adapter, against Apple's documented payloads and the probe observations: + +- both `administrativeAreaCode` shapes, present and absent, mapping to the same + field Google fills; +- an empty `locality` leaves the caller's `City` untouched; +- `q` construction from one, two, and three populated fields; +- `Country` long form. + +Router, with two fakes: + +- Apple success returns Apple's answer and never calls Google; +- each fallback trigger — error, `*QuotaError`, `*NotFoundError`, over-threshold — + calls Google and logs a reason; +- `NearbySearch` always calls Google, never Apple; +- Apple disabled means the router is not constructed at all. + +Quota counter, on the existing `miniredis` setup in `redis_client_mocks`: + +- increments per outbound call including retries and token exchanges; +- threshold routes to Google; +- the key expires; +- a Redis failure does not block the request. + +No live Apple calls in tests. Fixtures come from Apple's published examples and +from recorded probe shapes, never from committed raw responses. + +## What this supersedes + +From [the Phase 2 plan](2026-08-07-apple-maps-phase-2-plan.md), now moot because +Apple serves no place search: + +- the `POI.LocationType` → Apple category allowlist; +- Correction 1, Apple places carrying empty `Hours`; +- Correction 2, restricting Apple to types with a 1:1 mapping; +- Correction 3, hours-filtered requests routing to Google; +- Correction 4, price-constrained requests routing to Google; +- `FallbackSearchClient` and its `canServe` predicate. + +Still current: the `apple:` ID prefix convention, should place search ever be +revisited; the quota-counter reasoning; and the credentials list. + +From [the SDK design](2026-08-04-apple-maps-sdk-design.md), the "Cache identity" +section's shared-geo-index reasoning no longer applies, since no Apple place is +written. + +## Open questions + +1. Apple's terms on caching geocode results, and the `geocode:cities` TTL that + may follow. Pre-production gate, not a merge gate. +2. Whether the native app draws on the same 25,000/day team quota through + MapKit JS. Needed to size `APPLE_MAPS_EXTERNAL_ALLOWANCE`; until then the + threshold stays conservative. +3. The Google side of the `AdminAreaLevelOne` comparison rests on documented + `ShortName` behaviour, not a paired call — no Google API key was available + during the probe. Worth one confirming pair, though it does not block. + +## Probe record + +Three throwaway probes on 2026-08-11, team `JRBD76VZ75`, key `FUTFWSCQA4`, +26 calls total. Never committed. + +1. Six geocodes and two reverse geocodes across administrative systems, + establishing the conditional `administrativeAreaCode`. +2. Ten forward geocodes testing `q` flattening and disambiguation, including the + Paris TX / Paris France pair. All ten correct. +3. Four reverse geocodes of city-states — Tokyo, Berlin, Singapore, Hong Kong — + confirming `locality` is populated there. + +Also settled, and previously open in the Phase 1 plan: `expiresInSeconds` is +**1800**, observed on the wire. The Step 8 probe never saw it because its raw +`/v1/token` call presented the access token instead of a signed auth JWT and got a +401. The token response carries exactly two keys, `accessToken` and +`expiresInSeconds`. + +One field went the other way: `subAdministrativeArea` was empty on all ten +geocode and reverse-geocode responses, despite appearing as +`"San Francisco County"` in Apple's `searchAutocomplete` example. It looks +endpoint-specific. Harmless — `GeocodeQuery` has no county field — but the struct +field added in the Phase 1 review has no source on the two endpoints this design +uses. From 268b8042ad5a5f17491e02b24ce6d2c1dc1aee9f Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Tue, 11 Aug 2026 21:22:31 -0700 Subject: [PATCH 09/18] docs: Phase 2 implementation plan for Apple Maps geocoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six tasks, each with its own test cycle and an independently reviewable deliverable: split Geocoder out of SearchClient, give PoiSearcher one provider seam, the Apple adapter, the quota counter, the router, and config wiring. The two refactors come first so the Apple work plugs into a seam that already exists rather than creating one, and each is shippable on its own with no behaviour change. Test code is written out rather than described, including the field-mapping cases the live probe settled — administrativeAreaCode present and absent, the empty-locality rule, and query flattening. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015WGSuRPA8YK814rwbCpStj --- ...2026-08-11-apple-maps-phase-2-geocoding.md | 1386 +++++++++++++++++ 1 file changed, 1386 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-apple-maps-phase-2-geocoding.md diff --git a/docs/superpowers/plans/2026-08-11-apple-maps-phase-2-geocoding.md b/docs/superpowers/plans/2026-08-11-apple-maps-phase-2-geocoding.md new file mode 100644 index 00000000..526a59fe --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-apple-maps-phase-2-geocoding.md @@ -0,0 +1,1386 @@ +# Apple Maps Phase 2 (Geocoding) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Route `Geocode` and `ReverseGeocode` to the Apple Maps Server API with automatic fallback to Google, leaving place search entirely on Google. + +**Architecture:** `SearchClient`'s two address methods become a named `Geocoder` interface. `AppleMapsClient` implements it over the existing `applemaps` package. `AppleGeocodeRouter` implements the full `SearchClient` — Apple-with-fallback for geocoding, unconditional passthrough to Google for `NearbySearch`. `PoiSearcher` holds one `searcher SearchClient` field, so all three provider call sites route through a single seam. Apple is off by default. + +**Tech Stack:** Go 1.24, `github.com/weihesdlegend/Vacation-planner/applemaps` (Phase 1, already merged), `go-redis`, `miniredis` for tests, `envconfig` for configuration. + +**Design:** [2026-08-11-apple-maps-phase-2-geocoding-design.md](../specs/2026-08-11-apple-maps-phase-2-geocoding-design.md) + +## Global Constraints + +- No Apple-sourced **place** is ever written to Redis. Apple serves geocoding only; `NearbySearch` always goes to Google. +- The `applemaps` package must keep zero dependencies on this repository. `applemaps/package_test.go` enforces this — never import `iowrappers` or `POI` from it. +- No live Apple calls in tests. Every fixture is either an Apple published example or a synthetic payload matching a recorded probe shape. +- Apple credentials are read from the environment only. Never commit a `.p8`, a key body, or a token. +- `APPLE_MAPS_ENABLED` defaults to `false`. With Apple disabled, `PoiSearcher.searcher` is the `*MapsClient` itself and `AppleGeocodeRouter` is never constructed. +- Missing or invalid Apple credentials degrade to Google-only. They must never fail startup. +- Field mapping, verified live and not negotiable: + - `GeocodeQuery.City` ← `structuredAddress.locality` + - `GeocodeQuery.AdminAreaLevelOne` ← `structuredAddress.administrativeAreaCode`, falling back to `structuredAddress.administrativeArea` when the code is empty + - `GeocodeQuery.Country` ← `Place.country` (long form) + - **Never overwrite a caller's `GeocodeQuery` field with an empty Apple value.** +- Run `go build ./... && go vet ./... && go test ./...` before every commit. + +## File Structure + +| File | Responsibility | +|---|---| +| `iowrappers/maps_client.go` (modify) | Split `Geocoder` out of `SearchClient`; add `PlaceDetailsClient` | +| `iowrappers/poi_searcher.go` (modify) | One `searcher SearchClient` seam; `details` capability; new constructor signature | +| `iowrappers/apple_maps_client.go` (create) | `AppleMapsClient` — `applemaps.Client` → `Geocoder` | +| `iowrappers/apple_quota.go` (create) | `QuotaCounter` + counting `http.RoundTripper` | +| `iowrappers/apple_geocode_router.go` (create) | `AppleGeocodeRouter` — routing, fallback, logging | +| `planner/planner.go` (modify) | Pass config through `Init`; drop `GetMapsClient()` use | +| `main.go` (modify) | Apple env vars | + +--- + +### Task 1: Split `Geocoder` out of `SearchClient` + +Pure refactor. No behaviour change, no call site moves — `*MapsClient` already satisfies both. + +**Files:** +- Modify: `iowrappers/maps_client.go:19-23` +- Test: `iowrappers/interfaces_test.go` (create) + +**Interfaces:** +- Consumes: nothing. +- Produces: `Geocoder` (methods `Geocode(context.Context, *GeocodeQuery) (float64, float64, error)`, `ReverseGeocode(context.Context, float64, float64) (*GeocodeQuery, error)`); `SearchClient` now embeds `Geocoder` and adds `NearbySearch(context.Context, *PlaceSearchRequest) ([]POI.Place, error)`; `PlaceDetailsClient` (method `PlaceDetailedSearch(context.Context, string, []string) (maps.PlaceDetailsResult, error)`). + +- [ ] **Step 1: Write the failing test** + +Create `iowrappers/interfaces_test.go`: + +```go +package iowrappers + +// Compile-time proof that the concrete Google client still satisfies every +// interface it is assigned to after the split. A break here is a build failure +// at the point of the mistake rather than at some distant call site. +var ( + _ Geocoder = (*MapsClient)(nil) + _ SearchClient = (*MapsClient)(nil) + _ PlaceDetailsClient = (*MapsClient)(nil) +) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go vet ./iowrappers/` +Expected: FAIL — `undefined: Geocoder`, `undefined: PlaceDetailsClient`. + +- [ ] **Step 3: Write minimal implementation** + +In `iowrappers/maps_client.go`, replace the `SearchClient` declaration: + +```go +// Geocoder translates between textual locations and coordinates. It is the half +// of SearchClient that Apple Maps can serve: Apple's Place object carries no +// opening hours, rating, or price at any tier, so it cannot serve NearbySearch, +// but its address data is complete. +type Geocoder interface { + Geocode(context.Context, *GeocodeQuery) (float64, float64, error) // translate a textual location to latitude and longitude + ReverseGeocode(context.Context, float64, float64) (*GeocodeQuery, error) // look up a textual location based on latitude and longitude +} + +// SearchClient defines an interface of a client that performs location-based operations such as nearby search +type SearchClient interface { + Geocoder + NearbySearch(context.Context, *PlaceSearchRequest) ([]POI.Place, error) // search nearby places in a category around a central location +} + +// PlaceDetailsClient buys the per-place detail record. It is separate from +// SearchClient because it is a capability with exactly one provider — Apple +// exposes no equivalent — and only the data migrations use it. +type PlaceDetailsClient interface { + PlaceDetailedSearch(context.Context, string, []string) (maps.PlaceDetailsResult, error) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go build ./... && go vet ./... && go test ./iowrappers/ ./planner/` +Expected: PASS, no call site changes required. + +- [ ] **Step 5: Commit** + +```bash +git add iowrappers/maps_client.go iowrappers/interfaces_test.go +git commit -m "refactor(iowrappers): split Geocoder out of SearchClient" +``` + +--- + +### Task 2: Give `PoiSearcher` one search seam + +Still no Apple. This makes the seam exist so later tasks plug into it, and closes the `GetMapsClient()` leak. + +**Files:** +- Modify: `iowrappers/poi_searcher.go:39-42` (struct), `:69-83` (constructor and getter), `:139`, `:159`, `:348` +- Modify: `iowrappers/data_migrations.go:146` +- Modify: `planner/planner.go:184`, `:193-197` +- Modify: `planner/place_search_auth_test.go:31`, `planner/reclassify_buckets_dry_run_test.go:33`, `:98` +- Test: `iowrappers/poi_searcher_seam_test.go` (create) + +**Interfaces:** +- Consumes: `Geocoder`, `SearchClient`, `PlaceDetailsClient` from Task 1. +- Produces: `CreatePoiSearcher(mapsApiKey string, redisUrl *url.URL, detailedSearchFields []string) *PoiSearcher`. `PoiSearcher.GetMapsClient()` is **deleted**. Field `PoiSearcher.searcher SearchClient` is the single provider seam that Task 5 replaces. + +- [ ] **Step 1: Write the failing test** + +Create `iowrappers/poi_searcher_seam_test.go`: + +```go +package iowrappers + +import ( + "context" + "testing" + + "github.com/weihesdlegend/Vacation-planner/POI" +) + +// stubSearchClient records which methods a PoiSearcher routed to it. +type stubSearchClient struct { + geocodeCalls int + reverseGeocodeCalls int + nearbySearchCalls int +} + +func (s *stubSearchClient) Geocode(context.Context, *GeocodeQuery) (float64, float64, error) { + s.geocodeCalls++ + return 1, 2, nil +} + +func (s *stubSearchClient) ReverseGeocode(context.Context, float64, float64) (*GeocodeQuery, error) { + s.reverseGeocodeCalls++ + return &GeocodeQuery{City: "Testville"}, nil +} + +func (s *stubSearchClient) NearbySearch(context.Context, *PlaceSearchRequest) ([]POI.Place, error) { + s.nearbySearchCalls++ + return nil, nil +} + +// Every provider call must go through the one seam, so swapping the field is +// enough to reroute the whole client. If any call site still reaches a concrete +// *MapsClient, these counters stay at zero. +func TestPoiSearcherRoutesGeocodingThroughTheSearcherField(t *testing.T) { + stub := &stubSearchClient{} + s := &PoiSearcher{searcher: stub} + + if _, _, err := s.searcher.Geocode(context.Background(), &GeocodeQuery{City: "x"}); err != nil { + t.Fatalf("Geocode: %v", err) + } + if _, err := s.searcher.ReverseGeocode(context.Background(), 1, 2); err != nil { + t.Fatalf("ReverseGeocode: %v", err) + } + if stub.geocodeCalls != 1 || stub.reverseGeocodeCalls != 1 { + t.Errorf("got %d geocode and %d reverse calls, want 1 and 1", + stub.geocodeCalls, stub.reverseGeocodeCalls) + } +} + +// GetMapsClient handed callers the concrete Google client, which is how provider +// choice leaked out of PoiSearcher. Its absence is the invariant. +func TestPoiSearcherHasNoGetMapsClient(t *testing.T) { + var s any = &PoiSearcher{} + if _, leaked := s.(interface{ GetMapsClient() *MapsClient }); leaked { + t.Error("PoiSearcher still exposes GetMapsClient; provider choice must not leak") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./iowrappers/ -run 'TestPoiSearcher(RoutesGeocoding|HasNoGetMapsClient)' -v` +Expected: FAIL — `unknown field searcher in struct literal`. + +- [ ] **Step 3: Write minimal implementation** + +In `iowrappers/poi_searcher.go`, replace the struct and constructor: + +```go +type PoiSearcher struct { + // searcher is the single provider seam. Every outbound Geocode, + // ReverseGeocode, and NearbySearch goes through it, so routing a provider in + // means replacing this one field and nothing else. It is the *MapsClient + // itself unless Apple is enabled. + searcher SearchClient + // details buys per-place detail records. Separate from searcher because it is + // a capability with one provider rather than a choice between providers, and + // only the data migrations use it. + details PlaceDetailsClient + redisClient *RedisClient +} + +func CreatePoiSearcher(mapsApiKey string, redisUrl *url.URL, detailedSearchFields []string) *PoiSearcher { + mapsClient := CreateMapsClient(mapsApiKey) + redisClient := CreateRedisClient(redisUrl) + + // Both of these used to be poked in from outside after construction, which is + // why PoiSearcher had to expose the concrete client at all. Doing it here + // leaves nothing for callers to reach through for. + mapsClient.SetCachedPlaceLookup(redisClient.CachedPlaces) + if len(detailedSearchFields) > 0 { + mapsClient.SetDetailedSearchFields(detailedSearchFields) + } + + return &PoiSearcher{ + searcher: mapsClient, + details: mapsClient, + redisClient: redisClient, + } +} + +// SetSearchClient replaces the provider seam. Used by the Apple Maps wiring in +// main to wrap the Google client in a router; nothing else should call it. +func (s *PoiSearcher) SetSearchClient(client SearchClient) { + s.searcher = client +} +``` + +Delete the `GetMapsClient` method entirely. Then update the three call sites in the same file — `s.mapsClient.Geocode` at line 139 and `s.mapsClient.ReverseGeocode` at line 159 become `s.searcher.…`, and `s.GetMapsClient().NearbySearch` at line 348 becomes `s.searcher.NearbySearch`. + +In `iowrappers/data_migrations.go:146`, replace `mapsClient := s.GetMapsClient()` with `mapsClient := s.details`. + +In `planner/planner.go`, read the config before constructing and drop the post-hoc poke: + +```go + var placeDetailsFields []string + if v, exists := p.Configs["server:google_maps:detailed_search_fields"]; exists { + placeDetailsFields = v.([]string) + } + + // initialize poi searcher + PoiSearcher := iowrappers.CreatePoiSearcher(mapsClientApiKey, redisURL, placeDetailsFields) +``` + +Keep the existing `p.Solver.Init(...)` block that follows, and delete the later +`p.Solver.Searcher.GetMapsClient().SetDetailedSearchFields(placeDetailsFields)` +line along with the `var placeDetailsFields []string` declaration that preceded +it. `placeDetailsFields` is still used by `CreatePhotoClient` below, so it must +stay in scope. + +In all three test call sites, add the new argument: `iowrappers.CreatePoiSearcher("test-maps-api-key", redisURL, nil)`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go build ./... && go vet ./... && go test ./...` +Expected: PASS. Confirm no `GetMapsClient` remains: `grep -rn "GetMapsClient" --include="*.go" .` returns nothing. + +- [ ] **Step 5: Commit** + +```bash +git add iowrappers/poi_searcher.go iowrappers/poi_searcher_seam_test.go iowrappers/data_migrations.go planner/ +git commit -m "refactor(iowrappers): route every provider call through one PoiSearcher seam" +``` + +--- + +### Task 3: `AppleMapsClient` adapter + +**Files:** +- Create: `iowrappers/apple_maps_client.go` +- Test: `iowrappers/apple_maps_client_test.go` + +**Interfaces:** +- Consumes: `Geocoder`, `GeocodeQuery` from Tasks 1-2. +- Produces: `CreateAppleMapsClient(cfg AppleMapsConfig) (*AppleMapsClient, error)`; `AppleMapsConfig{TeamID, KeyID, PrivateKey string; BaseURL string; HTTPClient *http.Client}`; `*AppleMapsClient` satisfies `Geocoder`. + +- [ ] **Step 1: Write the failing test** + +Create `iowrappers/apple_maps_client_test.go`: + +```go +package iowrappers + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// appleTestKey returns a throwaway P-256 key in the PEM form Apple's .p8 uses. +func appleTestKey(t *testing.T) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) +} + +// appleTestClient points an AppleMapsClient at a stub that answers the token +// exchange itself, so tests only describe the endpoint under test. +func appleTestClient(t *testing.T, handler http.HandlerFunc) (*AppleMapsClient, *url.Values) { + t.Helper() + lastQuery := &url.Values{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/token" { + fmt.Fprint(w, `{"accessToken":"test-token","expiresInSeconds":1800}`) + return + } + q := r.URL.Query() + *lastQuery = q + handler(w, r) + })) + t.Cleanup(srv.Close) + + client, err := CreateAppleMapsClient(AppleMapsConfig{ + TeamID: "TEAM123456", KeyID: "KEY7890123", + PrivateKey: appleTestKey(t), BaseURL: srv.URL, + }) + if err != nil { + t.Fatalf("CreateAppleMapsClient: %v", err) + } + return client, lastQuery +} + +// Apple omits administrativeAreaCode for countries with no conventional +// subdivision abbreviation — confirmed live for France, Germany, and Japan, +// while the US, Australia, and Canada all return one. Google fills +// AdminAreaLevelOne from administrative_area_level_1's ShortName, which falls +// back to the long name in exactly those cases, so the adapter must too. +func TestAppleReverseGeocodeMapsAdminAreaBothWays(t *testing.T) { + tests := []struct { + name string + body string + wantCity string + wantAdmin string + wantCtry string + }{ + { + name: "code present", + body: `{"results":[{"country":"United States","countryCode":"US", + "coordinate":{"latitude":37.33,"longitude":-122.03}, + "structuredAddress":{"locality":"Cupertino", + "administrativeArea":"California","administrativeAreaCode":"CA"}}]}`, + wantCity: "Cupertino", wantAdmin: "CA", wantCtry: "United States", + }, + { + name: "code absent falls back to the name", + body: `{"results":[{"country":"France","countryCode":"FR", + "coordinate":{"latitude":48.85,"longitude":2.29}, + "structuredAddress":{"locality":"Paris", + "administrativeArea":"Île-de-France"}}]}`, + wantCity: "Paris", wantAdmin: "Île-de-France", wantCtry: "France", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client, _ := appleTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, tc.body) + }) + got, err := client.ReverseGeocode(context.Background(), 37.33, -122.03) + if err != nil { + t.Fatalf("ReverseGeocode: %v", err) + } + if got.City != tc.wantCity || got.AdminAreaLevelOne != tc.wantAdmin || got.Country != tc.wantCtry { + t.Errorf("got %+v, want {%s %s %s}", *got, tc.wantCity, tc.wantAdmin, tc.wantCtry) + } + }) + } +} + +// Forward-geocoding a query that names an administrative area rather than a +// locality returns an empty locality — confirmed live for "Tokyo, Tokyo, Japan". +// PoiSearcher.Geocode writes the mutated query to the geocode:cities cache, so a +// blank City must not reach it. Google can clobber freely because its component +// matching always yields a locality; Apple cannot. +func TestAppleGeocodeNeverOverwritesWithAnEmptyValue(t *testing.T) { + client, _ := appleTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"results":[{"country":"Japan","countryCode":"JP", + "coordinate":{"latitude":35.6895,"longitude":139.6917}, + "structuredAddress":{"administrativeArea":"Tokyo"}}]}`) + }) + + query := &GeocodeQuery{City: "Tokyo", AdminAreaLevelOne: "Tokyo", Country: "Japan"} + lat, lng, err := client.Geocode(context.Background(), query) + if err != nil { + t.Fatalf("Geocode: %v", err) + } + if lat != 35.6895 || lng != 139.6917 { + t.Errorf("coordinate: got %v,%v", lat, lng) + } + if query.City != "Tokyo" { + t.Errorf("City: got %q, want the caller's %q preserved", query.City, "Tokyo") + } +} + +// Google takes structured components; Apple takes one free-text q. Probed live: +// "Paris, TX, United States" and "Paris, Île-de-France, France" resolve to +// different, correct coordinates, so the flattening preserves disambiguation. +// Empty fields must be dropped rather than left as empty segments. +func TestAppleGeocodeFlattensTheQuery(t *testing.T) { + tests := []struct { + name string + query GeocodeQuery + wantQ string + }{ + {"all three", GeocodeQuery{City: "Paris", AdminAreaLevelOne: "TX", Country: "United States"}, "Paris, TX, United States"}, + {"no admin area", GeocodeQuery{City: "Paris", Country: "France"}, "Paris, France"}, + {"city only", GeocodeQuery{City: "Paris"}, "Paris"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client, lastQuery := appleTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"results":[{"country":"United States", + "coordinate":{"latitude":33.66,"longitude":-95.55}, + "structuredAddress":{"locality":"Paris","administrativeAreaCode":"TX"}}]}`) + }) + q := tc.query + if _, _, err := client.Geocode(context.Background(), &q); err != nil { + t.Fatalf("Geocode: %v", err) + } + if got := lastQuery.Get("q"); got != tc.wantQ { + t.Errorf("q: got %q, want %q", got, tc.wantQ) + } + }) + } +} + +func TestAppleGeocodeRejectsAnEmptyQuery(t *testing.T) { + client, _ := appleTestClient(t, func(http.ResponseWriter, *http.Request) { + t.Error("no request should be sent for an empty query") + }) + if _, _, err := client.Geocode(context.Background(), &GeocodeQuery{}); err == nil { + t.Error("want an error when every field is empty") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./iowrappers/ -run TestApple -v` +Expected: FAIL — `undefined: CreateAppleMapsClient`, `undefined: AppleMapsConfig`. + +- [ ] **Step 3: Write minimal implementation** + +Create `iowrappers/apple_maps_client.go`: + +```go +package iowrappers + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/weihesdlegend/Vacation-planner/applemaps" +) + +// AppleMapsConfig carries the credentials and transport for an AppleMapsClient. +type AppleMapsConfig struct { + TeamID string + KeyID string + // PrivateKey is the .p8 contents, either raw PEM or base64-encoded PEM. + PrivateKey string + // BaseURL defaults to Apple's. Tests point it at an httptest server. + BaseURL string + // HTTPClient carries the quota-counting transport when one is wired in. + HTTPClient *http.Client +} + +// AppleMapsClient adapts the applemaps package to Geocoder. +// +// It implements Geocoder and not SearchClient deliberately. Apple's Place object +// carries no opening hours, rating, price level, or photo at any endpoint or +// tier, and there is no join key from an Apple place ID back to a Google +// place_id, so those fields could never be backfilled. A place search served +// from Apple would be permanently hours-less. Address data has no such gap. +type AppleMapsClient struct { + client *applemaps.Client +} + +func CreateAppleMapsClient(cfg AppleMapsConfig) (*AppleMapsClient, error) { + key, err := applemaps.ParsePrivateKey(cfg.PrivateKey) + if err != nil { + return nil, err + } + client, err := applemaps.New(applemaps.Options{ + TeamID: cfg.TeamID, + KeyID: cfg.KeyID, + PrivateKey: key, + BaseURL: cfg.BaseURL, + HTTPClient: cfg.HTTPClient, + }) + if err != nil { + return nil, err + } + return &AppleMapsClient{client: client}, nil +} + +// appleQueryString flattens a GeocodeQuery into Apple's single free-text q. +// +// Google matches structured components — ComponentLocality, ComponentCountry, +// ComponentAdministrativeArea — per field. Apple has no component parameters at +// all. Probing confirmed the flattened form still disambiguates: "Paris, TX, +// United States" and "Paris, Île-de-France, France" resolve to different, +// correct coordinates. Empty fields are dropped so the query never carries a +// bare separator. +func appleQueryString(query *GeocodeQuery) string { + parts := make([]string, 0, 3) + for _, field := range []string{query.City, query.AdminAreaLevelOne, query.Country} { + if trimmed := strings.TrimSpace(field); trimmed != "" { + parts = append(parts, trimmed) + } + } + return strings.Join(parts, ", ") +} + +// appleAdminAreaLevelOne mirrors what Google writes into AdminAreaLevelOne: +// administrative_area_level_1's ShortName, which is an abbreviation where one +// exists and the long name otherwise. Apple splits those across two fields and +// omits the code for countries without conventional abbreviations. +func appleAdminAreaLevelOne(address *applemaps.StructuredAddress) string { + if address.AdministrativeAreaCode != "" { + return address.AdministrativeAreaCode + } + return address.AdministrativeArea +} + +// applyApplePlace copies an Apple place's address onto a GeocodeQuery. +// +// A field is assigned only when Apple returned something for it. Forward +// geocoding a query that names an administrative area returns no locality, and +// PoiSearcher.Geocode writes the mutated query straight into the geocode:cities +// cache, so overwriting the caller's City with "" would poison the cache key. +func applyApplePlace(query *GeocodeQuery, place applemaps.Place) { + if address := place.StructuredAddress; address != nil { + if address.Locality != "" { + query.City = address.Locality + } + if adminArea := appleAdminAreaLevelOne(address); adminArea != "" { + query.AdminAreaLevelOne = adminArea + } + } + if place.Country != "" { + query.Country = place.Country + } +} + +func (c *AppleMapsClient) Geocode(ctx context.Context, query *GeocodeQuery) (float64, float64, error) { + q := appleQueryString(query) + if q == "" { + return 0, 0, errors.New("applemaps: geocode query has no city, administrative area, or country") + } + + places, err := c.client.Geocode(ctx, applemaps.GeocodeRequest{Q: q}) + if err != nil { + return 0, 0, err + } + + // applemaps.Geocode converts an empty result set into *NotFoundError, so a + // nil error guarantees at least one place. + place := places[0] + applyApplePlace(query, place) + return place.Coordinate.Latitude, place.Coordinate.Longitude, nil +} + +func (c *AppleMapsClient) ReverseGeocode(ctx context.Context, latitude, longitude float64) (*GeocodeQuery, error) { + places, err := c.client.ReverseGeocode(ctx, applemaps.ReverseGeocodeRequest{ + Latitude: latitude, + Longitude: longitude, + }) + if err != nil { + return nil, err + } + + query := &GeocodeQuery{} + applyApplePlace(query, places[0]) + return query, nil +} +``` + +Add to `iowrappers/interfaces_test.go`: `_ Geocoder = (*AppleMapsClient)(nil)`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go build ./... && go vet ./... && go test ./iowrappers/ -run TestApple -v` +Expected: PASS, all four tests including every subtest. + +- [ ] **Step 5: Commit** + +```bash +git add iowrappers/apple_maps_client.go iowrappers/apple_maps_client_test.go iowrappers/interfaces_test.go +git commit -m "feat(iowrappers): Apple Maps geocoding adapter" +``` + +--- + +### Task 4: Quota counter + +**Files:** +- Create: `iowrappers/apple_quota.go` +- Test: `test/redis_client_mocks/apple_quota_test.go` + +**Interfaces:** +- Consumes: `RedisClient`. +- Produces: `NewQuotaCounter(redisClient *RedisClient, cfg QuotaConfig) *QuotaCounter`; `QuotaConfig{DailyLimit int; Threshold float64; ExternalAllowance int}`; methods `(*QuotaCounter).OverThreshold(ctx context.Context) bool` and `(*QuotaCounter).Transport(base http.RoundTripper) http.RoundTripper`; exported `AppleDailyCallQuota = 25000`. + +- [ ] **Step 1: Write the failing test** + +Create `test/redis_client_mocks/apple_quota_test.go`: + +```go +package redis_client_mocks + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/weihesdlegend/Vacation-planner/iowrappers" +) + +func newTestQuotaCounter(limit int, threshold float64, allowance int) *iowrappers.QuotaCounter { + return iowrappers.NewQuotaCounter(RedisClient, iowrappers.QuotaConfig{ + DailyLimit: limit, + Threshold: threshold, + ExternalAllowance: allowance, + }) +} + +// Apple charges for every HTTP round trip, not every logical geocode: a stale +// token costs a /v1/token exchange and a 5xx costs a retry. Counting logical +// operations would under-report by a factor that varies with the failure rate, +// worst exactly when the budget is tightest. So the counter sits in the +// transport. +func TestQuotaCounterCountsEveryRoundTrip(t *testing.T) { + RedisMockSvr.FlushAll() + counter := newTestQuotaCounter(100, 0.9, 0) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + client := &http.Client{Transport: counter.Transport(http.DefaultTransport)} + for range 3 { + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("get: %v", err) + } + _ = resp.Body.Close() + } + + if got := counter.Count(context.Background()); got != 3 { + t.Errorf("count: got %d, want 3", got) + } +} + +func TestQuotaCounterThreshold(t *testing.T) { + tests := []struct { + name string + limit int + threshold float64 + allowance int + spend int + want bool + }{ + {"well under", 100, 0.9, 0, 10, false}, + {"just under", 100, 0.9, 0, 89, false}, + {"at the threshold", 100, 0.9, 0, 90, true}, + // The 25,000 is shared with MapKit JS, so traffic this counter cannot see + // still spends the budget. The allowance is charged before our own calls. + {"allowance alone crosses it", 100, 0.9, 90, 0, true}, + {"allowance plus spend crosses it", 100, 0.9, 50, 40, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + RedisMockSvr.FlushAll() + counter := newTestQuotaCounter(tc.limit, tc.threshold, tc.allowance) + ctx := context.Background() + for range tc.spend { + counter.Record(ctx) + } + if got := counter.OverThreshold(ctx); got != tc.want { + t.Errorf("OverThreshold: got %v, want %v", got, tc.want) + } + }) + } +} + +// The counter is a cost guard, not a correctness guard. If Redis is unreachable +// the request must still be served rather than failing closed. +// +// This uses its own miniredis rather than closing the package-wide one, which +// every other test in this package shares — stopping and restarting that server +// would make this test's failure mode depend on execution order. +func TestQuotaCounterRedisFailureDoesNotBlock(t *testing.T) { + deadSvr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis: %v", err) + } + deadURL, err := url.Parse("redis://" + deadSvr.Addr()) + if err != nil { + t.Fatalf("parse url: %v", err) + } + deadClient := iowrappers.CreateRedisClient(deadURL) + deadSvr.Close() + + counter := iowrappers.NewQuotaCounter(deadClient, iowrappers.QuotaConfig{ + DailyLimit: 100, Threshold: 0.9, + }) + ctx := context.Background() + + if counter.OverThreshold(ctx) { + t.Error("a Redis failure must not report the quota as exhausted") + } + // Record must also swallow the failure rather than panicking. + counter.Record(ctx) +} + +// A 48 hour expiry keeps yesterday's key readable for diagnosis without letting +// the keyspace grow without bound. +func TestQuotaCounterKeyExpires(t *testing.T) { + RedisMockSvr.FlushAll() + counter := newTestQuotaCounter(100, 0.9, 0) + counter.Record(context.Background()) + + ttl := RedisMockSvr.TTL(counter.Key()) + if ttl <= 0 { + t.Fatalf("TTL: got %v, want a positive expiry", ttl) + } + if ttl.Hours() > 48 { + t.Errorf("TTL: got %v, want at most 48h", ttl) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./test/redis_client_mocks/ -run TestQuota -v` +Expected: FAIL — `undefined: iowrappers.NewQuotaCounter`. + +- [ ] **Step 3: Write minimal implementation** + +Create `iowrappers/apple_quota.go`: + +```go +package iowrappers + +import ( + "context" + "net/http" + "time" +) + +// AppleDailyCallQuota is Apple's free daily service call allowance, per +// developer team. It is shared between the Maps Server API and MapKit JS. +const AppleDailyCallQuota = 25000 + +// appleQuotaKeyExpiry keeps the previous day's counter readable for diagnosis +// while bounding the keyspace. +const appleQuotaKeyExpiry = 48 * time.Hour + +// QuotaConfig configures a QuotaCounter. +type QuotaConfig struct { + // DailyLimit is the team's daily allowance. Zero means AppleDailyCallQuota. + DailyLimit int + // Threshold is the fraction of the allowance at which we stop using Apple. + // Zero means 0.9. Stopping early is deliberate: once the quota is exhausted + // Apple returns 429 on every endpoint including /v1/token, so waiting for the + // cliff would fail the whole provider over at an unpredictable moment. + Threshold float64 + // ExternalAllowance is how many calls per day are assumed to be spent by + // other consumers on the same team, principally MapKit JS. This counter can + // only ever observe our own traffic, so a threshold applied to our count + // alone is an under-estimate of true consumption rather than a measure of it. + ExternalAllowance int +} + +// QuotaCounter tracks outbound Apple calls for the current UTC day. +type QuotaCounter struct { + redisClient *RedisClient + cfg QuotaConfig + + // now is injectable so date rollover is testable. + now func() time.Time +} + +func NewQuotaCounter(redisClient *RedisClient, cfg QuotaConfig) *QuotaCounter { + if cfg.DailyLimit <= 0 { + cfg.DailyLimit = AppleDailyCallQuota + } + if cfg.Threshold <= 0 { + cfg.Threshold = 0.9 + } + return &QuotaCounter{redisClient: redisClient, cfg: cfg, now: time.Now} +} + +// Key is the counter's Redis key for the current UTC day. +func (q *QuotaCounter) Key() string { + return "applemaps:quota:" + q.now().UTC().Format("2006-01-02") +} + +// Record increments today's counter. A Redis failure is logged and ignored: the +// counter is a cost guard, and failing a request over it would trade a small +// overspend for an outage. +func (q *QuotaCounter) Record(ctx context.Context) { + key := q.Key() + count, err := q.redisClient.client.Incr(ctx, key).Result() + if err != nil { + Logger.Debugw("applemaps quota: increment failed", "key", key, "error", err) + return + } + // Only the first increment of the day needs the expiry set. + if count == 1 { + if err := q.redisClient.client.Expire(ctx, key, appleQuotaKeyExpiry).Err(); err != nil { + Logger.Debugw("applemaps quota: expire failed", "key", key, "error", err) + } + } +} + +// Count returns today's recorded call count, or 0 if Redis is unreachable. +func (q *QuotaCounter) Count(ctx context.Context) int64 { + count, err := q.redisClient.client.Get(ctx, q.Key()).Int64() + if err != nil { + return 0 + } + return count +} + +// OverThreshold reports whether Apple should be skipped for this request. It +// returns false when Redis is unreachable, so a cache outage degrades to +// spending quota rather than to refusing to use the provider. +func (q *QuotaCounter) OverThreshold(ctx context.Context) bool { + budget := float64(q.cfg.DailyLimit) * q.cfg.Threshold + spent := float64(q.Count(ctx) + int64(q.cfg.ExternalAllowance)) + return spent >= budget +} + +// quotaTransport increments the counter once per HTTP round trip. +type quotaTransport struct { + base http.RoundTripper + counter *QuotaCounter +} + +func (t *quotaTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.counter.Record(req.Context()) + return t.base.RoundTrip(req) +} + +// Transport wraps base so every request through it is counted, including token +// exchanges and retries — the two costs a per-search counter would miss. +func (q *QuotaCounter) Transport(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return "aTransport{base: base, counter: q} +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go build ./... && go vet ./... && go test ./test/redis_client_mocks/ -run TestQuota -v` +Expected: PASS, all four tests including every threshold subtest. + +- [ ] **Step 5: Commit** + +```bash +git add iowrappers/apple_quota.go test/redis_client_mocks/apple_quota_test.go +git commit -m "feat(iowrappers): Apple Maps daily quota counter" +``` + +--- + +### Task 5: `AppleGeocodeRouter` + +**Files:** +- Create: `iowrappers/apple_geocode_router.go` +- Test: `iowrappers/apple_geocode_router_test.go` + +**Interfaces:** +- Consumes: `Geocoder`, `SearchClient` (Task 1), `QuotaCounter` (Task 4), and the `stubSearchClient` test double defined in `iowrappers/poi_searcher_seam_test.go` (Task 2) — same package, so it is reused rather than redeclared. +- Produces: `NewAppleGeocodeRouter(apple Geocoder, google SearchClient, quota *QuotaCounter) *AppleGeocodeRouter`, satisfying `SearchClient`. The `stubGeocoder` test double defined here is reused by Task 6. + +- [ ] **Step 1: Write the failing test** + +Create `iowrappers/apple_geocode_router_test.go`: + +```go +package iowrappers + +import ( + "context" + "errors" + "testing" + + "github.com/weihesdlegend/Vacation-planner/POI" + "github.com/weihesdlegend/Vacation-planner/applemaps" +) + +// stubGeocoder stands in for Apple. +type stubGeocoder struct { + calls int + query *GeocodeQuery + lat float64 + lng float64 + err error +} + +func (s *stubGeocoder) Geocode(_ context.Context, query *GeocodeQuery) (float64, float64, error) { + s.calls++ + if s.err != nil { + return 0, 0, s.err + } + if s.query != nil { + *query = *s.query + } + return s.lat, s.lng, nil +} + +func (s *stubGeocoder) ReverseGeocode(context.Context, float64, float64) (*GeocodeQuery, error) { + s.calls++ + if s.err != nil { + return nil, s.err + } + return s.query, nil +} + +func TestRouterUsesAppleAndSkipsGoogleOnSuccess(t *testing.T) { + apple := &stubGeocoder{lat: 48.85, lng: 2.29, query: &GeocodeQuery{City: "Paris"}} + google := &stubSearchClient{} + router := NewAppleGeocodeRouter(apple, google, nil) + + lat, lng, err := router.Geocode(context.Background(), &GeocodeQuery{City: "Paris"}) + if err != nil { + t.Fatalf("Geocode: %v", err) + } + if lat != 48.85 || lng != 2.29 { + t.Errorf("coordinate: got %v,%v want 48.85,2.29", lat, lng) + } + if google.geocodeCalls != 0 { + t.Errorf("google geocode calls: got %d, want 0", google.geocodeCalls) + } +} + +// Each of these is a distinct reason Apple cannot answer, and each must cost one +// Apple attempt and then a Google call rather than an error to the caller. +func TestRouterFallsBackToGoogle(t *testing.T) { + tests := []struct { + name string + err error + }{ + {"transport or API error", errors.New("boom")}, + {"quota exhausted", &applemaps.QuotaError{APIError: &applemaps.APIError{StatusCode: 429, Message: "quota"}}}, + // Apple answers an unmatched geocode with HTTP 200 and an empty array; + // the SDK converts that to NotFoundError. Google may still find it. + {"no match", &applemaps.NotFoundError{Query: "nowhere"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + apple := &stubGeocoder{err: tc.err} + google := &stubSearchClient{} + router := NewAppleGeocodeRouter(apple, google, nil) + + if _, _, err := router.Geocode(context.Background(), &GeocodeQuery{City: "Paris"}); err != nil { + t.Fatalf("Geocode: %v", err) + } + if apple.calls != 1 { + t.Errorf("apple calls: got %d, want 1", apple.calls) + } + if google.geocodeCalls != 1 { + t.Errorf("google geocode calls: got %d, want 1", google.geocodeCalls) + } + + apple.calls, google.reverseGeocodeCalls = 0, 0 + if _, err := router.ReverseGeocode(context.Background(), 1, 2); err != nil { + t.Fatalf("ReverseGeocode: %v", err) + } + if apple.calls != 1 || google.reverseGeocodeCalls != 1 { + t.Errorf("reverse: apple=%d google=%d, want 1 and 1", apple.calls, google.reverseGeocodeCalls) + } + }) + } +} + +// Apple has no opening hours at any tier and no join key back to a Google +// place_id, so a place it returned could never be enriched. There is no +// condition under which it serves a place search. +func TestRouterNeverSendsNearbySearchToApple(t *testing.T) { + apple := &stubGeocoder{} + google := &stubSearchClient{} + router := NewAppleGeocodeRouter(apple, google, nil) + + if _, err := router.NearbySearch(context.Background(), &PlaceSearchRequest{ + PlaceCat: POI.PlaceCategoryEatery, + }); err != nil { + t.Fatalf("NearbySearch: %v", err) + } + if google.nearbySearchCalls != 1 { + t.Errorf("google nearby calls: got %d, want 1", google.nearbySearchCalls) + } + if apple.calls != 0 { + t.Errorf("apple calls: got %d, want 0 — Apple must never serve a place search", apple.calls) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./iowrappers/ -run TestRouter -v` +Expected: FAIL — `undefined: NewAppleGeocodeRouter`. + +- [ ] **Step 3: Write minimal implementation** + +Create `iowrappers/apple_geocode_router.go`: + +```go +package iowrappers + +import ( + "context" + "errors" + + "github.com/weihesdlegend/Vacation-planner/POI" + "github.com/weihesdlegend/Vacation-planner/applemaps" +) + +// AppleGeocodeRouter serves geocoding from Apple and everything else from Google. +// +// The split is a property of this type rather than a rule spread across +// PoiSearcher: Apple can answer address questions completely, and cannot answer +// place questions at all, so NearbySearch has no routing decision to make. +type AppleGeocodeRouter struct { + apple Geocoder + google SearchClient + // quota may be nil, which disables the pre-emptive check. + quota *QuotaCounter +} + +func NewAppleGeocodeRouter(apple Geocoder, google SearchClient, quota *QuotaCounter) *AppleGeocodeRouter { + return &AppleGeocodeRouter{apple: apple, google: google, quota: quota} +} + +// useApple reports whether Apple should be tried, logging the reason when not. +func (r *AppleGeocodeRouter) useApple(ctx context.Context, method string) bool { + if r.apple == nil { + return false + } + if r.quota != nil && r.quota.OverThreshold(ctx) { + Logger.Infow("applemaps: routing to Google", "method", method, "reason", "daily quota threshold reached") + return false + } + return true +} + +// logFallback records why an Apple attempt was abandoned. A fallback costs one +// Apple call plus one Google call, so a silent one hides real spend and makes +// the Apple hit rate unmeasurable. +func logFallback(method string, err error) { + reason := "error" + var quotaErr *applemaps.QuotaError + var notFound *applemaps.NotFoundError + switch { + case errors.As(err, "aErr): + reason = "quota exhausted" + case errors.As(err, ¬Found): + reason = "no match" + } + Logger.Infow("applemaps: falling back to Google", "method", method, "reason", reason, "error", err) +} + +func (r *AppleGeocodeRouter) Geocode(ctx context.Context, query *GeocodeQuery) (float64, float64, error) { + if r.useApple(ctx, "Geocode") { + // Apple's adapter mutates the query it is given, so it gets a copy: a + // failed attempt must not leave half-corrected fields for Google. + attempt := *query + lat, lng, err := r.apple.Geocode(ctx, &attempt) + if err == nil { + *query = attempt + return lat, lng, nil + } + logFallback("Geocode", err) + } + return r.google.Geocode(ctx, query) +} + +func (r *AppleGeocodeRouter) ReverseGeocode(ctx context.Context, latitude, longitude float64) (*GeocodeQuery, error) { + if r.useApple(ctx, "ReverseGeocode") { + query, err := r.apple.ReverseGeocode(ctx, latitude, longitude) + if err == nil { + return query, nil + } + logFallback("ReverseGeocode", err) + } + return r.google.ReverseGeocode(ctx, latitude, longitude) +} + +// NearbySearch always goes to Google. See the type comment. +func (r *AppleGeocodeRouter) NearbySearch(ctx context.Context, request *PlaceSearchRequest) ([]POI.Place, error) { + return r.google.NearbySearch(ctx, request) +} +``` + +Add `_ SearchClient = (*AppleGeocodeRouter)(nil)` to `iowrappers/interfaces_test.go`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go build ./... && go vet ./... && go test ./iowrappers/ -run TestRouter -v` +Expected: PASS, all three tests including every fallback subtest. + +- [ ] **Step 5: Commit** + +```bash +git add iowrappers/apple_geocode_router.go iowrappers/apple_geocode_router_test.go iowrappers/interfaces_test.go +git commit -m "feat(iowrappers): route geocoding to Apple with Google fallback" +``` + +--- + +### Task 6: Configuration and enablement + +**Files:** +- Modify: `main.go:23-37` (Config struct), `:92-94` (Init call) +- Modify: `planner/planner.go:148` (Init signature), `:184` +- Test: `iowrappers/apple_wiring_test.go` (create) + +**Interfaces:** +- Consumes: everything above, plus two test doubles already in the package — `stubSearchClient` from Task 2 and `appleTestKey` from Task 3's test file. +- Produces: `AppleMapsSettings{Enabled bool; TeamID, KeyID, PrivateKey string; QuotaThreshold float64; ExternalAllowance int}`; `(*PoiSearcher).EnableAppleMaps(settings AppleMapsSettings) error`; `planner.MyPlanner.Init` gains a final `appleMaps iowrappers.AppleMapsSettings` parameter. + +- [ ] **Step 1: Write the failing test** + +Create `iowrappers/apple_wiring_test.go`: + +```go +package iowrappers + +import "testing" + +// A bad key must degrade to Google-only, never take the service down. Credentials +// arrive from the environment and a deploy-time mistake is entirely possible. +func TestEnableAppleMapsWithBadCredentialsLeavesGoogleInPlace(t *testing.T) { + google := &stubSearchClient{} + s := &PoiSearcher{searcher: google} + + err := s.EnableAppleMaps(AppleMapsSettings{ + Enabled: true, TeamID: "T", KeyID: "K", PrivateKey: "not a key", + }) + if err == nil { + t.Fatal("want an error for an unparseable private key") + } + if s.searcher != SearchClient(google) { + t.Error("a bad key must leave the Google client in place") + } +} + +// Disabled is the default, and the router must not be constructed at all — the +// zero configuration is Google-only with nothing extra in the request path. +func TestEnableAppleMapsDisabledIsANoOp(t *testing.T) { + google := &stubSearchClient{} + s := &PoiSearcher{searcher: google} + + if err := s.EnableAppleMaps(AppleMapsSettings{Enabled: false}); err != nil { + t.Fatalf("EnableAppleMaps: %v", err) + } + if _, wrapped := s.searcher.(*AppleGeocodeRouter); wrapped { + t.Error("Apple disabled must not wrap the searcher") + } +} + +func TestEnableAppleMapsWrapsTheSearcher(t *testing.T) { + google := &stubSearchClient{} + s := &PoiSearcher{searcher: google} + + if err := s.EnableAppleMaps(AppleMapsSettings{ + Enabled: true, TeamID: "TEAM123456", KeyID: "KEY7890123", + PrivateKey: appleTestKey(t), + }); err != nil { + t.Fatalf("EnableAppleMaps: %v", err) + } + if _, wrapped := s.searcher.(*AppleGeocodeRouter); !wrapped { + t.Error("want the searcher wrapped in an AppleGeocodeRouter") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./iowrappers/ -run TestEnableAppleMaps -v` +Expected: FAIL — `undefined: AppleMapsSettings`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `iowrappers/apple_geocode_router.go`: + +```go +// AppleMapsSettings is the deployment-time configuration for Apple Maps. +type AppleMapsSettings struct { + // Enabled is false by default. Nothing Apple-derived is requested or cached + // until it is deliberately turned on, which is also what keeps the + // unresolved question of Apple's caching terms off the merge path. + Enabled bool + // TeamID is the Apple Developer team ID, the JWT iss claim. + TeamID string + // KeyID is the MapKit key ID, the JWT kid header. + KeyID string + // PrivateKey is the .p8 contents, raw PEM or base64-encoded PEM. + PrivateKey string + // QuotaThreshold is the fraction of the daily allowance at which Apple stops + // being used. Zero means the QuotaConfig default. + QuotaThreshold float64 + // ExternalAllowance is the calls per day assumed spent by MapKit JS on the + // same team, which this service cannot observe. + ExternalAllowance int +} + +// EnableAppleMaps routes geocoding through Apple, keeping Google as the +// fallback and as the only place-search provider. +// +// It returns an error rather than logging and continuing so the caller can +// decide, but the caller in main deliberately continues: a missing or malformed +// credential should cost the quota saving, not the service. +func (s *PoiSearcher) EnableAppleMaps(settings AppleMapsSettings) error { + if !settings.Enabled { + return nil + } + + quota := NewQuotaCounter(s.redisClient, QuotaConfig{ + Threshold: settings.QuotaThreshold, + ExternalAllowance: settings.ExternalAllowance, + }) + + // The counter lives in the transport so token exchanges and retries are + // counted alongside the calls a search makes directly. + appleClient, err := CreateAppleMapsClient(AppleMapsConfig{ + TeamID: settings.TeamID, + KeyID: settings.KeyID, + PrivateKey: settings.PrivateKey, + HTTPClient: &http.Client{ + Timeout: 15 * time.Second, + Transport: quota.Transport(nil), + }, + }) + if err != nil { + return err + } + + s.searcher = NewAppleGeocodeRouter(appleClient, s.searcher, quota) + Logger.Infow("applemaps: geocoding enabled", "team", settings.TeamID) + return nil +} +``` + +Add `"net/http"` and `"time"` to that file's imports. + +In `main.go`, extend the `Config` struct: + +```go + AppleMaps struct { + Enabled bool `envconfig:"APPLE_MAPS_ENABLED" default:"false"` + TeamID string `envconfig:"APPLE_MAPS_TEAM_ID"` + KeyID string `envconfig:"APPLE_MAPS_KEY_ID"` + PrivateKey string `envconfig:"APPLE_MAPS_PRIVATE_KEY"` + QuotaThreshold float64 `envconfig:"APPLE_MAPS_QUOTA_THRESHOLD" default:"0.9"` + ExternalAllowance int `envconfig:"APPLE_MAPS_EXTERNAL_ALLOWANCE" default:"0"` + } +``` + +Pass it through the `Init` call: + +```go + myPlanner.Init(conf.MapsClientApiKey, redisURL, conf.Redis.RedisStreamName, + flattenConfig(configs), conf.GoogleOAuthClientID, conf.GoogleOAuthClientSecret, + conf.Server.Domain, conf.GeonamesApiKey, conf.BlobBucketId, + iowrappers.AppleMapsSettings{ + Enabled: conf.AppleMaps.Enabled, + TeamID: conf.AppleMaps.TeamID, + KeyID: conf.AppleMaps.KeyID, + PrivateKey: conf.AppleMaps.PrivateKey, + QuotaThreshold: conf.AppleMaps.QuotaThreshold, + ExternalAllowance: conf.AppleMaps.ExternalAllowance, + }) +``` + +In `planner/planner.go:148`, add the parameter to `Init`'s signature — +`appleMaps iowrappers.AppleMapsSettings` as the final argument — and enable it +immediately after `CreatePoiSearcher`: + +```go + PoiSearcher := iowrappers.CreatePoiSearcher(mapsClientApiKey, redisURL, placeDetailsFields) + if err := PoiSearcher.EnableAppleMaps(appleMaps); err != nil { + // Degrade to Google-only. A bad Apple credential costs the quota saving, + // not the service. + logger.Warnf("Apple Maps disabled: %v", err) + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go build ./... && go vet ./... && go test ./...` +Expected: PASS. Confirm the default is off: `APPLE_MAPS_ENABLED` unset means `EnableAppleMaps` returns immediately and no router is constructed. + +- [ ] **Step 5: Commit** + +```bash +git add main.go planner/planner.go iowrappers/apple_geocode_router.go iowrappers/apple_wiring_test.go +git commit -m "feat: wire Apple Maps geocoding behind APPLE_MAPS_ENABLED" +``` + +--- + +## Verification + +After Task 6, end to end: + +1. `gofmt -l . && go build ./... && go vet ./... && go test ./...` — clean. +2. `go test -race -count=2 ./iowrappers/ ./test/redis_client_mocks/` — clean. +3. `grep -rn "GetMapsClient" --include="*.go" .` — no results. +4. Google-only path unchanged: run the service with `APPLE_MAPS_ENABLED` unset + and confirm `POST /v1/nearby-places-by-category` returns places with populated + `rating` and `hours`, exactly as before. +5. With credentials set and `APPLE_MAPS_ENABLED=true`, call + `GET /v1/reverse-geocoding?latitude=37.3316851&longitude=-122.0300674` and + confirm `{"city":"Cupertino","admin_area_level_one":"CA","country":"United States"}` + — the values the live probe recorded — and that the log shows no fallback. +6. Repeat step 5 with a non-abbreviating country, latitude `48.8583701` + longitude `2.2944813`, expecting + `{"city":"Paris","admin_area_level_one":"Île-de-France","country":"France"}`. + This is the case the `administrativeAreaCode` fallback exists for. +7. Confirm the quota key exists and counts more than the number of requests made, + since the first request also pays for a token exchange: + `redis-cli GET applemaps:quota:$(date -u +%F)`. +8. Set `APPLE_MAPS_QUOTA_THRESHOLD=0.0001` and confirm the next request logs + `routing to Google` with reason `daily quota threshold reached` and still + returns a correct answer. + +## Out of scope + +Recorded so they are not mistaken for omissions. + +- Place search on Apple. Structurally blocked by missing opening hours with no + join key to backfill them; see the design's scope section. +- A TTL on the `geocode:cities` hash. It has none today, which is a live question + for Apple's caching terms, but changing it also changes Google-sourced data and + belongs to that decision rather than this one. +- Confirming `AdminAreaLevelOne` against Google by paired call. No Google API key + was available; the mapping rests on documented `ShortName` behaviour plus the + live Apple probe. From 59bafc2f100356253f3dc6031bd6be21127e02e9 Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Thu, 13 Aug 2026 20:51:43 -0700 Subject: [PATCH 10/18] refactor(iowrappers): split Geocoder out of SearchClient Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YXnUPPE6LvNbzU1QnYuUCn --- iowrappers/interfaces_test.go | 10 ++++++++++ iowrappers/maps_client.go | 21 ++++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 iowrappers/interfaces_test.go diff --git a/iowrappers/interfaces_test.go b/iowrappers/interfaces_test.go new file mode 100644 index 00000000..69e07fb1 --- /dev/null +++ b/iowrappers/interfaces_test.go @@ -0,0 +1,10 @@ +package iowrappers + +// Compile-time proof that the concrete Google client still satisfies every +// interface it is assigned to after the split. A break here is a build failure +// at the point of the mistake rather than at some distant call site. +var ( + _ Geocoder = (*MapsClient)(nil) + _ SearchClient = (*MapsClient)(nil) + _ PlaceDetailsClient = (*MapsClient)(nil) +) diff --git a/iowrappers/maps_client.go b/iowrappers/maps_client.go index 1fb6d174..2b798951 100644 --- a/iowrappers/maps_client.go +++ b/iowrappers/maps_client.go @@ -15,11 +15,26 @@ import ( "googlemaps.github.io/maps" ) -// SearchClient defines an interface of a client that performs location-based operations such as nearby search -type SearchClient interface { +// Geocoder translates between textual locations and coordinates. It is the half +// of SearchClient that Apple Maps can serve: Apple's Place object carries no +// opening hours, rating, or price at any tier, so it cannot serve NearbySearch, +// but its address data is complete. +type Geocoder interface { Geocode(context.Context, *GeocodeQuery) (float64, float64, error) // translate a textual location to latitude and longitude ReverseGeocode(context.Context, float64, float64) (*GeocodeQuery, error) // look up a textual location based on latitude and longitude - NearbySearch(context.Context, *PlaceSearchRequest) ([]POI.Place, error) // search nearby places in a category around a central location +} + +// SearchClient defines an interface of a client that performs location-based operations such as nearby search +type SearchClient interface { + Geocoder + NearbySearch(context.Context, *PlaceSearchRequest) ([]POI.Place, error) // search nearby places in a category around a central location +} + +// PlaceDetailsClient buys the per-place detail record. It is separate from +// SearchClient because it is a capability with exactly one provider — Apple +// exposes no equivalent — and only the data migrations use it. +type PlaceDetailsClient interface { + PlaceDetailedSearch(context.Context, string, []string) (maps.PlaceDetailsResult, error) } // CachedPlaceLookup resolves already-stored place records by ID, returning only the IDs it From 1bf8749971c1bf703af2e5dbaff22016d2fdd290 Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Thu, 13 Aug 2026 20:54:57 -0700 Subject: [PATCH 11/18] refactor(iowrappers): route every provider call through one PoiSearcher seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deviation from the plan doc: the mapsClient field survives as the Google-only capability handle. Text search (text_search.go:210) and the migrations' Place Details wrapper (nearby_search.go:432) both reach the unexported apiSemaphore, which no interface can express, so the planned details field would have had no consumer. GetMapsClient() is still deleted — the leak was external reach-through. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YXnUPPE6LvNbzU1QnYuUCn --- iowrappers/data_migrations.go | 2 +- iowrappers/poi_searcher.go | 43 ++++++++++------ iowrappers/poi_searcher_seam_test.go | 58 ++++++++++++++++++++++ planner/place_search_auth_test.go | 2 +- planner/planner.go | 13 +++-- planner/reclassify_buckets_dry_run_test.go | 4 +- 6 files changed, 96 insertions(+), 26 deletions(-) create mode 100644 iowrappers/poi_searcher_seam_test.go diff --git a/iowrappers/data_migrations.go b/iowrappers/data_migrations.go index 0bbb98ad..e5179ca9 100644 --- a/iowrappers/data_migrations.go +++ b/iowrappers/data_migrations.go @@ -143,7 +143,7 @@ func isPlaceDetailsValid(place POI.Place, nonEmptyFields []PlaceDetailsFields) b // a generic migration method // returns place details results for the calling function to extract and use specific fields func (s *PoiSearcher) addDataFieldsToPlaces(context context.Context, field string, batchSize int) (map[string]PlaceDetailsSearchResult, error) { - mapsClient := s.GetMapsClient() + mapsClient := s.mapsClient redisClient := s.GetRedisClient() placeDetailsKeys, totalPlacesCount, err := redisClient.GetPlaceCountInRedis(context) if err != nil { diff --git a/iowrappers/poi_searcher.go b/iowrappers/poi_searcher.go index a67e2c02..616e67de 100644 --- a/iowrappers/poi_searcher.go +++ b/iowrappers/poi_searcher.go @@ -37,6 +37,15 @@ const ( ) type PoiSearcher struct { + // searcher is the single provider seam. Every outbound Geocode, + // ReverseGeocode, and NearbySearch goes through it, so routing a provider in + // means replacing this one field and nothing else. It is the *MapsClient + // itself unless Apple is enabled. + searcher SearchClient + // mapsClient remains as the Google-only capability handle — text search and + // the migrations' Place Details path both reach unexported MapsClient + // internals (apiSemaphore) that no interface can express. It must never serve + // geocoding or nearby search — those go through searcher. mapsClient *MapsClient redisClient *RedisClient } @@ -66,20 +75,24 @@ type NearbyCityResponse struct { var Logger *zap.SugaredLogger -func CreatePoiSearcher(mapsApiKey string, redisUrl *url.URL) *PoiSearcher { - poiSearcher := PoiSearcher{ - mapsClient: CreateMapsClient(mapsApiKey), - redisClient: CreateRedisClient(redisUrl), +func CreatePoiSearcher(mapsApiKey string, redisUrl *url.URL, detailedSearchFields []string) *PoiSearcher { + mapsClient := CreateMapsClient(mapsApiKey) + redisClient := CreateRedisClient(redisUrl) + + // Both of these used to be poked in from outside after construction, which is + // why PoiSearcher had to expose the concrete client at all. Doing it here + // leaves nothing for callers to reach through for. Wired here rather than in + // CreateMapsClient so the maps client keeps no dependency on Redis. + mapsClient.SetCachedPlaceLookup(redisClient.CachedPlaces) + if len(detailedSearchFields) > 0 { + mapsClient.SetDetailedSearchFields(detailedSearchFields) } - // Let external searches consult the cache before buying Place Details for a place we already - // have. Wired here rather than in CreateMapsClient so the maps client keeps no dependency on - // Redis. - poiSearcher.mapsClient.SetCachedPlaceLookup(poiSearcher.redisClient.CachedPlaces) - return &poiSearcher -} -func (s *PoiSearcher) GetMapsClient() *MapsClient { - return s.mapsClient + return &PoiSearcher{ + searcher: mapsClient, + mapsClient: mapsClient, + redisClient: redisClient, + } } func (s *PoiSearcher) GetRedisClient() *RedisClient { @@ -136,7 +149,7 @@ func (s *PoiSearcher) Geocode(context context.Context, query *GeocodeQuery) (lat var geocodeMissingErr error lat, lng, geocodeMissingErr = s.redisClient.Geocode(context, query) if geocodeMissingErr != nil { - lat, lng, err = s.mapsClient.Geocode(context, query) + lat, lng, err = s.searcher.Geocode(context, query) if err != nil { return } @@ -156,7 +169,7 @@ func (s *PoiSearcher) ReverseGeocode(ctx context.Context, lat, lng float64) (*Ge if cached, err := s.redisClient.ReverseGeocode(ctx, lat, lng); err == nil { return cached, nil } - query, err := s.mapsClient.ReverseGeocode(ctx, lat, lng) + query, err := s.searcher.ReverseGeocode(ctx, lat, lng) if err != nil { return nil, err } @@ -345,7 +358,7 @@ func (s *PoiSearcher) searchPlacesWithMaps(ctx context.Context, req *PlaceSearch // so one API spend populates the cache for a whole area req.Radius = ColdStartSearchRadius - places, err := s.GetMapsClient().NearbySearch(ctx, req) + places, err := s.searcher.NearbySearch(ctx, req) // restore search radius upon search completion req.Radius = originalRadius diff --git a/iowrappers/poi_searcher_seam_test.go b/iowrappers/poi_searcher_seam_test.go new file mode 100644 index 00000000..aab68aef --- /dev/null +++ b/iowrappers/poi_searcher_seam_test.go @@ -0,0 +1,58 @@ +package iowrappers + +import ( + "context" + "testing" + + "github.com/weihesdlegend/Vacation-planner/POI" +) + +// stubSearchClient records which methods a PoiSearcher routed to it. +type stubSearchClient struct { + geocodeCalls int + reverseGeocodeCalls int + nearbySearchCalls int +} + +func (s *stubSearchClient) Geocode(context.Context, *GeocodeQuery) (float64, float64, error) { + s.geocodeCalls++ + return 1, 2, nil +} + +func (s *stubSearchClient) ReverseGeocode(context.Context, float64, float64) (*GeocodeQuery, error) { + s.reverseGeocodeCalls++ + return &GeocodeQuery{City: "Testville"}, nil +} + +func (s *stubSearchClient) NearbySearch(context.Context, *PlaceSearchRequest) ([]POI.Place, error) { + s.nearbySearchCalls++ + return nil, nil +} + +// Every provider call must go through the one seam, so swapping the field is +// enough to reroute the whole client. If any call site still reaches a concrete +// *MapsClient, these counters stay at zero. +func TestPoiSearcherRoutesGeocodingThroughTheSearcherField(t *testing.T) { + stub := &stubSearchClient{} + s := &PoiSearcher{searcher: stub} + + if _, _, err := s.searcher.Geocode(context.Background(), &GeocodeQuery{City: "x"}); err != nil { + t.Fatalf("Geocode: %v", err) + } + if _, err := s.searcher.ReverseGeocode(context.Background(), 1, 2); err != nil { + t.Fatalf("ReverseGeocode: %v", err) + } + if stub.geocodeCalls != 1 || stub.reverseGeocodeCalls != 1 { + t.Errorf("got %d geocode and %d reverse calls, want 1 and 1", + stub.geocodeCalls, stub.reverseGeocodeCalls) + } +} + +// GetMapsClient handed callers the concrete Google client, which is how provider +// choice leaked out of PoiSearcher. Its absence is the invariant. +func TestPoiSearcherHasNoGetMapsClient(t *testing.T) { + var s any = &PoiSearcher{} + if _, leaked := s.(interface{ GetMapsClient() *MapsClient }); leaked { + t.Error("PoiSearcher still exposes GetMapsClient; provider choice must not leak") + } +} diff --git a/planner/place_search_auth_test.go b/planner/place_search_auth_test.go index 4585e9be..3aed6705 100644 --- a/planner/place_search_auth_test.go +++ b/planner/place_search_auth_test.go @@ -28,7 +28,7 @@ func newPlaceSearchTestPlanner(t *testing.T) *MyPlanner { } return &MyPlanner{ RedisClient: redis_client_mocks.RedisClient, - Solver: Solver{Searcher: iowrappers.CreatePoiSearcher("test-maps-api-key", redisURL)}, + Solver: Solver{Searcher: iowrappers.CreatePoiSearcher("test-maps-api-key", redisURL, nil)}, } } diff --git a/planner/planner.go b/planner/planner.go index 3f24ff6f..c9cf9096 100644 --- a/planner/planner.go +++ b/planner/planner.go @@ -180,8 +180,13 @@ func (p *MyPlanner) Init(mapsClientApiKey string, redisURL *url.URL, redisStream p.MapsClientApiKey = mapsClientApiKey p.BlobBucket = blobBucket + var placeDetailsFields []string + if v, exists := p.Configs["server:google_maps:detailed_search_fields"]; exists { + placeDetailsFields = v.([]string) + } + // initialize poi searcher - PoiSearcher := iowrappers.CreatePoiSearcher(mapsClientApiKey, redisURL) + PoiSearcher := iowrappers.CreatePoiSearcher(mapsClientApiKey, redisURL, placeDetailsFields) if v, exists := p.Configs["server:plan_solver:same_place_dedupe_count_limit"]; exists { if c, exists := p.Configs["server:plan_solver:nearby_cities_count_limit"]; exists { p.Solver.Init(PoiSearcher, v.(int), c.(int)) @@ -190,12 +195,6 @@ func (p *MyPlanner) Init(mapsClientApiKey string, redisURL *url.URL, redisStream logger.Fatal("failed to initialize the planner") } - var placeDetailsFields []string - if v, exists := p.Configs["server:google_maps:detailed_search_fields"]; exists { - placeDetailsFields = v.([]string) - p.Solver.Searcher.GetMapsClient().SetDetailedSearchFields(placeDetailsFields) - } - p.PhotoClient, err = iowrappers.CreatePhotoClient(mapsClientApiKey, PhotoApiBaseURL, enableMapsPhotoClient, placeDetailsFields, p.RedisClient) if err != nil { log.Fatalf("failed to initialize photo client, err:%v\n", err) diff --git a/planner/reclassify_buckets_dry_run_test.go b/planner/reclassify_buckets_dry_run_test.go index 5da8b8d0..89ce7656 100644 --- a/planner/reclassify_buckets_dry_run_test.go +++ b/planner/reclassify_buckets_dry_run_test.go @@ -30,7 +30,7 @@ func TestReclassifyBucketsMigrationDryRunDefault(t *testing.T) { } p := &MyPlanner{ RedisClient: redis_client_mocks.RedisClient, - Solver: Solver{Searcher: iowrappers.CreatePoiSearcher("test-maps-api-key", redisURL)}, + Solver: Solver{Searcher: iowrappers.CreatePoiSearcher("test-maps-api-key", redisURL, nil)}, } router := gin.New() @@ -95,7 +95,7 @@ func TestReclassifyBucketsMigrationRequiresAdmin(t *testing.T) { } p := &MyPlanner{ RedisClient: redis_client_mocks.RedisClient, - Solver: Solver{Searcher: iowrappers.CreatePoiSearcher("test-maps-api-key", redisURL)}, + Solver: Solver{Searcher: iowrappers.CreatePoiSearcher("test-maps-api-key", redisURL, nil)}, } router := gin.New() From 6f7545ab763f26271c433954d4a8904f0edb80d2 Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Thu, 13 Aug 2026 20:56:29 -0700 Subject: [PATCH 12/18] feat(iowrappers): Apple Maps geocoding adapter Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YXnUPPE6LvNbzU1QnYuUCn --- iowrappers/apple_maps_client.go | 132 +++++++++++++++++++++ iowrappers/apple_maps_client_test.go | 169 +++++++++++++++++++++++++++ iowrappers/interfaces_test.go | 2 + 3 files changed, 303 insertions(+) create mode 100644 iowrappers/apple_maps_client.go create mode 100644 iowrappers/apple_maps_client_test.go diff --git a/iowrappers/apple_maps_client.go b/iowrappers/apple_maps_client.go new file mode 100644 index 00000000..3048e827 --- /dev/null +++ b/iowrappers/apple_maps_client.go @@ -0,0 +1,132 @@ +package iowrappers + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/weihesdlegend/Vacation-planner/applemaps" +) + +// AppleMapsConfig carries the credentials and transport for an AppleMapsClient. +type AppleMapsConfig struct { + TeamID string + KeyID string + // PrivateKey is the .p8 contents, either raw PEM or base64-encoded PEM. + PrivateKey string + // BaseURL defaults to Apple's. Tests point it at an httptest server. + BaseURL string + // HTTPClient carries the quota-counting transport when one is wired in. + HTTPClient *http.Client +} + +// AppleMapsClient adapts the applemaps package to Geocoder. +// +// It implements Geocoder and not SearchClient deliberately. Apple's Place object +// carries no opening hours, rating, price level, or photo at any endpoint or +// tier, and there is no join key from an Apple place ID back to a Google +// place_id, so those fields could never be backfilled. A place search served +// from Apple would be permanently hours-less. Address data has no such gap. +type AppleMapsClient struct { + client *applemaps.Client +} + +func CreateAppleMapsClient(cfg AppleMapsConfig) (*AppleMapsClient, error) { + key, err := applemaps.ParsePrivateKey(cfg.PrivateKey) + if err != nil { + return nil, err + } + client, err := applemaps.New(applemaps.Options{ + TeamID: cfg.TeamID, + KeyID: cfg.KeyID, + PrivateKey: key, + BaseURL: cfg.BaseURL, + HTTPClient: cfg.HTTPClient, + }) + if err != nil { + return nil, err + } + return &AppleMapsClient{client: client}, nil +} + +// appleQueryString flattens a GeocodeQuery into Apple's single free-text q. +// +// Google matches structured components — ComponentLocality, ComponentCountry, +// ComponentAdministrativeArea — per field. Apple has no component parameters at +// all. Probing confirmed the flattened form still disambiguates: "Paris, TX, +// United States" and "Paris, Île-de-France, France" resolve to different, +// correct coordinates. Empty fields are dropped so the query never carries a +// bare separator. +func appleQueryString(query *GeocodeQuery) string { + parts := make([]string, 0, 3) + for _, field := range []string{query.City, query.AdminAreaLevelOne, query.Country} { + if trimmed := strings.TrimSpace(field); trimmed != "" { + parts = append(parts, trimmed) + } + } + return strings.Join(parts, ", ") +} + +// appleAdminAreaLevelOne mirrors what Google writes into AdminAreaLevelOne: +// administrative_area_level_1's ShortName, which is an abbreviation where one +// exists and the long name otherwise. Apple splits those across two fields and +// omits the code for countries without conventional abbreviations. +func appleAdminAreaLevelOne(address *applemaps.StructuredAddress) string { + if address.AdministrativeAreaCode != "" { + return address.AdministrativeAreaCode + } + return address.AdministrativeArea +} + +// applyApplePlace copies an Apple place's address onto a GeocodeQuery. +// +// A field is assigned only when Apple returned something for it. Forward +// geocoding a query that names an administrative area returns no locality, and +// PoiSearcher.Geocode writes the mutated query straight into the geocode:cities +// cache, so overwriting the caller's City with "" would poison the cache key. +func applyApplePlace(query *GeocodeQuery, place applemaps.Place) { + if address := place.StructuredAddress; address != nil { + if address.Locality != "" { + query.City = address.Locality + } + if adminArea := appleAdminAreaLevelOne(address); adminArea != "" { + query.AdminAreaLevelOne = adminArea + } + } + if place.Country != "" { + query.Country = place.Country + } +} + +func (c *AppleMapsClient) Geocode(ctx context.Context, query *GeocodeQuery) (float64, float64, error) { + q := appleQueryString(query) + if q == "" { + return 0, 0, errors.New("applemaps: geocode query has no city, administrative area, or country") + } + + places, err := c.client.Geocode(ctx, applemaps.GeocodeRequest{Q: q}) + if err != nil { + return 0, 0, err + } + + // applemaps.Geocode converts an empty result set into *NotFoundError, so a + // nil error guarantees at least one place. + place := places[0] + applyApplePlace(query, place) + return place.Coordinate.Latitude, place.Coordinate.Longitude, nil +} + +func (c *AppleMapsClient) ReverseGeocode(ctx context.Context, latitude, longitude float64) (*GeocodeQuery, error) { + places, err := c.client.ReverseGeocode(ctx, applemaps.ReverseGeocodeRequest{ + Latitude: latitude, + Longitude: longitude, + }) + if err != nil { + return nil, err + } + + query := &GeocodeQuery{} + applyApplePlace(query, places[0]) + return query, nil +} diff --git a/iowrappers/apple_maps_client_test.go b/iowrappers/apple_maps_client_test.go new file mode 100644 index 00000000..c9335709 --- /dev/null +++ b/iowrappers/apple_maps_client_test.go @@ -0,0 +1,169 @@ +package iowrappers + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// appleTestKey returns a throwaway P-256 key in the PEM form Apple's .p8 uses. +func appleTestKey(t *testing.T) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) +} + +// appleTestClient points an AppleMapsClient at a stub that answers the token +// exchange itself, so tests only describe the endpoint under test. +func appleTestClient(t *testing.T, handler http.HandlerFunc) (*AppleMapsClient, *url.Values) { + t.Helper() + lastQuery := &url.Values{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/token" { + fmt.Fprint(w, `{"accessToken":"test-token","expiresInSeconds":1800}`) + return + } + q := r.URL.Query() + *lastQuery = q + handler(w, r) + })) + t.Cleanup(srv.Close) + + client, err := CreateAppleMapsClient(AppleMapsConfig{ + TeamID: "TEAM123456", KeyID: "KEY7890123", + PrivateKey: appleTestKey(t), BaseURL: srv.URL, + }) + if err != nil { + t.Fatalf("CreateAppleMapsClient: %v", err) + } + return client, lastQuery +} + +// Apple omits administrativeAreaCode for countries with no conventional +// subdivision abbreviation — confirmed live for France, Germany, and Japan, +// while the US, Australia, and Canada all return one. Google fills +// AdminAreaLevelOne from administrative_area_level_1's ShortName, which falls +// back to the long name in exactly those cases, so the adapter must too. +func TestAppleReverseGeocodeMapsAdminAreaBothWays(t *testing.T) { + tests := []struct { + name string + body string + wantCity string + wantAdmin string + wantCtry string + }{ + { + name: "code present", + body: `{"results":[{"country":"United States","countryCode":"US", + "coordinate":{"latitude":37.33,"longitude":-122.03}, + "structuredAddress":{"locality":"Cupertino", + "administrativeArea":"California","administrativeAreaCode":"CA"}}]}`, + wantCity: "Cupertino", wantAdmin: "CA", wantCtry: "United States", + }, + { + name: "code absent falls back to the name", + body: `{"results":[{"country":"France","countryCode":"FR", + "coordinate":{"latitude":48.85,"longitude":2.29}, + "structuredAddress":{"locality":"Paris", + "administrativeArea":"Île-de-France"}}]}`, + wantCity: "Paris", wantAdmin: "Île-de-France", wantCtry: "France", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client, _ := appleTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, tc.body) + }) + got, err := client.ReverseGeocode(context.Background(), 37.33, -122.03) + if err != nil { + t.Fatalf("ReverseGeocode: %v", err) + } + if got.City != tc.wantCity || got.AdminAreaLevelOne != tc.wantAdmin || got.Country != tc.wantCtry { + t.Errorf("got %+v, want {%s %s %s}", *got, tc.wantCity, tc.wantAdmin, tc.wantCtry) + } + }) + } +} + +// Forward-geocoding a query that names an administrative area rather than a +// locality returns an empty locality — confirmed live for "Tokyo, Tokyo, Japan". +// PoiSearcher.Geocode writes the mutated query to the geocode:cities cache, so a +// blank City must not reach it. Google can clobber freely because its component +// matching always yields a locality; Apple cannot. +func TestAppleGeocodeNeverOverwritesWithAnEmptyValue(t *testing.T) { + client, _ := appleTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"results":[{"country":"Japan","countryCode":"JP", + "coordinate":{"latitude":35.6895,"longitude":139.6917}, + "structuredAddress":{"administrativeArea":"Tokyo"}}]}`) + }) + + query := &GeocodeQuery{City: "Tokyo", AdminAreaLevelOne: "Tokyo", Country: "Japan"} + lat, lng, err := client.Geocode(context.Background(), query) + if err != nil { + t.Fatalf("Geocode: %v", err) + } + if lat != 35.6895 || lng != 139.6917 { + t.Errorf("coordinate: got %v,%v", lat, lng) + } + if query.City != "Tokyo" { + t.Errorf("City: got %q, want the caller's %q preserved", query.City, "Tokyo") + } +} + +// Google takes structured components; Apple takes one free-text q. Probed live: +// "Paris, TX, United States" and "Paris, Île-de-France, France" resolve to +// different, correct coordinates, so the flattening preserves disambiguation. +// Empty fields must be dropped rather than left as empty segments. +func TestAppleGeocodeFlattensTheQuery(t *testing.T) { + tests := []struct { + name string + query GeocodeQuery + wantQ string + }{ + {"all three", GeocodeQuery{City: "Paris", AdminAreaLevelOne: "TX", Country: "United States"}, "Paris, TX, United States"}, + {"no admin area", GeocodeQuery{City: "Paris", Country: "France"}, "Paris, France"}, + {"city only", GeocodeQuery{City: "Paris"}, "Paris"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client, lastQuery := appleTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"results":[{"country":"United States", + "coordinate":{"latitude":33.66,"longitude":-95.55}, + "structuredAddress":{"locality":"Paris","administrativeAreaCode":"TX"}}]}`) + }) + q := tc.query + if _, _, err := client.Geocode(context.Background(), &q); err != nil { + t.Fatalf("Geocode: %v", err) + } + if got := lastQuery.Get("q"); got != tc.wantQ { + t.Errorf("q: got %q, want %q", got, tc.wantQ) + } + }) + } +} + +func TestAppleGeocodeRejectsAnEmptyQuery(t *testing.T) { + client, _ := appleTestClient(t, func(http.ResponseWriter, *http.Request) { + t.Error("no request should be sent for an empty query") + }) + if _, _, err := client.Geocode(context.Background(), &GeocodeQuery{}); err == nil { + t.Error("want an error when every field is empty") + } +} diff --git a/iowrappers/interfaces_test.go b/iowrappers/interfaces_test.go index 69e07fb1..d38315da 100644 --- a/iowrappers/interfaces_test.go +++ b/iowrappers/interfaces_test.go @@ -7,4 +7,6 @@ var ( _ Geocoder = (*MapsClient)(nil) _ SearchClient = (*MapsClient)(nil) _ PlaceDetailsClient = (*MapsClient)(nil) + + _ Geocoder = (*AppleMapsClient)(nil) ) From 1b83be40fec8df07662f84ff1689518743f70d1e Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Thu, 13 Aug 2026 20:57:34 -0700 Subject: [PATCH 13/18] feat(iowrappers): Apple Maps daily quota counter Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YXnUPPE6LvNbzU1QnYuUCn --- iowrappers/apple_quota.go | 111 +++++++++++++++++ test/redis_client_mocks/apple_quota_test.go | 127 ++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 iowrappers/apple_quota.go create mode 100644 test/redis_client_mocks/apple_quota_test.go diff --git a/iowrappers/apple_quota.go b/iowrappers/apple_quota.go new file mode 100644 index 00000000..ae17e80d --- /dev/null +++ b/iowrappers/apple_quota.go @@ -0,0 +1,111 @@ +package iowrappers + +import ( + "context" + "net/http" + "time" +) + +// AppleDailyCallQuota is Apple's free daily service call allowance, per +// developer team. It is shared between the Maps Server API and MapKit JS. +const AppleDailyCallQuota = 25000 + +// appleQuotaKeyExpiry keeps the previous day's counter readable for diagnosis +// while bounding the keyspace. +const appleQuotaKeyExpiry = 48 * time.Hour + +// QuotaConfig configures a QuotaCounter. +type QuotaConfig struct { + // DailyLimit is the team's daily allowance. Zero means AppleDailyCallQuota. + DailyLimit int + // Threshold is the fraction of the allowance at which we stop using Apple. + // Zero means 0.9. Stopping early is deliberate: once the quota is exhausted + // Apple returns 429 on every endpoint including /v1/token, so waiting for the + // cliff would fail the whole provider over at an unpredictable moment. + Threshold float64 + // ExternalAllowance is how many calls per day are assumed to be spent by + // other consumers on the same team, principally MapKit JS. This counter can + // only ever observe our own traffic, so a threshold applied to our count + // alone is an under-estimate of true consumption rather than a measure of it. + ExternalAllowance int +} + +// QuotaCounter tracks outbound Apple calls for the current UTC day. +type QuotaCounter struct { + redisClient *RedisClient + cfg QuotaConfig + + // now is injectable so date rollover is testable. + now func() time.Time +} + +func NewQuotaCounter(redisClient *RedisClient, cfg QuotaConfig) *QuotaCounter { + if cfg.DailyLimit <= 0 { + cfg.DailyLimit = AppleDailyCallQuota + } + if cfg.Threshold <= 0 { + cfg.Threshold = 0.9 + } + return &QuotaCounter{redisClient: redisClient, cfg: cfg, now: time.Now} +} + +// Key is the counter's Redis key for the current UTC day. +func (q *QuotaCounter) Key() string { + return "applemaps:quota:" + q.now().UTC().Format("2006-01-02") +} + +// Record increments today's counter. A Redis failure is logged and ignored: the +// counter is a cost guard, and failing a request over it would trade a small +// overspend for an outage. +func (q *QuotaCounter) Record(ctx context.Context) { + key := q.Key() + count, err := q.redisClient.client.Incr(ctx, key).Result() + if err != nil { + Logger.Debugw("applemaps quota: increment failed", "key", key, "error", err) + return + } + // Only the first increment of the day needs the expiry set. + if count == 1 { + if err := q.redisClient.client.Expire(ctx, key, appleQuotaKeyExpiry).Err(); err != nil { + Logger.Debugw("applemaps quota: expire failed", "key", key, "error", err) + } + } +} + +// Count returns today's recorded call count, or 0 if Redis is unreachable. +func (q *QuotaCounter) Count(ctx context.Context) int64 { + count, err := q.redisClient.client.Get(ctx, q.Key()).Int64() + if err != nil { + return 0 + } + return count +} + +// OverThreshold reports whether Apple should be skipped for this request. It +// returns false when Redis is unreachable, so a cache outage degrades to +// spending quota rather than to refusing to use the provider. +func (q *QuotaCounter) OverThreshold(ctx context.Context) bool { + budget := float64(q.cfg.DailyLimit) * q.cfg.Threshold + spent := float64(q.Count(ctx) + int64(q.cfg.ExternalAllowance)) + return spent >= budget +} + +// quotaTransport increments the counter once per HTTP round trip. +type quotaTransport struct { + base http.RoundTripper + counter *QuotaCounter +} + +func (t *quotaTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.counter.Record(req.Context()) + return t.base.RoundTrip(req) +} + +// Transport wraps base so every request through it is counted, including token +// exchanges and retries — the two costs a per-search counter would miss. +func (q *QuotaCounter) Transport(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return "aTransport{base: base, counter: q} +} diff --git a/test/redis_client_mocks/apple_quota_test.go b/test/redis_client_mocks/apple_quota_test.go new file mode 100644 index 00000000..48f7f6b6 --- /dev/null +++ b/test/redis_client_mocks/apple_quota_test.go @@ -0,0 +1,127 @@ +package redis_client_mocks + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/weihesdlegend/Vacation-planner/iowrappers" +) + +func newTestQuotaCounter(limit int, threshold float64, allowance int) *iowrappers.QuotaCounter { + return iowrappers.NewQuotaCounter(RedisClient, iowrappers.QuotaConfig{ + DailyLimit: limit, + Threshold: threshold, + ExternalAllowance: allowance, + }) +} + +// Apple charges for every HTTP round trip, not every logical geocode: a stale +// token costs a /v1/token exchange and a 5xx costs a retry. Counting logical +// operations would under-report by a factor that varies with the failure rate, +// worst exactly when the budget is tightest. So the counter sits in the +// transport. +func TestQuotaCounterCountsEveryRoundTrip(t *testing.T) { + RedisMockSvr.FlushAll() + counter := newTestQuotaCounter(100, 0.9, 0) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + client := &http.Client{Transport: counter.Transport(http.DefaultTransport)} + for range 3 { + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("get: %v", err) + } + _ = resp.Body.Close() + } + + if got := counter.Count(context.Background()); got != 3 { + t.Errorf("count: got %d, want 3", got) + } +} + +func TestQuotaCounterThreshold(t *testing.T) { + tests := []struct { + name string + limit int + threshold float64 + allowance int + spend int + want bool + }{ + {"well under", 100, 0.9, 0, 10, false}, + {"just under", 100, 0.9, 0, 89, false}, + {"at the threshold", 100, 0.9, 0, 90, true}, + // The 25,000 is shared with MapKit JS, so traffic this counter cannot see + // still spends the budget. The allowance is charged before our own calls. + {"allowance alone crosses it", 100, 0.9, 90, 0, true}, + {"allowance plus spend crosses it", 100, 0.9, 50, 40, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + RedisMockSvr.FlushAll() + counter := newTestQuotaCounter(tc.limit, tc.threshold, tc.allowance) + ctx := context.Background() + for range tc.spend { + counter.Record(ctx) + } + if got := counter.OverThreshold(ctx); got != tc.want { + t.Errorf("OverThreshold: got %v, want %v", got, tc.want) + } + }) + } +} + +// The counter is a cost guard, not a correctness guard. If Redis is unreachable +// the request must still be served rather than failing closed. +// +// This uses its own miniredis rather than closing the package-wide one, which +// every other test in this package shares — stopping and restarting that server +// would make this test's failure mode depend on execution order. +func TestQuotaCounterRedisFailureDoesNotBlock(t *testing.T) { + deadSvr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis: %v", err) + } + deadURL, err := url.Parse("redis://" + deadSvr.Addr()) + if err != nil { + t.Fatalf("parse url: %v", err) + } + deadClient := iowrappers.CreateRedisClient(deadURL) + deadSvr.Close() + + counter := iowrappers.NewQuotaCounter(deadClient, iowrappers.QuotaConfig{ + DailyLimit: 100, Threshold: 0.9, + }) + ctx := context.Background() + + if counter.OverThreshold(ctx) { + t.Error("a Redis failure must not report the quota as exhausted") + } + // Record must also swallow the failure rather than panicking. + counter.Record(ctx) +} + +// A 48 hour expiry keeps yesterday's key readable for diagnosis without letting +// the keyspace grow without bound. +func TestQuotaCounterKeyExpires(t *testing.T) { + RedisMockSvr.FlushAll() + counter := newTestQuotaCounter(100, 0.9, 0) + counter.Record(context.Background()) + + ttl := RedisMockSvr.TTL(counter.Key()) + if ttl <= 0 { + t.Fatalf("TTL: got %v, want a positive expiry", ttl) + } + if ttl.Hours() > 48 { + t.Errorf("TTL: got %v, want at most 48h", ttl) + } +} From 5462bf1c9cab83685d64ed85f38e008947225d26 Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Thu, 13 Aug 2026 20:58:48 -0700 Subject: [PATCH 14/18] feat(iowrappers): route geocoding to Apple with Google fallback Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YXnUPPE6LvNbzU1QnYuUCn --- iowrappers/apple_geocode_router.go | 84 +++++++++++++++++ iowrappers/apple_geocode_router_test.go | 117 ++++++++++++++++++++++++ iowrappers/interfaces_test.go | 3 +- 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 iowrappers/apple_geocode_router.go create mode 100644 iowrappers/apple_geocode_router_test.go diff --git a/iowrappers/apple_geocode_router.go b/iowrappers/apple_geocode_router.go new file mode 100644 index 00000000..12408584 --- /dev/null +++ b/iowrappers/apple_geocode_router.go @@ -0,0 +1,84 @@ +package iowrappers + +import ( + "context" + "errors" + + "github.com/weihesdlegend/Vacation-planner/POI" + "github.com/weihesdlegend/Vacation-planner/applemaps" +) + +// AppleGeocodeRouter serves geocoding from Apple and everything else from Google. +// +// The split is a property of this type rather than a rule spread across +// PoiSearcher: Apple can answer address questions completely, and cannot answer +// place questions at all, so NearbySearch has no routing decision to make. +type AppleGeocodeRouter struct { + apple Geocoder + google SearchClient + // quota may be nil, which disables the pre-emptive check. + quota *QuotaCounter +} + +func NewAppleGeocodeRouter(apple Geocoder, google SearchClient, quota *QuotaCounter) *AppleGeocodeRouter { + return &AppleGeocodeRouter{apple: apple, google: google, quota: quota} +} + +// useApple reports whether Apple should be tried, logging the reason when not. +func (r *AppleGeocodeRouter) useApple(ctx context.Context, method string) bool { + if r.apple == nil { + return false + } + if r.quota != nil && r.quota.OverThreshold(ctx) { + Logger.Infow("applemaps: routing to Google", "method", method, "reason", "daily quota threshold reached") + return false + } + return true +} + +// logFallback records why an Apple attempt was abandoned. A fallback costs one +// Apple call plus one Google call, so a silent one hides real spend and makes +// the Apple hit rate unmeasurable. +func logFallback(method string, err error) { + reason := "error" + var quotaErr *applemaps.QuotaError + var notFound *applemaps.NotFoundError + switch { + case errors.As(err, "aErr): + reason = "quota exhausted" + case errors.As(err, ¬Found): + reason = "no match" + } + Logger.Infow("applemaps: falling back to Google", "method", method, "reason", reason, "error", err) +} + +func (r *AppleGeocodeRouter) Geocode(ctx context.Context, query *GeocodeQuery) (float64, float64, error) { + if r.useApple(ctx, "Geocode") { + // Apple's adapter mutates the query it is given, so it gets a copy: a + // failed attempt must not leave half-corrected fields for Google. + attempt := *query + lat, lng, err := r.apple.Geocode(ctx, &attempt) + if err == nil { + *query = attempt + return lat, lng, nil + } + logFallback("Geocode", err) + } + return r.google.Geocode(ctx, query) +} + +func (r *AppleGeocodeRouter) ReverseGeocode(ctx context.Context, latitude, longitude float64) (*GeocodeQuery, error) { + if r.useApple(ctx, "ReverseGeocode") { + query, err := r.apple.ReverseGeocode(ctx, latitude, longitude) + if err == nil { + return query, nil + } + logFallback("ReverseGeocode", err) + } + return r.google.ReverseGeocode(ctx, latitude, longitude) +} + +// NearbySearch always goes to Google. See the type comment. +func (r *AppleGeocodeRouter) NearbySearch(ctx context.Context, request *PlaceSearchRequest) ([]POI.Place, error) { + return r.google.NearbySearch(ctx, request) +} diff --git a/iowrappers/apple_geocode_router_test.go b/iowrappers/apple_geocode_router_test.go new file mode 100644 index 00000000..efafe7dd --- /dev/null +++ b/iowrappers/apple_geocode_router_test.go @@ -0,0 +1,117 @@ +package iowrappers + +import ( + "context" + "errors" + "testing" + + "github.com/weihesdlegend/Vacation-planner/POI" + "github.com/weihesdlegend/Vacation-planner/applemaps" +) + +// stubGeocoder stands in for Apple. +type stubGeocoder struct { + calls int + query *GeocodeQuery + lat float64 + lng float64 + err error +} + +func (s *stubGeocoder) Geocode(_ context.Context, query *GeocodeQuery) (float64, float64, error) { + s.calls++ + if s.err != nil { + return 0, 0, s.err + } + if s.query != nil { + *query = *s.query + } + return s.lat, s.lng, nil +} + +func (s *stubGeocoder) ReverseGeocode(context.Context, float64, float64) (*GeocodeQuery, error) { + s.calls++ + if s.err != nil { + return nil, s.err + } + return s.query, nil +} + +func TestRouterUsesAppleAndSkipsGoogleOnSuccess(t *testing.T) { + apple := &stubGeocoder{lat: 48.85, lng: 2.29, query: &GeocodeQuery{City: "Paris"}} + google := &stubSearchClient{} + router := NewAppleGeocodeRouter(apple, google, nil) + + lat, lng, err := router.Geocode(context.Background(), &GeocodeQuery{City: "Paris"}) + if err != nil { + t.Fatalf("Geocode: %v", err) + } + if lat != 48.85 || lng != 2.29 { + t.Errorf("coordinate: got %v,%v want 48.85,2.29", lat, lng) + } + if google.geocodeCalls != 0 { + t.Errorf("google geocode calls: got %d, want 0", google.geocodeCalls) + } +} + +// Each of these is a distinct reason Apple cannot answer, and each must cost one +// Apple attempt and then a Google call rather than an error to the caller. +func TestRouterFallsBackToGoogle(t *testing.T) { + tests := []struct { + name string + err error + }{ + {"transport or API error", errors.New("boom")}, + {"quota exhausted", &applemaps.QuotaError{APIError: &applemaps.APIError{StatusCode: 429, Message: "quota"}}}, + // Apple answers an unmatched geocode with HTTP 200 and an empty array; + // the SDK converts that to NotFoundError. Google may still find it. + {"no match", &applemaps.NotFoundError{Query: "nowhere"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + apple := &stubGeocoder{err: tc.err} + google := &stubSearchClient{} + router := NewAppleGeocodeRouter(apple, google, nil) + + if _, _, err := router.Geocode(context.Background(), &GeocodeQuery{City: "Paris"}); err != nil { + t.Fatalf("Geocode: %v", err) + } + if apple.calls != 1 { + t.Errorf("apple calls: got %d, want 1", apple.calls) + } + if google.geocodeCalls != 1 { + t.Errorf("google geocode calls: got %d, want 1", google.geocodeCalls) + } + + apple.calls, google.reverseGeocodeCalls = 0, 0 + if _, err := router.ReverseGeocode(context.Background(), 1, 2); err != nil { + t.Fatalf("ReverseGeocode: %v", err) + } + if apple.calls != 1 || google.reverseGeocodeCalls != 1 { + t.Errorf("reverse: apple=%d google=%d, want 1 and 1", apple.calls, google.reverseGeocodeCalls) + } + }) + } +} + +// Apple has no opening hours at any tier and no join key back to a Google +// place_id, so a place it returned could never be enriched. There is no +// condition under which it serves a place search. +func TestRouterNeverSendsNearbySearchToApple(t *testing.T) { + apple := &stubGeocoder{} + google := &stubSearchClient{} + router := NewAppleGeocodeRouter(apple, google, nil) + + if _, err := router.NearbySearch(context.Background(), &PlaceSearchRequest{ + PlaceCat: POI.PlaceCategoryEatery, + }); err != nil { + t.Fatalf("NearbySearch: %v", err) + } + if google.nearbySearchCalls != 1 { + t.Errorf("google nearby calls: got %d, want 1", google.nearbySearchCalls) + } + if apple.calls != 0 { + t.Errorf("apple calls: got %d, want 0 — Apple must never serve a place search", apple.calls) + } +} diff --git a/iowrappers/interfaces_test.go b/iowrappers/interfaces_test.go index d38315da..52277910 100644 --- a/iowrappers/interfaces_test.go +++ b/iowrappers/interfaces_test.go @@ -8,5 +8,6 @@ var ( _ SearchClient = (*MapsClient)(nil) _ PlaceDetailsClient = (*MapsClient)(nil) - _ Geocoder = (*AppleMapsClient)(nil) + _ Geocoder = (*AppleMapsClient)(nil) + _ SearchClient = (*AppleGeocodeRouter)(nil) ) From 641b0ab5fd29821df52170910872d7269795a90d Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Thu, 13 Aug 2026 21:02:07 -0700 Subject: [PATCH 15/18] feat: wire Apple Maps geocoding behind APPLE_MAPS_ENABLED Also two fixes surfaced by the full suite: the quota test's FlushAll wiped this package's init()-seeded fixtures for later tests, so it now deletes only the quota key; and gofmt drift in planner.go and nearby_search_test.go. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YXnUPPE6LvNbzU1QnYuUCn --- iowrappers/apple_geocode_router.go | 58 +++++++++++++++++++ iowrappers/apple_wiring_test.go | 49 ++++++++++++++++ main.go | 18 +++++- planner/planner.go | 9 ++- test/redis_client_mocks/apple_quota_test.go | 11 ++-- test/redis_client_mocks/nearby_search_test.go | 6 +- 6 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 iowrappers/apple_wiring_test.go diff --git a/iowrappers/apple_geocode_router.go b/iowrappers/apple_geocode_router.go index 12408584..9b1f0a5f 100644 --- a/iowrappers/apple_geocode_router.go +++ b/iowrappers/apple_geocode_router.go @@ -3,6 +3,8 @@ package iowrappers import ( "context" "errors" + "net/http" + "time" "github.com/weihesdlegend/Vacation-planner/POI" "github.com/weihesdlegend/Vacation-planner/applemaps" @@ -82,3 +84,59 @@ func (r *AppleGeocodeRouter) ReverseGeocode(ctx context.Context, latitude, longi func (r *AppleGeocodeRouter) NearbySearch(ctx context.Context, request *PlaceSearchRequest) ([]POI.Place, error) { return r.google.NearbySearch(ctx, request) } + +// AppleMapsSettings is the deployment-time configuration for Apple Maps. +type AppleMapsSettings struct { + // Enabled is false by default. Nothing Apple-derived is requested or cached + // until it is deliberately turned on, which is also what keeps the + // unresolved question of Apple's caching terms off the merge path. + Enabled bool + // TeamID is the Apple Developer team ID, the JWT iss claim. + TeamID string + // KeyID is the MapKit key ID, the JWT kid header. + KeyID string + // PrivateKey is the .p8 contents, raw PEM or base64-encoded PEM. + PrivateKey string + // QuotaThreshold is the fraction of the daily allowance at which Apple stops + // being used. Zero means the QuotaConfig default. + QuotaThreshold float64 + // ExternalAllowance is the calls per day assumed spent by MapKit JS on the + // same team, which this service cannot observe. + ExternalAllowance int +} + +// EnableAppleMaps routes geocoding through Apple, keeping Google as the +// fallback and as the only place-search provider. +// +// It returns an error rather than logging and continuing so the caller can +// decide, but the caller in main deliberately continues: a missing or malformed +// credential should cost the quota saving, not the service. +func (s *PoiSearcher) EnableAppleMaps(settings AppleMapsSettings) error { + if !settings.Enabled { + return nil + } + + quota := NewQuotaCounter(s.redisClient, QuotaConfig{ + Threshold: settings.QuotaThreshold, + ExternalAllowance: settings.ExternalAllowance, + }) + + // The counter lives in the transport so token exchanges and retries are + // counted alongside the calls a search makes directly. + appleClient, err := CreateAppleMapsClient(AppleMapsConfig{ + TeamID: settings.TeamID, + KeyID: settings.KeyID, + PrivateKey: settings.PrivateKey, + HTTPClient: &http.Client{ + Timeout: 15 * time.Second, + Transport: quota.Transport(nil), + }, + }) + if err != nil { + return err + } + + s.searcher = NewAppleGeocodeRouter(appleClient, s.searcher, quota) + Logger.Infow("applemaps: geocoding enabled", "team", settings.TeamID) + return nil +} diff --git a/iowrappers/apple_wiring_test.go b/iowrappers/apple_wiring_test.go new file mode 100644 index 00000000..05c06e93 --- /dev/null +++ b/iowrappers/apple_wiring_test.go @@ -0,0 +1,49 @@ +package iowrappers + +import "testing" + +// A bad key must degrade to Google-only, never take the service down. Credentials +// arrive from the environment and a deploy-time mistake is entirely possible. +func TestEnableAppleMapsWithBadCredentialsLeavesGoogleInPlace(t *testing.T) { + google := &stubSearchClient{} + s := &PoiSearcher{searcher: google} + + err := s.EnableAppleMaps(AppleMapsSettings{ + Enabled: true, TeamID: "T", KeyID: "K", PrivateKey: "not a key", + }) + if err == nil { + t.Fatal("want an error for an unparseable private key") + } + if s.searcher != SearchClient(google) { + t.Error("a bad key must leave the Google client in place") + } +} + +// Disabled is the default, and the router must not be constructed at all — the +// zero configuration is Google-only with nothing extra in the request path. +func TestEnableAppleMapsDisabledIsANoOp(t *testing.T) { + google := &stubSearchClient{} + s := &PoiSearcher{searcher: google} + + if err := s.EnableAppleMaps(AppleMapsSettings{Enabled: false}); err != nil { + t.Fatalf("EnableAppleMaps: %v", err) + } + if _, wrapped := s.searcher.(*AppleGeocodeRouter); wrapped { + t.Error("Apple disabled must not wrap the searcher") + } +} + +func TestEnableAppleMapsWrapsTheSearcher(t *testing.T) { + google := &stubSearchClient{} + s := &PoiSearcher{searcher: google} + + if err := s.EnableAppleMaps(AppleMapsSettings{ + Enabled: true, TeamID: "TEAM123456", KeyID: "KEY7890123", + PrivateKey: appleTestKey(t), + }); err != nil { + t.Fatalf("EnableAppleMaps: %v", err) + } + if _, wrapped := s.searcher.(*AppleGeocodeRouter); !wrapped { + t.Error("want the searcher wrapped in an AppleGeocodeRouter") + } +} diff --git a/main.go b/main.go index c71a06f6..5a2950ba 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,14 @@ type Config struct { GoogleOAuthClientSecret string `envconfig:"GOOGLE_OAUTH_CLIENT_SECRET"` GeonamesApiKey string `envconfig:"GEONAMES_API_KEY"` BlobBucketId string `envconfig:"BLOB_BUCKET_ID"` + AppleMaps struct { + Enabled bool `envconfig:"APPLE_MAPS_ENABLED" default:"false"` + TeamID string `envconfig:"APPLE_MAPS_TEAM_ID"` + KeyID string `envconfig:"APPLE_MAPS_KEY_ID"` + PrivateKey string `envconfig:"APPLE_MAPS_PRIVATE_KEY"` + QuotaThreshold float64 `envconfig:"APPLE_MAPS_QUOTA_THRESHOLD" default:"0.9"` + ExternalAllowance int `envconfig:"APPLE_MAPS_EXTERNAL_ALLOWANCE" default:"0"` + } } type Configurations struct { @@ -91,7 +99,15 @@ func RunServer() { myPlanner.Init(conf.MapsClientApiKey, redisURL, conf.Redis.RedisStreamName, flattenConfig(configs), conf.GoogleOAuthClientID, conf.GoogleOAuthClientSecret, - conf.Server.Domain, conf.GeonamesApiKey, conf.BlobBucketId) + conf.Server.Domain, conf.GeonamesApiKey, conf.BlobBucketId, + iowrappers.AppleMapsSettings{ + Enabled: conf.AppleMaps.Enabled, + TeamID: conf.AppleMaps.TeamID, + KeyID: conf.AppleMaps.KeyID, + PrivateKey: conf.AppleMaps.PrivateKey, + QuotaThreshold: conf.AppleMaps.QuotaThreshold, + ExternalAllowance: conf.AppleMaps.ExternalAllowance, + }) svr := myPlanner.SetupRouter(conf.Server.ServerPort) c := make(chan os.Signal, 1) diff --git a/planner/planner.go b/planner/planner.go index c9cf9096..20525c6d 100644 --- a/planner/planner.go +++ b/planner/planner.go @@ -145,7 +145,7 @@ type PlaceDetailsResp struct { type RequestIdKey string -func (p *MyPlanner) Init(mapsClientApiKey string, redisURL *url.URL, redisStreamName string, configs map[string]interface{}, oauthClientID string, oauthClientSecret string, domain string, geonamesApiKey string, blobBucket string) { +func (p *MyPlanner) Init(mapsClientApiKey string, redisURL *url.URL, redisStreamName string, configs map[string]interface{}, oauthClientID string, oauthClientSecret string, domain string, geonamesApiKey string, blobBucket string, appleMaps iowrappers.AppleMapsSettings) { logger := iowrappers.Logger p.PlanningEvents = make(chan iowrappers.PlanningEvent, jobQueueBufferSize) p.RedisClient = iowrappers.CreateRedisClient(redisURL) @@ -187,6 +187,11 @@ func (p *MyPlanner) Init(mapsClientApiKey string, redisURL *url.URL, redisStream // initialize poi searcher PoiSearcher := iowrappers.CreatePoiSearcher(mapsClientApiKey, redisURL, placeDetailsFields) + if err := PoiSearcher.EnableAppleMaps(appleMaps); err != nil { + // Degrade to Google-only. A bad Apple credential costs the quota saving, + // not the service. + logger.Warnf("Apple Maps disabled: %v", err) + } if v, exists := p.Configs["server:plan_solver:same_place_dedupe_count_limit"]; exists { if c, exists := p.Configs["server:plan_solver:nearby_cities_count_limit"]; exists { p.Solver.Init(PoiSearcher, v.(int), c.(int)) @@ -1438,7 +1443,7 @@ func (p *MyPlanner) getNearbyPlacesByCategory(ctx *gin.Context) { MinNumResults: uint(limit), // Bound the expensive Place Details calls even when the candidate // pool (limit) is widened for dedup — details cost stays ~today's. - DetailsLimit: min(limit, 20), + DetailsLimit: min(limit, 20), } result := nearbyPlacesByCategoryResult{Category: string(placeCat), Places: []POI.Place{}} places, searchErr := p.Solver.Searcher.NearbySearch(searchContext, searchReq) diff --git a/test/redis_client_mocks/apple_quota_test.go b/test/redis_client_mocks/apple_quota_test.go index 48f7f6b6..d36483d0 100644 --- a/test/redis_client_mocks/apple_quota_test.go +++ b/test/redis_client_mocks/apple_quota_test.go @@ -11,12 +11,18 @@ import ( "github.com/weihesdlegend/Vacation-planner/iowrappers" ) +// newTestQuotaCounter returns a counter with today's key cleared. Only the +// quota key is deleted — this package's fixtures (cities, places) are seeded +// once in init() functions and shared by every test, so a FlushAll here would +// wipe them for whichever tests happen to run later. func newTestQuotaCounter(limit int, threshold float64, allowance int) *iowrappers.QuotaCounter { - return iowrappers.NewQuotaCounter(RedisClient, iowrappers.QuotaConfig{ + counter := iowrappers.NewQuotaCounter(RedisClient, iowrappers.QuotaConfig{ DailyLimit: limit, Threshold: threshold, ExternalAllowance: allowance, }) + RedisMockSvr.Del(counter.Key()) + return counter } // Apple charges for every HTTP round trip, not every logical geocode: a stale @@ -25,7 +31,6 @@ func newTestQuotaCounter(limit int, threshold float64, allowance int) *iowrapper // worst exactly when the budget is tightest. So the counter sits in the // transport. func TestQuotaCounterCountsEveryRoundTrip(t *testing.T) { - RedisMockSvr.FlushAll() counter := newTestQuotaCounter(100, 0.9, 0) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -67,7 +72,6 @@ func TestQuotaCounterThreshold(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - RedisMockSvr.FlushAll() counter := newTestQuotaCounter(tc.limit, tc.threshold, tc.allowance) ctx := context.Background() for range tc.spend { @@ -113,7 +117,6 @@ func TestQuotaCounterRedisFailureDoesNotBlock(t *testing.T) { // A 48 hour expiry keeps yesterday's key readable for diagnosis without letting // the keyspace grow without bound. func TestQuotaCounterKeyExpires(t *testing.T) { - RedisMockSvr.FlushAll() counter := newTestQuotaCounter(100, 0.9, 0) counter.Record(context.Background()) diff --git a/test/redis_client_mocks/nearby_search_test.go b/test/redis_client_mocks/nearby_search_test.go index 24f0f217..732c8bad 100644 --- a/test/redis_client_mocks/nearby_search_test.go +++ b/test/redis_client_mocks/nearby_search_test.go @@ -149,9 +149,9 @@ func TestGetPlaces_readsEveryPriceLevel(t *testing.T) { // The setup of this test case guarantees that the Morgan Library & Museum is within the search radius but is excluded due to temporary closure func TestGetPlaces_shouldExcludePlacesNotOperational(t *testing.T) { placeSearchRequest := iowrappers.PlaceSearchRequest{ - Location: POI.Location{Longitude: -74.0060, Latitude: 40.7128}, - PlaceCat: POI.PlaceCategoryVisit, - Radius: uint(20000), + Location: POI.Location{Longitude: -74.0060, Latitude: 40.7128}, + PlaceCat: POI.PlaceCategoryVisit, + Radius: uint(20000), } cachedVisitPlaces, _ := RedisClient.NearbySearch(RedisContext, &placeSearchRequest) From abc223b189d8f89f26b627a354e1cae9e6d1503f Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Thu, 13 Aug 2026 21:03:28 -0700 Subject: [PATCH 16/18] docs: check off Phase 2 geocoding plan tasks Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YXnUPPE6LvNbzU1QnYuUCn --- ...2026-08-11-apple-maps-phase-2-geocoding.md | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/docs/superpowers/plans/2026-08-11-apple-maps-phase-2-geocoding.md b/docs/superpowers/plans/2026-08-11-apple-maps-phase-2-geocoding.md index 526a59fe..b0b2f3d0 100644 --- a/docs/superpowers/plans/2026-08-11-apple-maps-phase-2-geocoding.md +++ b/docs/superpowers/plans/2026-08-11-apple-maps-phase-2-geocoding.md @@ -51,7 +51,7 @@ Pure refactor. No behaviour change, no call site moves — `*MapsClient` already - Consumes: nothing. - Produces: `Geocoder` (methods `Geocode(context.Context, *GeocodeQuery) (float64, float64, error)`, `ReverseGeocode(context.Context, float64, float64) (*GeocodeQuery, error)`); `SearchClient` now embeds `Geocoder` and adds `NearbySearch(context.Context, *PlaceSearchRequest) ([]POI.Place, error)`; `PlaceDetailsClient` (method `PlaceDetailedSearch(context.Context, string, []string) (maps.PlaceDetailsResult, error)`). -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** Create `iowrappers/interfaces_test.go`: @@ -68,12 +68,12 @@ var ( ) ``` -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: `go vet ./iowrappers/` Expected: FAIL — `undefined: Geocoder`, `undefined: PlaceDetailsClient`. -- [ ] **Step 3: Write minimal implementation** +- [x] **Step 3: Write minimal implementation** In `iowrappers/maps_client.go`, replace the `SearchClient` declaration: @@ -101,12 +101,12 @@ type PlaceDetailsClient interface { } ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `go build ./... && go vet ./... && go test ./iowrappers/ ./planner/` Expected: PASS, no call site changes required. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add iowrappers/maps_client.go iowrappers/interfaces_test.go @@ -130,7 +130,7 @@ Still no Apple. This makes the seam exist so later tasks plug into it, and close - Consumes: `Geocoder`, `SearchClient`, `PlaceDetailsClient` from Task 1. - Produces: `CreatePoiSearcher(mapsApiKey string, redisUrl *url.URL, detailedSearchFields []string) *PoiSearcher`. `PoiSearcher.GetMapsClient()` is **deleted**. Field `PoiSearcher.searcher SearchClient` is the single provider seam that Task 5 replaces. -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** Create `iowrappers/poi_searcher_seam_test.go`: @@ -195,12 +195,12 @@ func TestPoiSearcherHasNoGetMapsClient(t *testing.T) { } ``` -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: `go test ./iowrappers/ -run 'TestPoiSearcher(RoutesGeocoding|HasNoGetMapsClient)' -v` Expected: FAIL — `unknown field searcher in struct literal`. -- [ ] **Step 3: Write minimal implementation** +- [x] **Step 3: Write minimal implementation** In `iowrappers/poi_searcher.go`, replace the struct and constructor: @@ -268,12 +268,12 @@ stay in scope. In all three test call sites, add the new argument: `iowrappers.CreatePoiSearcher("test-maps-api-key", redisURL, nil)`. -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `go build ./... && go vet ./... && go test ./...` Expected: PASS. Confirm no `GetMapsClient` remains: `grep -rn "GetMapsClient" --include="*.go" .` returns nothing. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add iowrappers/poi_searcher.go iowrappers/poi_searcher_seam_test.go iowrappers/data_migrations.go planner/ @@ -292,7 +292,7 @@ git commit -m "refactor(iowrappers): route every provider call through one PoiSe - Consumes: `Geocoder`, `GeocodeQuery` from Tasks 1-2. - Produces: `CreateAppleMapsClient(cfg AppleMapsConfig) (*AppleMapsClient, error)`; `AppleMapsConfig{TeamID, KeyID, PrivateKey string; BaseURL string; HTTPClient *http.Client}`; `*AppleMapsClient` satisfies `Geocoder`. -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** Create `iowrappers/apple_maps_client_test.go`: @@ -468,12 +468,12 @@ func TestAppleGeocodeRejectsAnEmptyQuery(t *testing.T) { } ``` -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: `go test ./iowrappers/ -run TestApple -v` Expected: FAIL — `undefined: CreateAppleMapsClient`, `undefined: AppleMapsConfig`. -- [ ] **Step 3: Write minimal implementation** +- [x] **Step 3: Write minimal implementation** Create `iowrappers/apple_maps_client.go`: @@ -614,12 +614,12 @@ func (c *AppleMapsClient) ReverseGeocode(ctx context.Context, latitude, longitud Add to `iowrappers/interfaces_test.go`: `_ Geocoder = (*AppleMapsClient)(nil)`. -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `go build ./... && go vet ./... && go test ./iowrappers/ -run TestApple -v` Expected: PASS, all four tests including every subtest. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add iowrappers/apple_maps_client.go iowrappers/apple_maps_client_test.go iowrappers/interfaces_test.go @@ -638,7 +638,7 @@ git commit -m "feat(iowrappers): Apple Maps geocoding adapter" - Consumes: `RedisClient`. - Produces: `NewQuotaCounter(redisClient *RedisClient, cfg QuotaConfig) *QuotaCounter`; `QuotaConfig{DailyLimit int; Threshold float64; ExternalAllowance int}`; methods `(*QuotaCounter).OverThreshold(ctx context.Context) bool` and `(*QuotaCounter).Transport(base http.RoundTripper) http.RoundTripper`; exported `AppleDailyCallQuota = 25000`. -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** Create `test/redis_client_mocks/apple_quota_test.go`: @@ -772,12 +772,12 @@ func TestQuotaCounterKeyExpires(t *testing.T) { } ``` -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: `go test ./test/redis_client_mocks/ -run TestQuota -v` Expected: FAIL — `undefined: iowrappers.NewQuotaCounter`. -- [ ] **Step 3: Write minimal implementation** +- [x] **Step 3: Write minimal implementation** Create `iowrappers/apple_quota.go`: @@ -895,12 +895,12 @@ func (q *QuotaCounter) Transport(base http.RoundTripper) http.RoundTripper { } ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `go build ./... && go vet ./... && go test ./test/redis_client_mocks/ -run TestQuota -v` Expected: PASS, all four tests including every threshold subtest. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add iowrappers/apple_quota.go test/redis_client_mocks/apple_quota_test.go @@ -919,7 +919,7 @@ git commit -m "feat(iowrappers): Apple Maps daily quota counter" - Consumes: `Geocoder`, `SearchClient` (Task 1), `QuotaCounter` (Task 4), and the `stubSearchClient` test double defined in `iowrappers/poi_searcher_seam_test.go` (Task 2) — same package, so it is reused rather than redeclared. - Produces: `NewAppleGeocodeRouter(apple Geocoder, google SearchClient, quota *QuotaCounter) *AppleGeocodeRouter`, satisfying `SearchClient`. The `stubGeocoder` test double defined here is reused by Task 6. -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** Create `iowrappers/apple_geocode_router_test.go`: @@ -1043,12 +1043,12 @@ func TestRouterNeverSendsNearbySearchToApple(t *testing.T) { } ``` -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: `go test ./iowrappers/ -run TestRouter -v` Expected: FAIL — `undefined: NewAppleGeocodeRouter`. -- [ ] **Step 3: Write minimal implementation** +- [x] **Step 3: Write minimal implementation** Create `iowrappers/apple_geocode_router.go`: @@ -1141,12 +1141,12 @@ func (r *AppleGeocodeRouter) NearbySearch(ctx context.Context, request *PlaceSea Add `_ SearchClient = (*AppleGeocodeRouter)(nil)` to `iowrappers/interfaces_test.go`. -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `go build ./... && go vet ./... && go test ./iowrappers/ -run TestRouter -v` Expected: PASS, all three tests including every fallback subtest. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add iowrappers/apple_geocode_router.go iowrappers/apple_geocode_router_test.go iowrappers/interfaces_test.go @@ -1166,7 +1166,7 @@ git commit -m "feat(iowrappers): route geocoding to Apple with Google fallback" - Consumes: everything above, plus two test doubles already in the package — `stubSearchClient` from Task 2 and `appleTestKey` from Task 3's test file. - Produces: `AppleMapsSettings{Enabled bool; TeamID, KeyID, PrivateKey string; QuotaThreshold float64; ExternalAllowance int}`; `(*PoiSearcher).EnableAppleMaps(settings AppleMapsSettings) error`; `planner.MyPlanner.Init` gains a final `appleMaps iowrappers.AppleMapsSettings` parameter. -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** Create `iowrappers/apple_wiring_test.go`: @@ -1222,12 +1222,12 @@ func TestEnableAppleMapsWrapsTheSearcher(t *testing.T) { } ``` -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: `go test ./iowrappers/ -run TestEnableAppleMaps -v` Expected: FAIL — `undefined: AppleMapsSettings`. -- [ ] **Step 3: Write minimal implementation** +- [x] **Step 3: Write minimal implementation** Append to `iowrappers/apple_geocode_router.go`: @@ -1333,12 +1333,12 @@ immediately after `CreatePoiSearcher`: } ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `go build ./... && go vet ./... && go test ./...` Expected: PASS. Confirm the default is off: `APPLE_MAPS_ENABLED` unset means `EnableAppleMaps` returns immediately and no router is constructed. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add main.go planner/planner.go iowrappers/apple_geocode_router.go iowrappers/apple_wiring_test.go From d9c37202544d033ddffd5679f878d6d9a79b5768 Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Thu, 13 Aug 2026 22:16:01 -0700 Subject: [PATCH 17/18] deploy: enable Apple Maps geocoding in production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APPLE_MAPS_ENABLED plus team/key IDs go in env.production; the .p8 private key is fetched from Secret Manager as APPLE_MAPS_PRIVATE_KEY (base64 PEM, which applemaps.ParsePrivateKey accepts). The secret must exist before this deploys — deploy.sh aborts on a missing secret. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YXnUPPE6LvNbzU1QnYuUCn --- deploy/deploy.sh | 2 +- deploy/env.production | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 0194e979..692d7891 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -10,7 +10,7 @@ REGION="us-west1" IMAGE_URI="${REGION}-docker.pkg.dev/${PROJECT_ID}/planner/backend" SECRET_NAMES=(MAPS_CLIENT_API_KEY GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET \ JWT_SIGNING_SECRET SENDGRID_API_KEY OPENAI_API_KEY GEONAMES_API_KEY \ - AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY) + AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY APPLE_MAPS_PRIVATE_KEY) token() { curl -sfS -H 'Metadata-Flavor: Google' \ diff --git a/deploy/env.production b/deploy/env.production index befa2b06..0735fb57 100644 --- a/deploy/env.production +++ b/deploy/env.production @@ -8,3 +8,11 @@ ADMIN_USERS=ronak@offerbee.ai,tim@offerbee.ai MAILER_EMAIL_ADDRESS= BLOB_BUCKET_ID= AWS_REGION= +# Apple Maps geocoding (Geocode/ReverseGeocode via Apple, Google fallback). +# The private key is a secret; deploy.sh appends APPLE_MAPS_PRIVATE_KEY from +# Secret Manager (stored as base64-encoded PEM, single line). +APPLE_MAPS_ENABLED=true +APPLE_MAPS_TEAM_ID=JRBD76VZ75 +APPLE_MAPS_KEY_ID=FUTFWSCQA4 +APPLE_MAPS_QUOTA_THRESHOLD=0.9 +APPLE_MAPS_EXTERNAL_ALLOWANCE=0 From 8a5403588149ddc67bbbea438f03f70e5be02a3e Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Thu, 13 Aug 2026 22:23:19 -0700 Subject: [PATCH 18/18] test(iowrappers): satisfy errcheck on test-server writes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YXnUPPE6LvNbzU1QnYuUCn --- iowrappers/apple_maps_client_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iowrappers/apple_maps_client_test.go b/iowrappers/apple_maps_client_test.go index c9335709..e08fc67e 100644 --- a/iowrappers/apple_maps_client_test.go +++ b/iowrappers/apple_maps_client_test.go @@ -35,7 +35,7 @@ func appleTestClient(t *testing.T, handler http.HandlerFunc) (*AppleMapsClient, lastQuery := &url.Values{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/token" { - fmt.Fprint(w, `{"accessToken":"test-token","expiresInSeconds":1800}`) + _, _ = fmt.Fprint(w, `{"accessToken":"test-token","expiresInSeconds":1800}`) return } q := r.URL.Query() @@ -88,7 +88,7 @@ func TestAppleReverseGeocodeMapsAdminAreaBothWays(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client, _ := appleTestClient(t, func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, tc.body) + _, _ = fmt.Fprint(w, tc.body) }) got, err := client.ReverseGeocode(context.Background(), 37.33, -122.03) if err != nil { @@ -108,7 +108,7 @@ func TestAppleReverseGeocodeMapsAdminAreaBothWays(t *testing.T) { // matching always yields a locality; Apple cannot. func TestAppleGeocodeNeverOverwritesWithAnEmptyValue(t *testing.T) { client, _ := appleTestClient(t, func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, `{"results":[{"country":"Japan","countryCode":"JP", + _, _ = fmt.Fprint(w, `{"results":[{"country":"Japan","countryCode":"JP", "coordinate":{"latitude":35.6895,"longitude":139.6917}, "structuredAddress":{"administrativeArea":"Tokyo"}}]}`) }) @@ -144,7 +144,7 @@ func TestAppleGeocodeFlattensTheQuery(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client, lastQuery := appleTestClient(t, func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, `{"results":[{"country":"United States", + _, _ = fmt.Fprint(w, `{"results":[{"country":"United States", "coordinate":{"latitude":33.66,"longitude":-95.55}, "structuredAddress":{"locality":"Paris","administrativeAreaCode":"TX"}}]}`) })