diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..c059afe82 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,71 @@ +name: Deploy + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + tag: + description: "Existing image tag to (re)deploy; empty = build current SHA" + required: false + +permissions: + contents: read + id-token: write + +concurrency: + group: deploy-production + cancel-in-progress: false + +jobs: + test: + uses: ./.github/workflows/go.yml + + deploy: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Resolve and validate image tag + id: tag + run: | + tag="${{ inputs.tag != '' && inputs.tag || github.sha }}" + # tag flows into shell commands below and a docker reference; allow + # only the characters valid in a docker tag so it cannot inject. + if ! printf '%s' "$tag" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$'; then + echo "refusing unsafe image tag: $tag" >&2 + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }} + service_account: ${{ vars.GCP_DEPLOYER_SA }} + + - uses: google-github-actions/setup-gcloud@v3 + + - name: Build and push image + if: inputs.tag == '' + env: + IMAGE: ${{ vars.GCP_IMAGE }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + gcloud auth configure-docker us-west1-docker.pkg.dev --quiet + docker build -t "${IMAGE}:${TAG}" . + docker push "${IMAGE}:${TAG}" + + - name: Deploy to VM over IAP + env: + ZONE: ${{ vars.GCP_ZONE }} + VM: ${{ vars.GCP_VM }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + gcloud compute ssh "$VM" --zone "$ZONE" --tunnel-through-iap \ + --command='mkdir -p /tmp/planner-deploy' + gcloud compute scp deploy/docker-compose.prod.yml deploy/Caddyfile \ + deploy/env.production deploy/deploy.sh \ + "$VM":/tmp/planner-deploy/ --zone "$ZONE" --tunnel-through-iap + gcloud compute ssh "$VM" --zone "$ZONE" --tunnel-through-iap \ + --command="sudo bash -c 'cp /tmp/planner-deploy/* /opt/planner/ && bash /opt/planner/deploy.sh ${TAG}'" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 241d24bab..838b5d3ab 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -2,48 +2,41 @@ name: Go on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] + workflow_call: jobs: golangci: name: lint runs-on: ubuntu-latest steps: - - uses: actions/setup-go@v3 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: - go-version: ^1.23 - - uses: actions/checkout@v3 + go-version-file: go.mod - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v9 with: version: latest + only-new-issues: true build: name: Build runs-on: ubuntu-latest steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v6 + - name: Set up Go 1.x - uses: actions/setup-go@v3 + uses: actions/setup-go@v6 with: - go-version: ^1.23 + go-version-file: go.mod id: go - - name: Check out code into the Go module directory - uses: actions/checkout@v3 - - - name: Get dependencies - run: | - go get -v -t -d ./... - if [ -f Gopkg.toml ]; then - curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh - dep ensure - fi - - name: Build run: go build -v . - working-directory: . - name: Test run: go test -v ./... diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml deleted file mode 100644 index 884b27259..000000000 --- a/.github/workflows/node.js.yml +++ /dev/null @@ -1,31 +0,0 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs - -name: Node.js CI - -on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] - -jobs: - build: - - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [16.x, 18.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - - steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run build --if-present - - run: npm test diff --git a/.gitignore b/.gitignore index 989af4c0c..cfbe234c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ .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 .DS_Store node_modules/ +Vacation-planner diff --git a/Dockerfile b/Dockerfile index 8dbcd8a20..7ad822ef8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,17 @@ -FROM golang:1.21-alpine - -ENV GO111MODULE=on - -COPY . /app/ -WORKDIR /app/ - -RUN go build -v . +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /out/vacation-planner . +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata && \ + addgroup -S app && adduser -S app -G app +WORKDIR /app +COPY --from=build /out/vacation-planner ./vacation-planner +COPY config/ ./config/ +COPY assets/ ./assets/ +USER app EXPOSE 10000 - -CMD ["./Vacation-planner"] +CMD ["./vacation-planner"] diff --git a/POI/categories.go b/POI/categories.go index 1c09290dc..7d035c4ab 100644 --- a/POI/categories.go +++ b/POI/categories.go @@ -2,6 +2,8 @@ package POI import ( "fmt" + "math" + "sort" "strings" ) @@ -10,66 +12,395 @@ type PlaceCategory string const ( PlaceCategoryVisit = PlaceCategory("Visit") PlaceCategoryEatery = PlaceCategory("Eatery") + // Categories below back the merchant/best-card nearby endpoint. They are not used by + // trip planning, which only slots Visit/Eatery places. + PlaceCategoryShopping = PlaceCategory("Shopping") + PlaceCategoryLodging = PlaceCategory("Lodging") + PlaceCategoryWellness = PlaceCategory("Wellness") ) type PlaceIcon string const ( - PlaceIconVisit = PlaceIcon("attractions") - PlaceIconEatery = PlaceIcon("restaurant") - PlaceIconEmpty = PlaceIcon("") + PlaceIconVisit = PlaceIcon("attractions") + PlaceIconEatery = PlaceIcon("restaurant") + PlaceIconShopping = PlaceIcon("shopping_bag") + PlaceIconLodging = PlaceIcon("hotel") + PlaceIconWellness = PlaceIcon("spa") + PlaceIconEmpty = PlaceIcon("") ) type LocationType string const ( - LocationTypeCafe = LocationType("cafe") - LocationTypeRestaurant = LocationType("restaurant") - LocationTypeMuseum = LocationType("museum") - LocationTypeGallery = LocationType("art_gallery") - LocationTypeAmusementPark = LocationType("amusement_park") - LocationTypePark = LocationType("park") + // LocationTypeAny leaves the Google Maps place type unset, used by keyword (brand) searches + LocationTypeAny = LocationType("") + // Eatery place types + LocationTypeCafe = LocationType("cafe") + LocationTypeRestaurant = LocationType("restaurant") + LocationTypeBar = LocationType("bar") + LocationTypeBakery = LocationType("bakery") + LocationTypeMealTakeaway = LocationType("meal_takeaway") + LocationTypeMealDelivery = LocationType("meal_delivery") + LocationTypeNightClub = LocationType("night_club") + // Visit place types + LocationTypeMuseum = LocationType("museum") + LocationTypeGallery = LocationType("art_gallery") + LocationTypeAmusementPark = LocationType("amusement_park") + LocationTypePark = LocationType("park") + LocationTypeTouristAttraction = LocationType("tourist_attraction") + LocationTypeZoo = LocationType("zoo") + LocationTypeAquarium = LocationType("aquarium") + LocationTypeMovieTheater = LocationType("movie_theater") + LocationTypeStadium = LocationType("stadium") + LocationTypeBowlingAlley = LocationType("bowling_alley") + // Shopping place types + LocationTypeShoppingMall = LocationType("shopping_mall") + LocationTypeDepartmentStore = LocationType("department_store") + LocationTypeSupermarket = LocationType("supermarket") + LocationTypeClothingStore = LocationType("clothing_store") + LocationTypeStore = LocationType("store") + // LocationTypeGroceryOrSupermarket is a types[]-only value: it appears in a place's + // Types list but is NOT a legal ?type= value for the legacy Nearby Search + // (maps.ParsePlaceType rejects it). Never pass it to CreateMapSearchRequest or add it to + // GetPlaceTypes; it is matched only via PrimaryLocationType/GetPlaceCategory. + LocationTypeGroceryOrSupermarket = LocationType("grocery_or_supermarket") + // Any type that is a strict specialization of the already-mapped `store` goes to Shopping. + LocationTypeConvenienceStore = LocationType("convenience_store") + LocationTypeHardwareStore = LocationType("hardware_store") + LocationTypeHomeGoodsStore = LocationType("home_goods_store") + LocationTypeElectronicsStore = LocationType("electronics_store") + LocationTypeFurnitureStore = LocationType("furniture_store") + LocationTypeBookStore = LocationType("book_store") + LocationTypeShoeStore = LocationType("shoe_store") + LocationTypeJewelryStore = LocationType("jewelry_store") + LocationTypePetStore = LocationType("pet_store") + LocationTypeBicycleStore = LocationType("bicycle_store") + LocationTypeFlorist = LocationType("florist") + LocationTypeLiquorStore = LocationType("liquor_store") + LocationTypeGasStation = LocationType("gas_station") + // Lodging place types + LocationTypeLodging = LocationType("lodging") + // Wellness place types + LocationTypeGym = LocationType("gym") + LocationTypeSpa = LocationType("spa") + LocationTypePharmacy = LocationType("pharmacy") + LocationTypeDrugstore = LocationType("drugstore") + LocationTypeBeautySalon = LocationType("beauty_salon") + LocationTypeHairCare = LocationType("hair_care") ) -func GetPlaceCategory(placeType LocationType) (placeCategory PlaceCategory) { - switch placeType { - case LocationTypePark, LocationTypeAmusementPark, LocationTypeGallery, LocationTypeMuseum: - placeCategory = PlaceCategoryVisit - case LocationTypeCafe, LocationTypeRestaurant: - placeCategory = PlaceCategoryEatery - default: - placeCategory = PlaceCategoryEatery +// placeTypeToCategory is the reverse map from a Google place type to its category. It backs +// both GetPlaceCategory (the write path / classification rule) and ReclassifyForCategory (the +// read filter) below, so there is exactly one table that decides "what category does this +// Google type belong to" anywhere in the service. +// +// It is a SUPERSET of GetPlaceTypes' inverse: it covers every Google primary type (see +// PrimaryLocationType) this service knows how to classify, not only the types the nearby-search +// endpoints actively query for (GetPlaceTypes' 18 searched types are all present here too). +// Widening this map only ever makes ReclassifyForCategory keep MORE places, never fewer — see +// TestReclassifyForCategoryKeepsAllFormerlySearchedTypes. +var placeTypeToCategory = map[LocationType]PlaceCategory{ + // Eatery + LocationTypeCafe: PlaceCategoryEatery, + LocationTypeRestaurant: PlaceCategoryEatery, + LocationTypeBar: PlaceCategoryEatery, + LocationTypeBakery: PlaceCategoryEatery, + LocationTypeMealTakeaway: PlaceCategoryEatery, + LocationTypeMealDelivery: PlaceCategoryEatery, + LocationTypeNightClub: PlaceCategoryEatery, + + // Visit + LocationTypePark: PlaceCategoryVisit, + LocationTypeAmusementPark: PlaceCategoryVisit, + LocationTypeGallery: PlaceCategoryVisit, + LocationTypeMuseum: PlaceCategoryVisit, + LocationTypeTouristAttraction: PlaceCategoryVisit, + LocationTypeZoo: PlaceCategoryVisit, + LocationTypeAquarium: PlaceCategoryVisit, + LocationTypeMovieTheater: PlaceCategoryVisit, + LocationTypeStadium: PlaceCategoryVisit, + LocationTypeBowlingAlley: PlaceCategoryVisit, + + // Shopping. Any type that is a strict specialization of the already-mapped `store` goes + // to Shopping. + LocationTypeShoppingMall: PlaceCategoryShopping, + LocationTypeDepartmentStore: PlaceCategoryShopping, + LocationTypeSupermarket: PlaceCategoryShopping, + LocationTypeClothingStore: PlaceCategoryShopping, + LocationTypeStore: PlaceCategoryShopping, + LocationTypeGroceryOrSupermarket: PlaceCategoryShopping, + LocationTypeConvenienceStore: PlaceCategoryShopping, + LocationTypeHardwareStore: PlaceCategoryShopping, + LocationTypeHomeGoodsStore: PlaceCategoryShopping, + LocationTypeElectronicsStore: PlaceCategoryShopping, + LocationTypeFurnitureStore: PlaceCategoryShopping, + LocationTypeBookStore: PlaceCategoryShopping, + LocationTypeShoeStore: PlaceCategoryShopping, + LocationTypeJewelryStore: PlaceCategoryShopping, + LocationTypePetStore: PlaceCategoryShopping, + LocationTypeBicycleStore: PlaceCategoryShopping, + LocationTypeFlorist: PlaceCategoryShopping, + LocationTypeLiquorStore: PlaceCategoryShopping, + LocationTypeGasStation: PlaceCategoryShopping, + + // Lodging + LocationTypeLodging: PlaceCategoryLodging, + + // Wellness + LocationTypeGym: PlaceCategoryWellness, + LocationTypeSpa: PlaceCategoryWellness, + LocationTypePharmacy: PlaceCategoryWellness, + LocationTypeDrugstore: PlaceCategoryWellness, + LocationTypeBeautySalon: PlaceCategoryWellness, + LocationTypeHairCare: PlaceCategoryWellness, + + // LocationTypeAny ("") is deliberately NOT a key: GetPlaceCategory("") must stay + // ("", false), the same as any other unmapped type. +} + +// GetPlaceCategory maps a Google Maps place type back to its category, reporting whether +// the type is mapped at all. placeTypeToCategory (above) is now a SUPERSET of GetPlaceTypes' +// inverse, not its exact inverse — it classifies every primary type this service recognizes, +// while GetPlaceTypes still only lists the subset each category's Nearby Search issues as +// ?type=. The write path relies on the shared subset: the nearby-search cache writes each place +// under EncodeNearbySearchRedisKey(GetPlaceCategory(place.LocationType), ...), so a type that +// resolves to a different category than the one it was searched under would never cache-hit. +// +// It deliberately has NO default category. An earlier version defaulted to Eatery, which +// silently absorbed place types the legacy Nearby Search does not understand — two +// Places-API-(New)-only types ("fast_food_restaurant", "food_court") were added to +// GetPlaceTypes(Eatery), Google ignored the unenforceable filter, and prominence-ranked +// hotels were written into the eatery geo buckets. Returning ok=false forces every caller +// to decide what an unmapped type means, and makes TestPlaceCategoryRoundTrip able to fail. +// +// DO NOT ADD: fast_food_restaurant, food_court. Both are Places API (New)-only values that +// never appear in a legacy Nearby Search result's types[], so adding them re-opens the exact +// guard class TestGetPlaceCategoryRejectsUnknownTypes exists for. +func GetPlaceCategory(placeType LocationType) (PlaceCategory, bool) { + cat, ok := placeTypeToCategory[placeType] + return cat, ok +} + +// MappedLocationTypes returns every LocationType classified by GetPlaceCategory, sorted for +// deterministic iteration. Exported for tests (e.g. TestGetPlaceCategoryKeysAreGoogleTypes), +// which need to walk the full map without depending on Go's randomized map iteration order. +func MappedLocationTypes() []LocationType { + keys := make([]LocationType, 0, len(placeTypeToCategory)) + for t := range placeTypeToCategory { + keys = append(keys, t) } - return + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + return keys } -// GetPlaceTypes returns a set of types defined in Google Maps API given a location type +// GetPlaceTypes returns a set of types defined in Google Maps API given a location type. This +// is the SEARCHED subset: the exact place types each category's Nearby Search issues as ?type=. +// It is intentionally unchanged by the classification-map expansion above — widening it changes +// the outbound Google query for every existing search, not just how a result gets classified, +// and is how the fast_food_restaurant incident happened (a type the legacy API doesn't +// understand, silently ignored by Google, poisoning the eatery cache). Add new types to +// placeTypeToCategory / GetPlaceCategory instead of here. func GetPlaceTypes(placeCat PlaceCategory) (placeTypes []LocationType) { switch placeCat { case PlaceCategoryVisit: placeTypes = append(placeTypes, - []LocationType{LocationTypePark, LocationTypeAmusementPark, LocationTypeGallery, LocationTypeMuseum}...) + []LocationType{ + LocationTypePark, LocationTypeAmusementPark, LocationTypeGallery, LocationTypeMuseum, + LocationTypeMovieTheater, LocationTypeBowlingAlley, + LocationTypeZoo, LocationTypeAquarium, + LocationTypeStadium, LocationTypeTouristAttraction, + }...) case PlaceCategoryEatery: placeTypes = append(placeTypes, - []LocationType{LocationTypeCafe, LocationTypeRestaurant}...) + []LocationType{LocationTypeCafe, LocationTypeRestaurant, LocationTypeBar, LocationTypeBakery, LocationTypeMealTakeaway}...) + case PlaceCategoryShopping: + placeTypes = append(placeTypes, + []LocationType{LocationTypeShoppingMall, LocationTypeDepartmentStore, LocationTypeSupermarket, LocationTypeClothingStore, LocationTypeStore}...) + case PlaceCategoryLodging: + placeTypes = append(placeTypes, + []LocationType{LocationTypeLodging}...) + case PlaceCategoryWellness: + placeTypes = append(placeTypes, + []LocationType{LocationTypeGym, LocationTypeSpa, LocationTypePharmacy}...) } return } +// AllPlaceCategories enumerates every place category. It is the single source of truth for +// "what categories exist": ParsePlaceCategory validates against it, and callers that must touch +// every category's geo bucket (e.g. deleting a place that may be filed under several) iterate +// it rather than hardcoding a subset — which is how Shopping, Lodging, and Wellness came to be +// missed by cleanup paths written before they existed. +var AllPlaceCategories = []PlaceCategory{ + PlaceCategoryVisit, + PlaceCategoryEatery, + PlaceCategoryShopping, + PlaceCategoryLodging, + PlaceCategoryWellness, +} + +// ParsePlaceCategory converts a category string (e.g. from an API request) into a known +// PlaceCategory, reporting whether it matched. Matching is exact against the canonical +// category names ("Eatery", "Shopping", "Lodging", "Wellness", "Visit"). +func ParsePlaceCategory(s string) (PlaceCategory, bool) { + for _, cat := range AllPlaceCategories { + if PlaceCategory(s) == cat { + return cat, true + } + } + return PlaceCategory(""), false +} + +// umbrellaLocationTypes are Google's generic feature types that describe almost +// every place and say nothing about its primary function. They are skipped when +// picking a place's primary type. +// Note: "store" is intentionally NOT here — it is a meaningful Shopping type. +var umbrellaLocationTypes = map[LocationType]bool{ + LocationType("food"): true, + LocationType("point_of_interest"): true, + LocationType("establishment"): true, + LocationType("premise"): true, + LocationType("geocode"): true, + LocationType("political"): true, +} + +// PrimaryLocationType returns a place's primary Google feature type: the first +// entry in its Types list that isn't a generic umbrella (food, point_of_interest, +// …). Google lists the most specific type first, so this is the place's real +// function (e.g. "supermarket" for a store the restaurant search also matched). +// Returns "" when there is no meaningful type (empty/unknown Types). +func PrimaryLocationType(types []string) LocationType { + for _, t := range types { + lt := LocationType(t) + if !umbrellaLocationTypes[lt] { + return lt + } + } + return LocationType("") +} + +// ReclassifyForCategory decides whether a place belongs in cat based on its PRIMARY function, +// and returns the place re-tagged with that primary type. It now keys on the same +// placeTypeToCategory map as GetPlaceCategory — the same map the nearby-search write path +// (SetPlacesAddGeoLocations) and the bucket-cleanup migration +// (RemoveMisclassifiedPlacesFromCategoryBuckets) key on — so there is one rule everywhere for +// "does this place belong in this category": +// +// - primary type maps to cat → keep, LocationType := primary +// (e.g. a "cafe"-searched result that is really a restaurant is re-tagged). +// - primary type maps to a DIFFERENT category → drop (keep=false): its main +// function is something else (a supermarket the food search returned). +// - no Types at all (empty primary) → keep unchanged (older cached records), so +// coverage never regresses on data written before Types was captured. +// - primary type is present but unmapped → drop (keep=false), same as the old rule: +// an unmapped primary was never a member of GetPlaceTypes(cat) either, so this is not a +// behavior change from before the placeTypeToCategory unification. +// +// Because placeTypeToCategory is a strict superset of GetPlaceTypes' searched types (see +// GetPlaceCategory's docstring), this keeps every place the old primary-in-GetPlaceTypes(cat) +// rule kept, plus more — never fewer. TestReclassifyForCategoryKeepsAllFormerlySearchedTypes +// pins that monotonicity. +func ReclassifyForCategory(place Place, cat PlaceCategory) (Place, bool) { + primary := PrimaryLocationType(place.Types) + if primary == LocationType("") { + return place, true // records with no Types stay kept (older cache entries) + } + if c, ok := GetPlaceCategory(primary); ok && c == cat { + place.LocationType = primary // re-tag with the true type + return place, true + } + return place, false +} + // PriceyEatery returns whether a eatery place is expensive based on its price level func PriceyEatery(placeCategory PlaceCategory, priceLevel PriceLevel) bool { return (placeCategory == PlaceCategoryEatery) && (priceLevel >= PriceLevelThree) } -// EncodeNearbySearchRedisKey generates a Redis Key for Redis nearby search with place category and price info -// The key includes the price level info for eatery and no price info for visit -func EncodeNearbySearchRedisKey(placeCategory PlaceCategory, level PriceLevel) string { - keys := []string{"placeIDs", strings.ToLower(string(placeCategory))} - // add price levels for eatery category - if placeCategory == PlaceCategoryEatery { - keys = append(keys, fmt.Sprintf("level%d", level)) +// EncodeNearbySearchRedisKey generates the Redis geo-index key for a category's nearby search. +// +// One bucket per category, with no price segment. Eateries used to be split into +// placeIDs:eatery:level0..4 keyed on each place's own price level, which fragmented the index +// for no benefit: Google omits price_level for most places (so they collapsed into level0) and +// only accepts a price filter at level >= 3, so searches for levels 0-2 were identical yet each +// read back a fifth of the data. Callers that care about price already filter after the read +// (matching.filterPlacesOnPriceLevel). Redis GEO is a sorted set scored by 52-bit geohash and +// GEORADIUS probes 9 geohash cells at O(log N + M), so one bucket holds millions of members +// without degrading — which is how placeIDs:visit has always worked. +func EncodeNearbySearchRedisKey(placeCategory PlaceCategory) string { + return strings.Join([]string{"placeIDs", strings.ToLower(string(placeCategory))}, ":") +} + +// searchCellDegrees sizes the freshness grid to iowrappers.ColdStartSearchRadius (~8 km), the +// area one cold external search actually populates. A fixed-degree grid narrows in meters as +// latitude rises, which only shrinks cells — erring toward an extra cold search, never toward +// claiming coverage we do not have. +const searchCellDegrees = 0.072 + +// EncodeSearchCell quantizes coordinates to the freshness grid. This is a cache key, not a +// spatial index: it is never range-queried, so it needs no neighbor probing or Z-order +// ordering. The only property that matters is that a cell is no larger than the area a cold +// search populates. +func EncodeSearchCell(lat, lng float64) string { + return fmt.Sprintf("%d_%d", + int(math.Floor(lat/searchCellDegrees)), + int(math.Floor(lng/searchCellDegrees))) +} + +// EncodeLastSearchTimeField identifies the external search variant that last covered a cell: +// +// : the unfiltered search — every category, and eatery levels 0-2 +// :eatery:pricey the price-filtered 4x-radius search, N in {3,4} +// +// Note this is scoped to the SEARCH, not to the bucket. Levels 0-2 share one field because +// Google is issued an identical unfiltered request for all three, so two of every three +// fan-outs were redundant. Levels 3-4 keep their own field because PriceyEatery makes Google +// apply a real price filter at four times the radius: a fresh generic marker must not suppress +// that search, or expensive places beyond the generic search's reach are never fetched. +// +// It is keyed on a location cell rather than country/admin1/city because the buckets it guards +// are geo indexes read from arbitrary coordinates. A city name has no extent, so it cannot +// answer "did we populate the area this query covers?" — a request 20 km from a city centroid +// would read a marker claiming freshness over ground no search had reached. +func EncodeLastSearchTimeField(placeCategory PlaceCategory, level PriceLevel, lat, lng float64) string { + segments := []string{EncodeSearchCell(lat, lng), strings.ToLower(string(placeCategory))} + if PriceyEatery(placeCategory, level) { + segments = append(segments, fmt.Sprintf("pricey%d", level)) } - return strings.Join(keys, ":") + return strings.Join(segments, ":") +} + +// EncodeBrandLastSearchTimeField is the brand-search equivalent of EncodeLastSearchTimeField. +// Brand buckets are geo indexes read from precise coordinates too, so they need the same cell +// scoping. +func EncodeBrandLastSearchTimeField(keyword string, lat, lng float64) string { + return strings.Join([]string{EncodeSearchCell(lat, lng), "brand", NormalizeBrandKey(keyword)}, ":") +} + +// NormalizeBrandKey converts a brand keyword into a stable slug used in Redis keys and +// name matching, e.g. "Dunkin' Donuts" -> "dunkin-donuts" +func NormalizeBrandKey(keyword string) string { + var b strings.Builder + pendingDash := false + for _, r := range strings.ToLower(strings.TrimSpace(keyword)) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + if pendingDash && b.Len() > 0 { + b.WriteRune('-') + } + b.WriteRune(r) + pendingDash = false + } else { + pendingDash = true + } + } + return b.String() +} + +// EncodeBrandNearbySearchRedisKey generates a Redis key for brand-scoped nearby search, +// keeping brand search results in buckets separate from category-based searches +func EncodeBrandNearbySearchRedisKey(keyword string) string { + return strings.Join([]string{"placeIDs", "brand", NormalizeBrandKey(keyword)}, ":") } type StayingTime uint8 diff --git a/POI/places.go b/POI/places.go index 45cc5c67e..b1000c959 100644 --- a/POI/places.go +++ b/POI/places.go @@ -43,6 +43,11 @@ func (w Weekday) Name() string { return mapping[w] } +// WeekdayFromTime converts Go's time.Weekday (Sunday=0) to POI's Weekday (Monday=0) +func WeekdayFromTime(day time.Weekday) Weekday { + return Weekday((int(day) + 6) % 7) +} + type PlacePhoto struct { // reference from Google Images Reference string `bson:"reference"` @@ -62,21 +67,28 @@ const ( ) type Place struct { - ID string `bson:"_id"` - Name string `bson:"name"` - Status BusinessStatus `bson:"status"` - LocationType LocationType `bson:"location_type"` - Address Address `bson:"address"` - FormattedAddress string `bson:"formatted_address"` - Location Location `bson:"location"` - PriceLevel PriceLevel `bson:"price_level"` - Rating float32 `bson:"rating"` - Hours [7]string `bson:"hours"` - URL string `bson:"url"` - Photo PlacePhoto `bson:"photo"` - UserRatingsTotal int `bson:"user_ratings_total"` - Summary string `bson:"summary"` - LastUpdatedAt string `bson:"last_updated_at"` + ID string `bson:"_id"` + Name string `bson:"name"` + Status BusinessStatus `bson:"status"` + LocationType LocationType `bson:"location_type"` + // Types is the full Google Maps feature-type list for the place (primary type + // first), e.g. ["supermarket","grocery_or_supermarket","food",...]. LocationType + // above is the single type a search tagged the place with (often the SEARCHED + // type, not the actual one); Types preserves the truth so callers can classify + // a place by its primary function. Populated on nearby search; may be empty on + // older cached records. + Types []string `bson:"types"` + Address Address `bson:"address"` + FormattedAddress string `bson:"formatted_address"` + Location Location `bson:"location"` + PriceLevel PriceLevel `bson:"price_level"` + Rating float32 `bson:"rating"` + Hours [7]string `bson:"hours"` + URL string `bson:"url"` + Photo PlacePhoto `bson:"photo"` + UserRatingsTotal int `bson:"user_ratings_total"` + Summary string `bson:"summary"` + LastUpdatedAt string `bson:"last_updated_at"` } type Location struct { @@ -132,6 +144,16 @@ const ( PriceLevelDefault = 2 ) +// AllPriceLevels enumerates every price level a place can carry. +// +// Eateries used to be partitioned across one geo bucket per level +// (placeIDs:eatery:level0..4); they no longer are, so this is not a list of buckets. Its +// remaining use is the migration that unions those retired keys into placeIDs:eatery, which +// needs to enumerate them. See EncodeNearbySearchRedisKey for why the split was collapsed. +var AllPriceLevels = []PriceLevel{ + PriceLevelZero, PriceLevelOne, PriceLevelTwo, PriceLevelThree, PriceLevelFour, +} + func (place *Place) GetName() string { return place.Name } @@ -144,10 +166,33 @@ func (place *Place) GetStatus() BusinessStatus { return place.Status } +// DefaultOpeningHours is the placeholder CreatePlace writes for any weekday the source data left +// blank. Because it is always filled in, a stored place's Hours are never empty and their +// emptiness cannot be used to detect missing data — use HasRealOpeningHours instead. +const DefaultOpeningHours = "8:30 am – 9:30 pm" + func (place *Place) GetHour(day Weekday) string { return place.Hours[day] } +// HasRealOpeningHours reports whether any weekday carries hours that came from source data +// rather than the DefaultOpeningHours placeholder. +func (place *Place) HasRealOpeningHours() bool { + for day := DateMonday; day <= DateSunday; day++ { + if hour := place.GetHour(day); hour != "" && hour != DefaultOpeningHours { + return true + } + } + return false +} + +// KnownClosedOnDay reports whether the place's cached hours explicitly mark it closed on +// the given weekday, e.g. "Sunday: Closed". Places with unknown or default hours return +// false — absence of data is not treated as closed. +func (place *Place) KnownClosedOnDay(day Weekday) bool { + return strings.Contains(strings.ToLower(place.GetHour(day)), "closed") +} + func (place *Place) GetID() string { return place.ID } @@ -337,7 +382,7 @@ func CreatePlace(name, addr, formattedAddr, businessStatus string, locationType // set default for weekday = DateMonday; weekday <= DateSunday; weekday++ { if place.GetHour(weekday) == "" { - place.SetHour(weekday, "8:30 am – 9:30 pm") + place.SetHour(weekday, DefaultOpeningHours) } } diff --git a/README.md b/README.md index 50b500c2d..3efd2f5f0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,128 @@ will incorporate personalized recommendations. * View trip details * Make a plan yourself by creating a template +## Place Search API + +Two endpoints let a caller add a place to the shared cache by name, as an alternative to the +category-based nearby-places search: search by free text, then confirm one result into the +cache. + +### `POST /v1/place-search` + +Runs a free-text Google Places search around a coordinate and returns every result as a +confirmable candidate; nothing is written to the shared cache by this call. Every result +(insertable or not) is stashed server-side under its Google place ID for 30 minutes so a +subsequent confirm call can resolve it by ID alone rather than trusting place data an HTTP +caller might send back. + +Request: + +```json +{ + "query": "Joe's Pizza", + "location": {"latitude": 40.7309, "longitude": -74.0021}, + "radius": 5000, + "limit": 10 +} +``` + +`query` is required (2-120 characters). `location` (`latitude`/`longitude`) is required, with +no default — same as the nearby-places endpoints' own zero-location rejection — because an +unbiased text query like "konjoe" can resolve to the wrong continent without a coordinate to +anchor it. `radius` is in meters and is clamped to the service's max search radius (16,000 m / +~10 miles) when zero or larger than that maximum. `limit` defaults to 10 and is capped at 20. + +Response (`200`, fields elided for brevity): + +```json +{ + "results": [ + { + "place": { + "ID": "ChIJd8BlQ2BZwokRAFUEcm_qrcA", + "Name": "Joe's Pizza", + "Status": "OPERATIONAL", + "LocationType": "restaurant", + "Types": ["restaurant", "food", "point_of_interest", "establishment"], + "FormattedAddress": "7 Carmine St, New York, NY 10014", + "Location": {"latitude": 40.7309, "longitude": -74.0021, "city": "", "adminAreaLevelOne": "", "country": ""}, + "PriceLevel": 1, + "Rating": 4.5, + "UserRatingsTotal": 3200 + }, + "category": "Eatery", + "insertable": true + } + ] +} +``` + +`category` and `insertable` are always derived server-side from the place's own Google types. +A candidate whose primary type does not map to a known category is still returned (so the +caller can see it), but with `category: ""` and `insertable: false`. + +### `POST /v1/place-search/confirm` + +Inserts a previously returned candidate into the shared cache — `placeIDs:` plus a +`place_details:place_ID:*` record — making one Place Details call to fill in hours, address, +URL, summary, and (as a gap-fill only, never overwriting an existing photo) photo before +writing. + +Request: + +```json +{"placeId": "ChIJd8BlQ2BZwokRAFUEcm_qrcA"} +``` + +Response (`200`): + +```json +{ + "place": { "ID": "ChIJd8BlQ2BZwokRAFUEcm_qrcA", "...": "same shape as place-search's place object, now enriched with hours/URL/summary" }, + "category": "Eatery", + "alreadyCached": false +} +``` + +Error responses: + +* `404` `{"error": "...", "code": "candidate_expired"}` — the place ID was never searched, or + its 30-minute stash entry expired. +* `422` `{"error": "...", "code": "unsupported_place_type", "placeType": ""}` — + the candidate's primary Google type does not map to any category; nothing is written. + +#### Visibility + +Confirming a place writes it into the shared Redis cache immediately — you can verify this +directly, without waiting on any other endpoint: the confirm response itself echoes the +written place, and `ZSCORE placeIDs: ` against Redis returns a score right +away. + +Whether the place then shows up in `/v1/nearby-places-by-category` depends on that cell's +search freshness, not on the write above. In a **warm** cell (one whose `MapsLastSearchTime` +marker is still fresh), the confirmed place appears on the very next read. In a **cold or +stale** cell, the next read triggers a background Google search whose results replace — not +merge with — the cached bucket in that response, so the newly confirmed place is briefly +missing from that one response and only appears from the following read onward. When +verifying a confirm in a cell you are not sure is warm, check `ZSCORE` first and expect to +need up to two reads of `/v1/nearby-places-by-category` before the place shows up. + +Never manually stamp a cell's `MapsLastSearchTime` to force this — doing so marks the cell +"searched" for 14 days and would suppress a real cold search the cell still needs. + +### Authentication + +Both endpoints require the same authentication as the other `/v1` endpoints: a Personal +Access Token via `Authorization: Bearer `, or a JWT session cookie as a browser +fallback. + +### Safety design + +The category a place lands under is always computed server-side from Google's own primary +type on the place, never accepted from the caller, and a primary type that maps to no known +category is refused outright rather than defaulted into some bucket — so nothing +client-supplied ever reaches the shared place cache unclassified. + ## Installation (Mac) * git clone the repository diff --git a/applemaps/auth.go b/applemaps/auth.go new file mode 100644 index 000000000..a7072e7e1 --- /dev/null +++ b/applemaps/auth.go @@ -0,0 +1,307 @@ +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 + + // 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. +// +// 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 + // 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. + generation uint64 +} + +// 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) { + 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.refreshAt) { + return ts.token, ts.generation, nil + } + token, err := ts.exchangeLocked(ctx) + if err != nil { + return "", 0, err + } + return token, ts.generation, nil +} + +// 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{} + 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++ +} + +// 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") + } + + 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 = 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 new file mode 100644 index 000000000..17f3328ae --- /dev/null +++ b/applemaps/auth_test.go @@ -0,0 +1,647 @@ +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 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) { + 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/client.go b/applemaps/client.go new file mode 100644 index 000000000..74d1520d7 --- /dev/null +++ b/applemaps/client.go @@ -0,0 +1,277 @@ +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 { + generation, err := c.attempt(ctx, path, params, out) + + var authErr *AuthError + if errors.As(err, &authErr) { + // 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. 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 generation, err + } + } + + generation, lastErr = c.once(ctx, path, params, out) + if lastErr == nil { + return generation, nil + } + + var apiErr *APIError + if !errors.As(lastErr, &apiErr) || !retryable(apiErr.StatusCode) { + return generation, lastErr + } + } + return generation, lastErr +} + +// 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 0, 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 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 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 generation, fmt.Errorf("applemaps: %s: HTTP %d and unreadable body: %w", path, resp.StatusCode, readErr) + } + return generation, newAPIError(resp.StatusCode, body) + } + + if out == nil { + return generation, nil + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return generation, fmt.Errorf("applemaps: %s: read response: %w", path, err) + } + if err := json.Unmarshal(body, out); err != nil { + return generation, fmt.Errorf("applemaps: %s: decode response: %w", path, err) + } + return generation, 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 000000000..0025961f9 --- /dev/null +++ b/applemaps/client_test.go @@ -0,0 +1,512 @@ +package applemaps + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "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) + } +} + +// 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) { + 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) + } +} + +// 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) + }) + // 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 000000000..2e9ee58cb --- /dev/null +++ b/applemaps/directions.go @@ -0,0 +1,254 @@ +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. 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. + 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") + } + // /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 +} + +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 000000000..84ee010b2 --- /dev/null +++ b/applemaps/directions_test.go @@ -0,0 +1,454 @@ +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}, + }, + { + // /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 { + 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") + } + }) + } +} + +// 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) { + 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/doc.go b/applemaps/doc.go new file mode 100644 index 000000000..e8d200290 --- /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 000000000..0b7c4a6f1 --- /dev/null +++ b/applemaps/errors.go @@ -0,0 +1,142 @@ +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 + +// 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 + 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/errors_test.go b/applemaps/errors_test.go new file mode 100644 index 000000000..cb523ccc1 --- /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 000000000..35b398d35 --- /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 000000000..0582048d0 --- /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 000000000..5ac3de547 --- /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 000000000..9305fd511 --- /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 000000000..c682aff50 --- /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/poicategory.go b/applemaps/poicategory.go new file mode 100644 index 000000000..b0aa91f74 --- /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/search.go b/applemaps/search.go new file mode 100644 index 000000000..21e0be33b --- /dev/null +++ b/applemaps/search.go @@ -0,0 +1,336 @@ +package applemaps + +import ( + "context" + "errors" + "fmt" + "net/url" + "slices" +) + +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 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. + EnablePagination bool +} + +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 +} + +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", string(r.SearchRegionPriority)) + } + if r.EnablePagination { + params.Set("enablePagination", "true") + } + 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 err := req.validate(); err != nil { + return nil, err + } + + var resp SearchResponse + if err := c.get(ctx, searchPath, req.params(c), &resp); err != nil { + return nil, err + } + 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. + 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. +// +// 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 err := req.validate(); err != nil { + return nil, err + } + if maxPages <= 0 { + maxPages = DefaultMaxSearchPages + } + + req.EnablePagination = true + result := &SearchAllResult{} + nextToken := "" + + for { + 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 + // 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 + } + + nextToken = "" + if page.PaginationInfo != nil { + nextToken = page.PaginationInfo.NextPageToken + } + if nextToken == "" { + return result, nil + } + if result.Pages >= maxPages { + result.Truncated = true + return result, nil + } + } +} + +// 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 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 { + 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", string(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 err := req.validate(); err != nil { + return nil, err + } + + 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 000000000..7007090b6 --- /dev/null +++ b/applemaps/search_test.go @@ -0,0 +1,512 @@ +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. +// +// 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 + queriesSeen := make([]url.Values, 0, totalPages) + + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + 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) + } + 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, &queriesSeen +} + +func TestSearchAllFollowsPagination(t *testing.T) { + client, calls, queriesSeen := 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 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 { + t.Error("displayMapRegion should come from the first page") + } +} + +// 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) { + 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() + // 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":{"lat":48.858,"lng":2.294}, + "structuredAddress":{"locality":"Paris","subAdministrativeArea":"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 { + 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.Fatal("structuredAddress did not decode") + } + if got.StructuredAddress.SubAdministrativeArea != "Paris" { + t.Errorf("subAdministrativeArea: got %q, want %q", got.StructuredAddress.SubAdministrativeArea, "Paris") + } +} + +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") + } +} + +// 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) + 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 new file mode 100644 index 000000000..1859202d6 --- /dev/null +++ b/applemaps/types.go @@ -0,0 +1,370 @@ +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 +// 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"` +} + +// 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 { + 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. +// +// 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"` + 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"` +} + +// 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 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"` + 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. +// +// 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 + +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 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. +// +// The set is not the same on both endpoints — see TransportTypeTransit. +type TransportType string + +const ( + TransportTypeAutomobile TransportType = "Automobile" + TransportTypeWalking TransportType = "Walking" + // 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 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 new file mode 100644 index 000000000..39b20e805 --- /dev/null +++ b/applemaps/types_test.go @@ -0,0 +1,353 @@ +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) + } +} + +// /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 + 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/assets/css/styles.css b/assets/css/styles.css index f44e67a83..6da9940ae 100644 --- a/assets/css/styles.css +++ b/assets/css/styles.css @@ -372,6 +372,83 @@ tr:nth-child(even) { background: rgba(255, 255, 255, 0.3); } +/* User Profile Swiper Styles */ +#profileSwiper { + height: 600px; + width: 100%; + max-width: 600px; + margin: 0 auto; + padding: 20px 0; +} + +#profileSwiper .swiper-slide { + height: auto; + display: flex; + justify-content: center; + align-items: center; +} + +#profileSwiper .swiper-slide-inner { + background: white; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + overflow: hidden; + width: 100%; + max-width: 500px; + min-height: 300px; +} + +[data-theme="dark"] #profileSwiper .swiper-slide-inner { + background: #1a1a1a; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.profile-favorites-slide { + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +[data-theme="dark"] .profile-favorites-slide { + background: linear-gradient(135deg, #434343 0%, #000000 100%); +} + +.profile-favorites-slide .card-body { + color: white; +} + +.profile-favorites-slide .card-title { + color: white !important; + font-weight: 700; +} + +.profile-plan-slide { + min-height: 400px; +} + +.profile-plan-slide .list-group-item { + background: transparent; + border-color: rgba(0, 0, 0, 0.125); + padding: 0.5rem 0; +} + +[data-theme="dark"] .profile-plan-slide .list-group-item { + border-color: rgba(255, 255, 255, 0.125); +} + +.profile-plan-slide .card-link { + color: #24c1e0; + text-decoration: none; + font-size: 0.95rem; +} + +.profile-plan-slide .card-link:hover { + text-decoration: underline; +} + @media only screen and (min-width: 576px) { #dateDiv { max-width: 11em; diff --git a/assets/js/user_profile.js b/assets/js/user_profile.js index 5fb150d8e..fd052d139 100644 --- a/assets/js/user_profile.js +++ b/assets/js/user_profile.js @@ -1,8 +1,7 @@ import { updateUsername } from "./user.js"; const username = updateUsername(); -const initNumPlansShown = 3; -let numPlansShown = initNumPlansShown; +let profileSwiper = null; async function deleteUserPlan() { const username = this.dataset.user; @@ -18,56 +17,76 @@ async function deleteUserPlan() { .catch((err) => console.error(err)); } -function renderCard(plan, idx) { - const cards = $("#cards"); - let card = $("
").addClass("card rounded mb-2").css("max-width", "350px"); - if (idx >= numPlansShown) { - card.css("display", "none"); - } - let cardBody = $("
").addClass("card-body"); - cardBody.append($("
").addClass("card-title").text(plan.destination)); - cardBody.append($("
").addClass("card-subtitle").text(plan.travel_date)); - let placeList = $("