Compare commits
17 Commits
feat/user-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55170352c3 | ||
|
|
4a57c76e3e | ||
|
|
328b7553a3 | ||
|
|
d113626796 | ||
|
|
5ccbbbd7fe | ||
|
|
f050b6de1d | ||
|
|
b0c4047024 | ||
|
|
522731953d | ||
|
|
7145bf2b1c | ||
|
|
8dddb1c754 | ||
|
|
58d9d817d3 | ||
|
|
46fa4a1323 | ||
|
|
f0964c8588 | ||
|
|
1d5069354a | ||
|
|
48257e36ff | ||
|
|
89820bb0ab | ||
|
|
17728bacc8 |
36
.github/workflows/pages.yml
vendored
Normal file
36
.github/workflows/pages.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
name: pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- ".github/workflows/pages.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/configure-pages@v5
|
||||
with:
|
||||
enablement: true
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
61
.github/workflows/release.yml
vendored
61
.github/workflows/release.yml
vendored
@ -1,12 +1,18 @@
|
||||
name: build
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to (re)release (e.g. v0.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
@ -19,12 +25,51 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Stash GoReleaser config
|
||||
run: cp .goreleaser.yml /tmp/.goreleaser.yml
|
||||
- name: Checkout release tag
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: git checkout ${{ inputs.tag }}
|
||||
- name: Verify Homebrew tap token
|
||||
env:
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$HOMEBREW_TAP_TOKEN" ]; then
|
||||
echo "::error::HOMEBREW_TAP_TOKEN is missing; cannot publish Homebrew formula"
|
||||
exit 1
|
||||
fi
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: Bearer $HOMEBREW_TAP_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
https://api.github.com/repos/steipete/homebrew-tap || true)"
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::HOMEBREW_TAP_TOKEN cannot access steipete/homebrew-tap (HTTP $code)"
|
||||
exit 1
|
||||
fi
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: build --clean
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: goplaces-${{ github.ref_name }}
|
||||
path: dist/**
|
||||
if-no-files-found: error
|
||||
args: release --clean --config /tmp/.goreleaser.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
- name: Verify Homebrew formula
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
release_tag="${{ inputs.tag }}"
|
||||
else
|
||||
release_tag="${{ github.ref_name }}"
|
||||
fi
|
||||
release_version="${release_tag#v}"
|
||||
formula="$(gh api repos/steipete/homebrew-tap/contents/Formula/goplaces.rb --jq '.content' | base64 --decode)"
|
||||
if ! grep -q "version \"${release_version}\"" <<<"$formula"; then
|
||||
echo "::error::steipete/homebrew-tap Formula/goplaces.rb was not updated to $release_version"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "releases/download/v${release_version}/" <<<"$formula"; then
|
||||
echo "::error::steipete/homebrew-tap Formula/goplaces.rb does not point at v$release_version assets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -17,31 +17,29 @@ linters:
|
||||
- prealloc
|
||||
- revive
|
||||
- unparam
|
||||
settings:
|
||||
revive:
|
||||
rules:
|
||||
- name: early-return
|
||||
- name: empty-lines
|
||||
- name: errorf
|
||||
- name: unexported-return
|
||||
exclusions:
|
||||
rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- goconst
|
||||
- dupl
|
||||
- gosec
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goimports
|
||||
|
||||
formatters-settings:
|
||||
gofumpt:
|
||||
extra-rules: true
|
||||
goimports:
|
||||
local-prefixes: github.com/steipete/goplaces
|
||||
|
||||
linters-settings:
|
||||
revive:
|
||||
rules:
|
||||
- name: early-return
|
||||
- name: empty-lines
|
||||
- name: errorf
|
||||
- name: unexported-return
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- goconst
|
||||
- dupl
|
||||
- gosec
|
||||
settings:
|
||||
gofumpt:
|
||||
extra-rules: true
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/steipete/goplaces
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@ -1,8 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.2 - Unreleased
|
||||
## 0.4.0 - 2026-05-04
|
||||
|
||||
- (TBD)
|
||||
- Add `business_status` to search, nearby, details, JSON output, and human CLI output. (#8) - thanks @doomsday-rgb
|
||||
- Add drive-only `--avoid-tolls`, `--avoid-highways`, and `--avoid-ferries` direction flags backed by Routes API `routeModifiers`. (#7) - thanks @gabob23
|
||||
|
||||
## 0.3.0 - 2026-02-14
|
||||
|
||||
- Add user rating counts (`user_rating_count`) in search/nearby/details and CLI output (`Rating: 4.5 (532)`). (#3) - thanks @aligurelli
|
||||
- Add `goplaces directions` on Routes API with walking default, units control (metric default), optional steps, and drive comparison. - thanks @joshp123
|
||||
|
||||
## 0.2.1 - 2026-01-23
|
||||
|
||||
|
||||
115
README.md
115
README.md
@ -1,31 +1,46 @@
|
||||
# 📍 goplaces — Modern Places for Go
|
||||
# goplaces
|
||||
|
||||
Modern Go client + CLI for the Google Places API (New). Fast for humans, tidy for scripts.
|
||||
Modern Go client and CLI for the Google Places API (New), plus selected Routes API workflows.
|
||||
|
||||
## Highlights
|
||||
Docs site: https://goplaces.sh
|
||||
|
||||
- Text search with filters: keyword, type, open now, min rating, price levels.
|
||||
- Autocomplete suggestions for places + queries (session tokens supported).
|
||||
- Nearby search around a location restriction.
|
||||
- Place photos in details + photo media URLs.
|
||||
- Route search along a driving path (Routes API).
|
||||
- Location bias (lat/lng/radius) and pagination tokens.
|
||||
- Place details: hours, phone, website, rating, price, types.
|
||||
- Optional reviews in details (`--reviews` / `IncludeReviews`).
|
||||
- Resolve free-form location strings to candidate places.
|
||||
- Locale hints (language + region) across search/resolve/details.
|
||||
- Typed models, validation errors, and API error surfacing.
|
||||
- CLI with color human output + `--json` (respects `NO_COLOR`).
|
||||
Use it when you want Google place data from a terminal, shell script, agent, or Go program without hand-writing Places field masks and request payloads. `goplaces` keeps the human CLI pleasant, but the same commands also emit stable JSON for automation.
|
||||
|
||||
Typical jobs:
|
||||
|
||||
- Find places by text, type, rating, price, current open state, and location bias.
|
||||
- Inspect a place: address, coordinates, phone, website, hours, photos, reviews, current open state, and business status.
|
||||
- Autocomplete partial place/query input.
|
||||
- Search nearby a lat/lng radius.
|
||||
- Resolve free-form locations to place candidates.
|
||||
- Search for places along a route.
|
||||
- Get directions, travel time, distance, steps, units, and drive route modifiers.
|
||||
|
||||
## Project Shape
|
||||
|
||||
- `cmd/goplaces`: CLI entrypoint built around the library.
|
||||
- Root package `github.com/steipete/goplaces`: stable public Go API.
|
||||
- `internal/places`: Places + Routes implementation and focused client tests.
|
||||
- `internal/cli`: command parsing, output rendering, and CLI tests.
|
||||
- Places API (New): search, nearby, details, autocomplete, photo media, resolve.
|
||||
- Routes API: route polyline sampling and directions.
|
||||
- Output: compact color text by default, JSON with `--json`.
|
||||
- Runtime config: environment variables or flags.
|
||||
|
||||
## Install / Run
|
||||
|
||||
Latest release: v0.2.1 (2026-01-23).
|
||||
Latest release: v0.4.0 (2026-05-04).
|
||||
|
||||
- Homebrew: `brew install steipete/tap/goplaces`
|
||||
- Go: `go install github.com/steipete/goplaces/cmd/goplaces@latest`
|
||||
- Source: `make goplaces`
|
||||
|
||||
## Config
|
||||
## API Setup
|
||||
|
||||
`goplaces` needs a Google API key with the right APIs enabled:
|
||||
|
||||
- Places API (New) for `search`, `nearby`, `autocomplete`, `details`, `photo`, and `resolve`.
|
||||
- Routes API for `route` and `directions`.
|
||||
|
||||
```bash
|
||||
export GOOGLE_PLACES_API_KEY="..."
|
||||
@ -35,8 +50,9 @@ Optional overrides:
|
||||
|
||||
- `GOOGLE_PLACES_BASE_URL` (testing, proxying, or mock servers)
|
||||
- `GOOGLE_ROUTES_BASE_URL` (testing Routes API or proxying)
|
||||
- `GOOGLE_DIRECTIONS_BASE_URL` (testing Routes API directions calls or proxying)
|
||||
|
||||
### Getting a Google Places API Key
|
||||
### Create a Key
|
||||
|
||||
1. **Create a Google Cloud Project**
|
||||
- Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
@ -48,7 +64,7 @@ Optional overrides:
|
||||
- Search for "Places API (New)" — make sure it says **(New)**!
|
||||
- Click "Enable"
|
||||
|
||||
3. **Enable the Routes API (for `route`)**
|
||||
3. **Enable the Routes API (for `route` and `directions`)**
|
||||
- Search for "Routes API"
|
||||
- Click "Enable"
|
||||
|
||||
@ -65,17 +81,17 @@ Optional overrides:
|
||||
|
||||
6. **(Recommended) Restrict the Key**
|
||||
- Click on the key in Credentials
|
||||
- Under "API restrictions", select "Restrict key" → "Places API (New)"
|
||||
- Under "API restrictions", select "Restrict key" → add "Places API (New)" and "Routes API"
|
||||
- Set quota limits in [Quotas](https://console.cloud.google.com/apis/api/places.googleapis.com/quotas)
|
||||
|
||||
> **Note**: The Places API has usage costs. Check [pricing](https://developers.google.com/maps/documentation/places/web-service/usage-and-billing) and set budget alerts!
|
||||
|
||||
## CLI
|
||||
## CLI Overview
|
||||
|
||||
Long flags accept `--flag value` or `--flag=value` (examples use space).
|
||||
|
||||
```text
|
||||
goplaces [--api-key=KEY] [--base-url=URL] [--routes-base-url=URL] [--timeout=10s] [--json] [--no-color] [--verbose]
|
||||
goplaces [--api-key=KEY] [--base-url=URL] [--routes-base-url=URL] [--directions-base-url=URL] [--timeout=10s] [--json] [--no-color] [--verbose]
|
||||
<command>
|
||||
|
||||
Commands:
|
||||
@ -83,11 +99,27 @@ Commands:
|
||||
nearby Search nearby places by location.
|
||||
search Search places by text query.
|
||||
route Search places along a route.
|
||||
directions Get directions between two points.
|
||||
details Fetch place details by place ID.
|
||||
photo Fetch a photo URL by photo name.
|
||||
resolve Resolve a location string to candidate places.
|
||||
```
|
||||
|
||||
Command map:
|
||||
|
||||
| Command | API | Use |
|
||||
| --- | --- | --- |
|
||||
| `search` | Places Text Search | Find places by query and filters. |
|
||||
| `nearby` | Places Nearby Search | Find places around a lat/lng radius. |
|
||||
| `autocomplete` | Places Autocomplete | Get place/query suggestions for partial input. |
|
||||
| `details` | Place Details | Fetch rich place data by place ID. |
|
||||
| `photo` | Place Photo Media | Turn a photo resource name into a media URL. |
|
||||
| `resolve` | Places Text Search | Resolve a free-form location string. |
|
||||
| `route` | Routes + Places | Sample a route and search near waypoints. |
|
||||
| `directions` | Routes | Get distance, duration, warnings, and steps. |
|
||||
|
||||
## Examples
|
||||
|
||||
Search with filters + location bias:
|
||||
|
||||
```bash
|
||||
@ -119,15 +151,31 @@ Route search:
|
||||
goplaces route "coffee" --from "Seattle, WA" --to "Portland, OR" --max-waypoints 5
|
||||
```
|
||||
|
||||
Details (with reviews):
|
||||
Directions (walking with optional driving comparison):
|
||||
|
||||
```bash
|
||||
goplaces details ChIJN1t_tDeuEmsRUsoyG83frY4 --reviews
|
||||
goplaces directions --from "Pike Place Market" --to "Space Needle"
|
||||
goplaces directions --from-place-id <fromId> --to-place-id <toId> --compare drive --steps
|
||||
```
|
||||
|
||||
Details (with photos):
|
||||
Driving route modifiers:
|
||||
|
||||
```bash
|
||||
goplaces directions --from "Paris" --to "Brest" --mode drive --avoid-tolls
|
||||
goplaces directions --from "Paris" --to "Brest" --mode drive --avoid-highways --avoid-ferries
|
||||
```
|
||||
|
||||
Units (default metric):
|
||||
|
||||
```bash
|
||||
goplaces directions --from "Pike Place Market" --to "Space Needle" --units imperial
|
||||
```
|
||||
|
||||
Details:
|
||||
|
||||
```bash
|
||||
goplaces details ChIJ-bfVTh8VkFQRDZLQnmioK9s
|
||||
goplaces details ChIJN1t_tDeuEmsRUsoyG83frY4 --reviews
|
||||
goplaces details ChIJN1t_tDeuEmsRUsoyG83frY4 --photos
|
||||
```
|
||||
|
||||
@ -149,6 +197,20 @@ JSON output:
|
||||
goplaces search "sushi" --json
|
||||
```
|
||||
|
||||
Example JSON result fields include:
|
||||
|
||||
```json
|
||||
{
|
||||
"place_id": "ChIJ-bfVTh8VkFQRDZLQnmioK9s",
|
||||
"name": "Space Needle",
|
||||
"address": "400 Broad St, Seattle, WA 98109, USA",
|
||||
"rating": 4.6,
|
||||
"user_rating_count": 58186,
|
||||
"open_now": true,
|
||||
"business_status": "OPERATIONAL"
|
||||
}
|
||||
```
|
||||
|
||||
## Library
|
||||
|
||||
```go
|
||||
@ -214,6 +276,8 @@ route, err := client.Route(ctx, goplaces.RouteRequest{
|
||||
- Reviews are returned only when `IncludeReviews`/`--reviews` is set.
|
||||
- Photos are returned only when `IncludePhotos`/`--photos` is set.
|
||||
- Route search requires the Google Routes API to be enabled.
|
||||
- `business_status` is returned for search, nearby, and details when Google includes it.
|
||||
- Direction route modifiers (`--avoid-tolls`, `--avoid-highways`, `--avoid-ferries`) require `--mode drive`.
|
||||
- Field masks are defined alongside each request (e.g. `search.go`, `details.go`, `autocomplete.go`).
|
||||
- The Places API is billed and quota-limited; keep an eye on your Cloud Console quotas.
|
||||
|
||||
@ -236,3 +300,4 @@ Optional env overrides:
|
||||
- Override the search text used in E2E: `GOOGLE_PLACES_E2E_QUERY`
|
||||
- Override language code for E2E: `GOOGLE_PLACES_E2E_LANGUAGE`
|
||||
- Override region code for E2E: `GOOGLE_PLACES_E2E_REGION`
|
||||
- Override directions endpoints/locations: `GOOGLE_DIRECTIONS_E2E_BASE_URL`, `GOOGLE_PLACES_E2E_DIRECTIONS_FROM`, `GOOGLE_PLACES_E2E_DIRECTIONS_TO`
|
||||
|
||||
91
api.go
Normal file
91
api.go
Normal file
@ -0,0 +1,91 @@
|
||||
// Package goplaces provides a Go client for the Google Places API (New).
|
||||
package goplaces
|
||||
|
||||
import "github.com/steipete/goplaces/internal/places"
|
||||
|
||||
// DefaultBaseURL is the default endpoint for the Places API (New).
|
||||
const DefaultBaseURL = places.DefaultBaseURL
|
||||
|
||||
// ErrMissingAPIKey indicates a missing API key.
|
||||
var ErrMissingAPIKey = places.ErrMissingAPIKey
|
||||
|
||||
// NewClient builds a client with sane defaults.
|
||||
func NewClient(opts Options) *Client {
|
||||
return places.NewClient(opts)
|
||||
}
|
||||
|
||||
type (
|
||||
// Client wraps access to the Google Places API.
|
||||
Client = places.Client
|
||||
// Options configures the Places client.
|
||||
Options = places.Options
|
||||
// ValidationError describes an invalid request payload.
|
||||
ValidationError = places.ValidationError
|
||||
// APIError represents an HTTP error from the Places API.
|
||||
APIError = places.APIError
|
||||
|
||||
// SearchRequest defines a text search with optional filters.
|
||||
SearchRequest = places.SearchRequest
|
||||
// Filters are optional search refinements.
|
||||
Filters = places.Filters
|
||||
// LocationBias limits search results to a circular area.
|
||||
LocationBias = places.LocationBias
|
||||
// LatLng holds geographic coordinates.
|
||||
LatLng = places.LatLng
|
||||
// SearchResponse contains a list of places and optional pagination token.
|
||||
SearchResponse = places.SearchResponse
|
||||
|
||||
// AutocompleteRequest defines input for autocomplete suggestions.
|
||||
AutocompleteRequest = places.AutocompleteRequest
|
||||
// AutocompleteResponse contains suggestions from autocomplete.
|
||||
AutocompleteResponse = places.AutocompleteResponse
|
||||
// AutocompleteSuggestion is a place or query prediction.
|
||||
AutocompleteSuggestion = places.AutocompleteSuggestion
|
||||
|
||||
// NearbySearchRequest defines a nearby search query.
|
||||
NearbySearchRequest = places.NearbySearchRequest
|
||||
// NearbySearchResponse contains nearby search results.
|
||||
NearbySearchResponse = places.NearbySearchResponse
|
||||
// PlaceSummary is a compact view of a place.
|
||||
PlaceSummary = places.PlaceSummary
|
||||
// PlaceDetails is a detailed view of a place.
|
||||
PlaceDetails = places.PlaceDetails
|
||||
// DetailsRequest fetches place details with optional locale hints.
|
||||
DetailsRequest = places.DetailsRequest
|
||||
|
||||
// Review represents a user review of a place.
|
||||
Review = places.Review
|
||||
// LocalizedText is a text value with an optional language code.
|
||||
LocalizedText = places.LocalizedText
|
||||
// AuthorAttribution describes a review author.
|
||||
AuthorAttribution = places.AuthorAttribution
|
||||
// ReviewVisitDate describes the date a reviewer visited a place.
|
||||
ReviewVisitDate = places.ReviewVisitDate
|
||||
// Photo describes photo metadata for a place.
|
||||
Photo = places.Photo
|
||||
// PhotoMediaRequest fetches a photo URL from a photo resource name.
|
||||
PhotoMediaRequest = places.PhotoMediaRequest
|
||||
// PhotoMediaResponse contains the photo URL for a photo name.
|
||||
PhotoMediaResponse = places.PhotoMediaResponse
|
||||
|
||||
// LocationResolveRequest resolves a text location into place candidates.
|
||||
LocationResolveRequest = places.LocationResolveRequest
|
||||
// LocationResolveResponse contains resolved locations.
|
||||
LocationResolveResponse = places.LocationResolveResponse
|
||||
// ResolvedLocation is a place candidate for a location string.
|
||||
ResolvedLocation = places.ResolvedLocation
|
||||
|
||||
// RouteRequest describes a query to search along a route.
|
||||
RouteRequest = places.RouteRequest
|
||||
// RouteResponse contains sampled waypoints with search results.
|
||||
RouteResponse = places.RouteResponse
|
||||
// RouteWaypoint ties a sampled route location to search results.
|
||||
RouteWaypoint = places.RouteWaypoint
|
||||
|
||||
// DirectionsRequest describes a directions query between two locations.
|
||||
DirectionsRequest = places.DirectionsRequest
|
||||
// DirectionsResponse contains a single route summary and steps.
|
||||
DirectionsResponse = places.DirectionsResponse
|
||||
// DirectionsStep is a single navigation step.
|
||||
DirectionsStep = places.DirectionsStep
|
||||
)
|
||||
10
api_test.go
Normal file
10
api_test.go
Normal file
@ -0,0 +1,10 @@
|
||||
package goplaces
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFacadeNewClient(t *testing.T) {
|
||||
client := NewClient(Options{APIKey: "key"})
|
||||
if client == nil {
|
||||
t.Fatal("expected client")
|
||||
}
|
||||
}
|
||||
678
client_test.go
678
client_test.go
@ -1,678 +0,0 @@
|
||||
package goplaces
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSearchSuccess(t *testing.T) {
|
||||
var gotRequest map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/places:searchText" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Goog-Api-Key") != "test-key" {
|
||||
t.Fatalf("missing api key header")
|
||||
}
|
||||
if r.Header.Get("X-Goog-FieldMask") != searchFieldMask {
|
||||
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &gotRequest); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"places": [
|
||||
{
|
||||
"id": "abc",
|
||||
"displayName": {"text": "Cafe"},
|
||||
"formattedAddress": "123 Street",
|
||||
"location": {"latitude": 1.23, "longitude": 4.56},
|
||||
"rating": 4.7,
|
||||
"priceLevel": "PRICE_LEVEL_MODERATE",
|
||||
"types": ["cafe"],
|
||||
"currentOpeningHours": {"openNow": true}
|
||||
}
|
||||
],
|
||||
"nextPageToken": "next"
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{
|
||||
APIKey: "test-key",
|
||||
BaseURL: server.URL + "/v1",
|
||||
Timeout: time.Second,
|
||||
})
|
||||
|
||||
open := true
|
||||
minRating := 4.0
|
||||
request := SearchRequest{
|
||||
Query: "coffee",
|
||||
Limit: 5,
|
||||
PageToken: "token",
|
||||
Language: "en",
|
||||
Region: "US",
|
||||
Filters: &Filters{
|
||||
Keyword: "best",
|
||||
Types: []string{"cafe"},
|
||||
OpenNow: &open,
|
||||
MinRating: &minRating,
|
||||
PriceLevels: []int{2},
|
||||
},
|
||||
LocationBias: &LocationBias{Lat: 40.0, Lng: -70.0, RadiusM: 500},
|
||||
}
|
||||
|
||||
response, err := client.Search(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("search error: %v", err)
|
||||
}
|
||||
if len(response.Results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(response.Results))
|
||||
}
|
||||
result := response.Results[0]
|
||||
if result.PlaceID != "abc" {
|
||||
t.Fatalf("unexpected place id: %s", result.PlaceID)
|
||||
}
|
||||
if result.Name != "Cafe" {
|
||||
t.Fatalf("unexpected name: %s", result.Name)
|
||||
}
|
||||
if result.PriceLevel == nil || *result.PriceLevel != 2 {
|
||||
t.Fatalf("unexpected price level: %#v", result.PriceLevel)
|
||||
}
|
||||
if result.OpenNow == nil || *result.OpenNow != true {
|
||||
t.Fatalf("unexpected openNow: %#v", result.OpenNow)
|
||||
}
|
||||
if response.NextPageToken != "next" {
|
||||
t.Fatalf("unexpected token: %s", response.NextPageToken)
|
||||
}
|
||||
|
||||
if gotRequest["textQuery"] != "coffee best" {
|
||||
t.Fatalf("unexpected textQuery: %#v", gotRequest["textQuery"])
|
||||
}
|
||||
if gotRequest["pageSize"].(float64) != 5 {
|
||||
t.Fatalf("unexpected pageSize: %#v", gotRequest["pageSize"])
|
||||
}
|
||||
if gotRequest["pageToken"] != "token" {
|
||||
t.Fatalf("unexpected pageToken: %#v", gotRequest["pageToken"])
|
||||
}
|
||||
if gotRequest["languageCode"] != "en" {
|
||||
t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"])
|
||||
}
|
||||
if gotRequest["regionCode"] != "US" {
|
||||
t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("bad"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL})
|
||||
_, err := client.Search(context.Background(), SearchRequest{Query: "coffee"})
|
||||
var apiErr *APIError
|
||||
if err == nil || !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected api error, got %v", err)
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected status: %d", apiErr.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchInvalidJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("not-json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL})
|
||||
_, err := client.Search(context.Background(), SearchRequest{Query: "coffee"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutocompleteSuccess(t *testing.T) {
|
||||
var gotRequest map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/places:autocomplete" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Goog-FieldMask") != autocompleteFieldMask {
|
||||
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &gotRequest); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"suggestions": [
|
||||
{
|
||||
"placePrediction": {
|
||||
"placeId": "place-1",
|
||||
"text": {"text": "Coffee Bar"},
|
||||
"structuredFormat": {
|
||||
"mainText": {"text": "Coffee"},
|
||||
"secondaryText": {"text": "Seattle"}
|
||||
},
|
||||
"types": ["cafe"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"queryPrediction": {
|
||||
"text": {"text": "coffee beans"},
|
||||
"structuredFormat": {
|
||||
"mainText": {"text": "coffee beans"},
|
||||
"secondaryText": {"text": "query"}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
|
||||
response, err := client.Autocomplete(context.Background(), AutocompleteRequest{
|
||||
Input: "cof",
|
||||
Limit: 5,
|
||||
SessionToken: "session",
|
||||
Language: "en",
|
||||
Region: "US",
|
||||
LocationBias: &LocationBias{Lat: 1.1, Lng: 2.2, RadiusM: 100},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("autocomplete error: %v", err)
|
||||
}
|
||||
if len(response.Suggestions) != 2 {
|
||||
t.Fatalf("expected 2 suggestions, got %d", len(response.Suggestions))
|
||||
}
|
||||
if response.Suggestions[0].Kind != "place" || response.Suggestions[0].PlaceID != "place-1" {
|
||||
t.Fatalf("unexpected place suggestion: %#v", response.Suggestions[0])
|
||||
}
|
||||
if response.Suggestions[1].Kind != "query" || response.Suggestions[1].Text != "coffee beans" {
|
||||
t.Fatalf("unexpected query suggestion: %#v", response.Suggestions[1])
|
||||
}
|
||||
|
||||
if gotRequest["input"] != "cof" {
|
||||
t.Fatalf("unexpected input: %#v", gotRequest["input"])
|
||||
}
|
||||
if gotRequest["sessionToken"] != "session" {
|
||||
t.Fatalf("unexpected session token: %#v", gotRequest["sessionToken"])
|
||||
}
|
||||
if gotRequest["languageCode"] != "en" {
|
||||
t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"])
|
||||
}
|
||||
if gotRequest["regionCode"] != "US" {
|
||||
t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"])
|
||||
}
|
||||
locationBias := gotRequest["locationBias"].(map[string]any)
|
||||
if locationBias["circle"] == nil {
|
||||
t.Fatalf("missing location bias circle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutocompleteLimitTrims(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{
|
||||
"suggestions": [
|
||||
{"queryPrediction": {"text": {"text": "a"}}},
|
||||
{"queryPrediction": {"text": {"text": "b"}}}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL})
|
||||
response, err := client.Autocomplete(context.Background(), AutocompleteRequest{
|
||||
Input: "cof",
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("autocomplete error: %v", err)
|
||||
}
|
||||
if len(response.Suggestions) != 1 {
|
||||
t.Fatalf("expected 1 suggestion, got %d", len(response.Suggestions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearbySearchSuccess(t *testing.T) {
|
||||
var gotRequest map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/places:searchNearby" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Goog-FieldMask") != nearbyFieldMask {
|
||||
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &gotRequest); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"places": [
|
||||
{
|
||||
"id": "abc",
|
||||
"displayName": {"text": "Cafe"},
|
||||
"formattedAddress": "123 Street",
|
||||
"location": {"latitude": 1.23, "longitude": 4.56},
|
||||
"rating": 4.7,
|
||||
"priceLevel": "PRICE_LEVEL_MODERATE",
|
||||
"types": ["cafe"],
|
||||
"currentOpeningHours": {"openNow": true}
|
||||
}
|
||||
],
|
||||
"nextPageToken": "next"
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
|
||||
response, err := client.NearbySearch(context.Background(), NearbySearchRequest{
|
||||
LocationRestriction: &LocationBias{Lat: 40.0, Lng: -70.0, RadiusM: 500},
|
||||
Limit: 5,
|
||||
IncludedTypes: []string{"cafe"},
|
||||
ExcludedTypes: []string{"bar"},
|
||||
Language: "en",
|
||||
Region: "US",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("nearby error: %v", err)
|
||||
}
|
||||
if len(response.Results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(response.Results))
|
||||
}
|
||||
if response.NextPageToken != "next" {
|
||||
t.Fatalf("unexpected token: %s", response.NextPageToken)
|
||||
}
|
||||
|
||||
if gotRequest["maxResultCount"].(float64) != 5 {
|
||||
t.Fatalf("unexpected maxResultCount: %#v", gotRequest["maxResultCount"])
|
||||
}
|
||||
if gotRequest["languageCode"] != "en" {
|
||||
t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"])
|
||||
}
|
||||
if gotRequest["regionCode"] != "US" {
|
||||
t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"])
|
||||
}
|
||||
if _, ok := gotRequest["locationRestriction"].(map[string]any); !ok {
|
||||
t.Fatalf("unexpected locationRestriction: %#v", gotRequest["locationRestriction"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhotoMediaSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/places/place-1/photos/photo-1/media" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
query := r.URL.Query()
|
||||
if query.Get("skipHttpRedirect") != "true" {
|
||||
t.Fatalf("unexpected skipHttpRedirect: %s", query.Get("skipHttpRedirect"))
|
||||
}
|
||||
if query.Get("maxWidthPx") != "800" {
|
||||
t.Fatalf("unexpected maxWidthPx: %s", query.Get("maxWidthPx"))
|
||||
}
|
||||
if query.Get("maxHeightPx") != "600" {
|
||||
t.Fatalf("unexpected maxHeightPx: %s", query.Get("maxHeightPx"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"name": "places/place-1/photos/photo-1", "photoUri": "https://example.com/photo.jpg"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
|
||||
response, err := client.PhotoMedia(context.Background(), PhotoMediaRequest{
|
||||
Name: "places/place-1/photos/photo-1",
|
||||
MaxWidthPx: 800,
|
||||
MaxHeightPx: 600,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("photo media error: %v", err)
|
||||
}
|
||||
if response.PhotoURI == "" {
|
||||
t.Fatalf("expected photo uri")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailsSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/places/place-123" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("languageCode") != "en" {
|
||||
t.Fatalf("unexpected languageCode: %s", r.URL.Query().Get("languageCode"))
|
||||
}
|
||||
if r.URL.Query().Get("regionCode") != "US" {
|
||||
t.Fatalf("unexpected regionCode: %s", r.URL.Query().Get("regionCode"))
|
||||
}
|
||||
if r.Header.Get("X-Goog-FieldMask") != detailsFieldMaskBase {
|
||||
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"id": "place-123",
|
||||
"displayName": {"text": "Park"},
|
||||
"formattedAddress": "Central",
|
||||
"location": {"latitude": 10, "longitude": 20},
|
||||
"rating": 4.2,
|
||||
"priceLevel": "PRICE_LEVEL_FREE",
|
||||
"types": ["park"],
|
||||
"regularOpeningHours": {"weekdayDescriptions": ["Mon: 9-5"]},
|
||||
"currentOpeningHours": {"openNow": false},
|
||||
"nationalPhoneNumber": "+1 555",
|
||||
"websiteUri": "https://example.com"
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
|
||||
place, err := client.DetailsWithOptions(context.Background(), DetailsRequest{
|
||||
PlaceID: "place-123",
|
||||
Language: "en",
|
||||
Region: "US",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("details error: %v", err)
|
||||
}
|
||||
if place.PlaceID != "place-123" {
|
||||
t.Fatalf("unexpected id: %s", place.PlaceID)
|
||||
}
|
||||
if place.OpenNow == nil || *place.OpenNow != false {
|
||||
t.Fatalf("unexpected openNow")
|
||||
}
|
||||
if len(place.Hours) != 1 {
|
||||
t.Fatalf("unexpected hours")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailsWithReviews(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("X-Goog-FieldMask"), "reviews") {
|
||||
t.Fatalf("expected reviews in field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"id": "place-123",
|
||||
"reviews": [
|
||||
{
|
||||
"name": "places/place-123/reviews/1",
|
||||
"rating": 4.5,
|
||||
"text": {"text": "Great coffee", "languageCode": "en"},
|
||||
"authorAttribution": {"displayName": "Alice", "uri": "https://example.com"},
|
||||
"relativePublishTimeDescription": "2 weeks ago",
|
||||
"publishTime": "2024-01-01T00:00:00Z",
|
||||
"visitDate": {"year": 2024, "month": 1, "day": 2}
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
|
||||
details, err := client.DetailsWithOptions(context.Background(), DetailsRequest{
|
||||
PlaceID: "place-123",
|
||||
IncludeReviews: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("details error: %v", err)
|
||||
}
|
||||
if len(details.Reviews) != 1 {
|
||||
t.Fatalf("expected 1 review")
|
||||
}
|
||||
review := details.Reviews[0]
|
||||
if review.Author == nil || review.Author.DisplayName != "Alice" {
|
||||
t.Fatalf("unexpected author: %#v", review.Author)
|
||||
}
|
||||
if review.Text == nil || review.Text.Text != "Great coffee" {
|
||||
t.Fatalf("unexpected text: %#v", review.Text)
|
||||
}
|
||||
if review.VisitDate == nil || review.VisitDate.Year != 2024 {
|
||||
t.Fatalf("unexpected visit date: %#v", review.VisitDate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailsWithPhotos(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("X-Goog-FieldMask"), "photos") {
|
||||
t.Fatalf("expected photos in field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"id": "place-123",
|
||||
"photos": [
|
||||
{
|
||||
"name": "places/place-123/photos/photo-1",
|
||||
"widthPx": 1200,
|
||||
"heightPx": 800,
|
||||
"authorAttributions": [{"displayName": "Alice", "uri": "https://example.com"}]
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
|
||||
details, err := client.DetailsWithOptions(context.Background(), DetailsRequest{
|
||||
PlaceID: "place-123",
|
||||
IncludePhotos: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("details error: %v", err)
|
||||
}
|
||||
if len(details.Photos) != 1 {
|
||||
t.Fatalf("expected 1 photo")
|
||||
}
|
||||
photo := details.Photos[0]
|
||||
if photo.Name == "" || photo.WidthPx != 1200 {
|
||||
t.Fatalf("unexpected photo: %#v", photo)
|
||||
}
|
||||
if len(photo.AuthorAttributions) != 1 {
|
||||
t.Fatalf("unexpected photo authors: %#v", photo.AuthorAttributions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailsFieldMaskForRequest(t *testing.T) {
|
||||
req := DetailsRequest{}
|
||||
if got := detailsFieldMaskForRequest(req); got != detailsFieldMaskBase {
|
||||
t.Fatalf("unexpected field mask: %s", got)
|
||||
}
|
||||
req.IncludeReviews = true
|
||||
got := detailsFieldMaskForRequest(req)
|
||||
if !strings.Contains(got, "reviews") {
|
||||
t.Fatalf("expected reviews in field mask: %s", got)
|
||||
}
|
||||
req = DetailsRequest{IncludePhotos: true}
|
||||
got = detailsFieldMaskForRequest(req)
|
||||
if !strings.Contains(got, "photos") {
|
||||
t.Fatalf("expected photos in field mask: %s", got)
|
||||
}
|
||||
req = DetailsRequest{IncludeReviews: true, IncludePhotos: true}
|
||||
got = detailsFieldMaskForRequest(req)
|
||||
if !strings.Contains(got, "reviews") || !strings.Contains(got, "photos") {
|
||||
t.Fatalf("expected reviews and photos in field mask: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSuccess(t *testing.T) {
|
||||
var gotRequest map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Goog-FieldMask") != resolveFieldMask {
|
||||
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &gotRequest); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"places": [
|
||||
{
|
||||
"id": "loc-1",
|
||||
"displayName": {"text": "Downtown"},
|
||||
"formattedAddress": "Main",
|
||||
"location": {"latitude": 1, "longitude": 2},
|
||||
"types": ["neighborhood"]
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL})
|
||||
response, err := client.Resolve(context.Background(), LocationResolveRequest{
|
||||
LocationText: "Downtown",
|
||||
Language: "en",
|
||||
Region: "US",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve error: %v", err)
|
||||
}
|
||||
if len(response.Results) != 1 {
|
||||
t.Fatalf("expected 1 result")
|
||||
}
|
||||
if gotRequest["languageCode"] != "en" {
|
||||
t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"])
|
||||
}
|
||||
if gotRequest["regionCode"] != "US" {
|
||||
t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAPIKey(t *testing.T) {
|
||||
client := NewClient(Options{})
|
||||
_, err := client.Search(context.Background(), SearchRequest{Query: "coffee"})
|
||||
if !errors.Is(err, ErrMissingAPIKey) {
|
||||
t.Fatalf("expected missing api key error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrors(t *testing.T) {
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: "http://example.com"})
|
||||
|
||||
_, err := client.Search(context.Background(), SearchRequest{Query: ""})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
|
||||
minRating := 9.0
|
||||
_, err = client.Search(context.Background(), SearchRequest{Query: "coffee", Filters: &Filters{MinRating: &minRating}})
|
||||
if err == nil {
|
||||
t.Fatalf("expected rating error")
|
||||
}
|
||||
|
||||
_, err = client.Search(context.Background(), SearchRequest{Query: "coffee", Limit: 42})
|
||||
if err == nil {
|
||||
t.Fatalf("expected limit error")
|
||||
}
|
||||
|
||||
_, err = client.Search(context.Background(), SearchRequest{Query: "coffee", Filters: &Filters{PriceLevels: []int{9}}})
|
||||
if err == nil {
|
||||
t.Fatalf("expected price level error")
|
||||
}
|
||||
|
||||
_, err = client.Search(context.Background(), SearchRequest{Query: "coffee", LocationBias: &LocationBias{Lat: 200, Lng: 0, RadiusM: 1}})
|
||||
if err == nil {
|
||||
t.Fatalf("expected location error")
|
||||
}
|
||||
|
||||
_, err = client.Resolve(context.Background(), LocationResolveRequest{LocationText: ""})
|
||||
if err == nil {
|
||||
t.Fatalf("expected resolve error")
|
||||
}
|
||||
|
||||
_, err = client.Resolve(context.Background(), LocationResolveRequest{LocationText: "x", Limit: 99})
|
||||
if err == nil {
|
||||
t.Fatalf("expected resolve limit error")
|
||||
}
|
||||
|
||||
_, err = client.Autocomplete(context.Background(), AutocompleteRequest{Input: ""})
|
||||
if err == nil {
|
||||
t.Fatalf("expected autocomplete input error")
|
||||
}
|
||||
|
||||
_, err = client.Autocomplete(context.Background(), AutocompleteRequest{Input: "x", Limit: 99})
|
||||
if err == nil {
|
||||
t.Fatalf("expected autocomplete limit error")
|
||||
}
|
||||
|
||||
_, err = client.NearbySearch(context.Background(), NearbySearchRequest{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected nearby location error")
|
||||
}
|
||||
|
||||
_, err = client.NearbySearch(context.Background(), NearbySearchRequest{
|
||||
LocationRestriction: &LocationBias{Lat: 1, Lng: 2, RadiusM: 3},
|
||||
Limit: 99,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected nearby limit error")
|
||||
}
|
||||
|
||||
_, err = client.PhotoMedia(context.Background(), PhotoMediaRequest{Name: ""})
|
||||
if err == nil {
|
||||
t.Fatalf("expected photo media name error")
|
||||
}
|
||||
|
||||
_, err = client.Details(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected details error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchBodyOmitsEmptyPriceLevels(t *testing.T) {
|
||||
request := SearchRequest{Query: "coffee", Filters: &Filters{PriceLevels: []int{9}}}
|
||||
body := buildSearchBody(request)
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if bytes.Contains(payload, []byte("priceLevels")) {
|
||||
t.Fatalf("unexpected priceLevels in payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappingHelpers(t *testing.T) {
|
||||
if mapLatLng(nil) != nil {
|
||||
t.Fatalf("expected nil location")
|
||||
}
|
||||
if displayName(nil) != "" {
|
||||
t.Fatalf("expected empty display name")
|
||||
}
|
||||
if openNow(nil) != nil {
|
||||
t.Fatalf("expected nil open now")
|
||||
}
|
||||
if weekdayDescriptions(nil) != nil {
|
||||
t.Fatalf("expected nil hours")
|
||||
}
|
||||
if mapPriceLevel("UNKNOWN") != nil {
|
||||
t.Fatalf("expected nil price level")
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,6 @@ func main() {
|
||||
exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
return cli.Run(args, stdout, stderr)
|
||||
}
|
||||
|
||||
1
docs/.nojekyll
Normal file
1
docs/.nojekyll
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
docs/CNAME
Normal file
1
docs/CNAME
Normal file
@ -0,0 +1 @@
|
||||
goplaces.sh
|
||||
760
docs/assets/site.css
Normal file
760
docs/assets/site.css
Normal file
@ -0,0 +1,760 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--ink: #eef6ee;
|
||||
--muted: #a6b8ad;
|
||||
--faint: #6f8175;
|
||||
--bg: #0b100e;
|
||||
--bg-2: #0e1512;
|
||||
--panel: #131b17;
|
||||
--panel-2: #182721;
|
||||
--line: #233129;
|
||||
--line-2: #304338;
|
||||
--mint: #8df0b0;
|
||||
--mint-deep: #4fd189;
|
||||
--amber: #ffca6e;
|
||||
--blue: #89c7ff;
|
||||
--rose: #ff8f9f;
|
||||
--violet: #c8a8ff;
|
||||
--shadow-sm: 0 6px 18px rgba(0, 0, 0, .35);
|
||||
--shadow-lg: 0 28px 80px rgba(0, 0, 0, .45);
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font: 16px/1.6 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before,
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
body::before {
|
||||
background:
|
||||
radial-gradient(1100px 700px at 12% -8%, rgba(141, 240, 176, .14), transparent 60%),
|
||||
radial-gradient(900px 600px at 92% -2%, rgba(255, 202, 110, .10), transparent 60%),
|
||||
radial-gradient(800px 500px at 60% 110%, rgba(137, 199, 255, .08), transparent 60%);
|
||||
animation: drift 22s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
body::after {
|
||||
background-image:
|
||||
linear-gradient(rgba(141, 240, 176, .04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(141, 240, 176, .04) 1px, transparent 1px);
|
||||
background-size: 44px 44px, 44px 44px;
|
||||
mask-image: radial-gradient(ellipse at center, black 50%, transparent 95%);
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
100% { transform: translate3d(-3%, -2%, 0); }
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
::selection { background: rgba(141, 240, 176, .35); color: #08100b; }
|
||||
|
||||
.shell {
|
||||
width: calc(100% - 40px);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ───── Top bar ───── */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
border-bottom: 1px solid rgba(141, 240, 176, .12);
|
||||
background: rgba(11, 16, 14, .72);
|
||||
backdrop-filter: saturate(160%) blur(18px);
|
||||
-webkit-backdrop-filter: saturate(160%) blur(18px);
|
||||
}
|
||||
|
||||
.nav {
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -.01em;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.brand:hover .mark { transform: rotate(45deg) scale(1.05); }
|
||||
|
||||
.mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid rgba(141, 240, 176, .55);
|
||||
background:
|
||||
radial-gradient(circle at 30% 30%, rgba(141, 240, 176, .55), rgba(141, 240, 176, .15) 60%, transparent 75%),
|
||||
linear-gradient(135deg, rgba(141, 240, 176, .25), rgba(255, 202, 110, .15));
|
||||
transform: rotate(45deg);
|
||||
box-shadow:
|
||||
inset 0 0 0 4px rgba(11, 16, 14, .8),
|
||||
0 0 0 1px rgba(141, 240, 176, .3),
|
||||
0 0 24px rgba(141, 240, 176, .25);
|
||||
transition: transform .35s cubic-bezier(.2, .8, .2, 1);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.links a, .button, .copy, .tab {
|
||||
border: 1px solid rgba(238, 246, 238, .12);
|
||||
border-radius: 999px;
|
||||
padding: 8px 14px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, .025);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color .2s, color .2s, background .2s, transform .2s;
|
||||
}
|
||||
|
||||
.links a:hover, .button:hover, .copy:hover, .tab:hover {
|
||||
border-color: rgba(141, 240, 176, .55);
|
||||
color: var(--ink);
|
||||
background: rgba(141, 240, 176, .06);
|
||||
}
|
||||
|
||||
/* ───── Hero ───── */
|
||||
.hero {
|
||||
min-height: calc(100vh - 64px);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(360px, .95fr);
|
||||
align-items: center;
|
||||
gap: 56px;
|
||||
padding: 72px 0 64px;
|
||||
}
|
||||
|
||||
.hero > * { min-width: 0; }
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--amber);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.eyebrow::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--amber);
|
||||
box-shadow: 0 0 12px rgba(255, 202, 110, .9);
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
line-height: 1.02;
|
||||
letter-spacing: -.02em;
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
}
|
||||
h1 {
|
||||
max-width: 820px;
|
||||
font-size: clamp(56px, 9vw, 124px);
|
||||
margin-top: 18px;
|
||||
background: linear-gradient(180deg, #f4faf5 0%, #b9d5c0 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
h2 { font-size: clamp(32px, 4.4vw, 56px); margin-top: 6px; }
|
||||
h3 { font-size: 20px; }
|
||||
|
||||
p { color: var(--muted); margin: 14px 0 0; }
|
||||
|
||||
.lede {
|
||||
max-width: 700px;
|
||||
font-size: 20px;
|
||||
color: #d4e2d9;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 18px;
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: var(--mint);
|
||||
color: #08100b;
|
||||
border-color: var(--mint);
|
||||
box-shadow: 0 0 0 1px rgba(141, 240, 176, .25), 0 14px 40px rgba(141, 240, 176, .25);
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
background: #a4f5c0;
|
||||
border-color: #a4f5c0;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button .arrow { display: inline-block; transition: transform .2s; }
|
||||
.button:hover .arrow { transform: translateX(3px); }
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 28px;
|
||||
margin-top: 42px;
|
||||
padding-top: 26px;
|
||||
border-top: 1px solid rgba(238, 246, 238, .08);
|
||||
}
|
||||
|
||||
.stat .num {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -.02em;
|
||||
color: var(--ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat .label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ───── Window / terminal ───── */
|
||||
.window {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(141, 240, 176, .18);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #0f1813 0%, #0a110d 100%);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.window-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(238, 246, 238, .08);
|
||||
background: rgba(255, 255, 255, .02);
|
||||
}
|
||||
|
||||
.window-bar .dots {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.window-bar .dots i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, .12);
|
||||
}
|
||||
|
||||
.window-bar .dots i:nth-child(1) { background: #ff5f56; }
|
||||
.window-bar .dots i:nth-child(2) { background: #ffbd2e; }
|
||||
.window-bar .dots i:nth-child(3) { background: #27c93f; }
|
||||
|
||||
.window-bar .title {
|
||||
margin-left: 8px;
|
||||
color: var(--faint);
|
||||
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.window-bar .right {
|
||||
margin-left: auto;
|
||||
color: var(--faint);
|
||||
font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
|
||||
.window pre {
|
||||
margin: 0;
|
||||
padding: 18px 18px 22px;
|
||||
overflow: auto;
|
||||
color: #dff6e5;
|
||||
font: 13.5px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* token classes for syntax highlighting */
|
||||
.tk-prompt { color: var(--mint); user-select: none; }
|
||||
.tk-cmd { color: #ffffff; }
|
||||
.tk-flag { color: var(--amber); }
|
||||
.tk-str { color: #ffd9a6; }
|
||||
.tk-num { color: var(--blue); }
|
||||
.tk-c { color: var(--faint); font-style: italic; }
|
||||
.tk-ok { color: var(--mint); }
|
||||
.tk-warn { color: var(--amber); }
|
||||
.tk-key { color: var(--blue); }
|
||||
.tk-fn { color: var(--mint); }
|
||||
.tk-type { color: var(--violet); }
|
||||
.tk-pun { color: #6f8175; }
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 1.05em;
|
||||
margin-left: 2px;
|
||||
background: var(--mint);
|
||||
vertical-align: -2px;
|
||||
animation: blink 1.05s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* ───── Quick install bar ───── */
|
||||
.qi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
padding: 10px 12px 10px 16px;
|
||||
border: 1px solid rgba(141, 240, 176, .25);
|
||||
border-radius: 999px;
|
||||
background: rgba(141, 240, 176, .06);
|
||||
font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
min-width: 0;
|
||||
box-shadow: 0 0 0 1px rgba(141, 240, 176, .1);
|
||||
}
|
||||
|
||||
.qi .dollar { color: var(--mint); }
|
||||
.qi .text { color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
|
||||
.qi .copy { padding: 6px 12px; font-size: 12.5px; }
|
||||
|
||||
/* ───── Section headers ───── */
|
||||
.section {
|
||||
padding: 96px 0;
|
||||
border-top: 1px solid rgba(238, 246, 238, .08);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
||||
align-items: end;
|
||||
gap: 36px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.section-head p { max-width: 520px; }
|
||||
|
||||
/* ───── Command grid ───── */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
border: 1px solid rgba(238, 246, 238, .1);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, .015));
|
||||
overflow: hidden;
|
||||
transition: transform .25s cubic-bezier(.2, .8, .2, 1), border-color .25s, background .25s;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
background: radial-gradient(280px 180px at var(--mx, 50%) var(--my, 0%), rgba(141, 240, 176, .18), transparent 60%);
|
||||
opacity: 0;
|
||||
transition: opacity .25s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(141, 240, 176, .4);
|
||||
}
|
||||
|
||||
.card:hover::before { opacity: 1; }
|
||||
|
||||
.card-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid rgba(141, 240, 176, .3);
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 14px;
|
||||
background: linear-gradient(180deg, rgba(141, 240, 176, .12), rgba(141, 240, 176, .03));
|
||||
color: var(--mint);
|
||||
}
|
||||
|
||||
.card-icon svg { width: 18px; height: 18px; }
|
||||
|
||||
.card-foot {
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-foot .arrow {
|
||||
color: var(--mint);
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition: opacity .2s, transform .2s;
|
||||
}
|
||||
|
||||
.card:hover .card-foot .arrow { opacity: 1; transform: translateX(0); }
|
||||
|
||||
.card code, .pill {
|
||||
color: var(--mint);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 21px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.card p { font-size: 14.5px; line-height: 1.55; }
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(141, 240, 176, .25);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(141, 240, 176, .06);
|
||||
font-size: 11.5px;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
|
||||
/* category-tinted pills (still readable on dark bg) */
|
||||
.pill.amber { color: var(--amber); border-color: rgba(255, 202, 110, .3); background: rgba(255, 202, 110, .08); }
|
||||
.pill.blue { color: var(--blue); border-color: rgba(137, 199, 255, .3); background: rgba(137, 199, 255, .08); }
|
||||
.pill.rose { color: var(--rose); border-color: rgba(255, 143, 159, .3); background: rgba(255, 143, 159, .08); }
|
||||
|
||||
/* ───── Setup tabs ───── */
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
border: 1px solid rgba(238, 246, 238, .1);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .02);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab { border: 1px solid transparent; background: transparent; padding: 7px 14px; }
|
||||
.tab[aria-selected="true"] {
|
||||
background: rgba(141, 240, 176, .12);
|
||||
color: var(--ink);
|
||||
border-color: rgba(141, 240, 176, .3);
|
||||
}
|
||||
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.is-active { display: block; }
|
||||
|
||||
/* ───── Split / panels ───── */
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: .9fr 1.1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.split > *,
|
||||
.command-layout > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid rgba(238, 246, 238, .1);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, .025);
|
||||
padding: 22px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.steps {
|
||||
list-style: none;
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.steps li {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.steps li b {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: rgba(141, 240, 176, .14);
|
||||
border: 1px solid rgba(141, 240, 176, .3);
|
||||
color: var(--mint);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.steps li p { margin: 0; color: var(--ink); font-size: 14.5px; }
|
||||
.steps li small { color: var(--muted); display: block; margin-top: 2px; font-size: 13px; }
|
||||
|
||||
.codebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.codebar .pill { padding: 4px 10px; }
|
||||
|
||||
.copy {
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
color: #dff6e5;
|
||||
font: 13.5px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.panel pre { padding: 14px 16px; background: rgba(0, 0, 0, .25); border-radius: 8px; }
|
||||
|
||||
/* ───── Architecture map ───── */
|
||||
.map {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.map .card { min-height: 150px; }
|
||||
|
||||
.map .card .path {
|
||||
display: inline-block;
|
||||
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: var(--amber);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ───── Command page (sub-pages) ───── */
|
||||
.command-hero {
|
||||
padding: 80px 0 28px;
|
||||
}
|
||||
|
||||
.command-hero h1 {
|
||||
font-size: clamp(46px, 7vw, 88px);
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.command-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 36px;
|
||||
align-items: start;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.side {
|
||||
position: sticky;
|
||||
top: 92px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(238, 246, 238, .08);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, .02);
|
||||
}
|
||||
|
||||
.side a {
|
||||
color: var(--muted);
|
||||
border-left: 2px solid transparent;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13.5px;
|
||||
transition: color .15s, background .15s, border-color .15s;
|
||||
}
|
||||
|
||||
.side a:hover { color: var(--ink); border-left-color: var(--mint); background: rgba(141, 240, 176, .05); }
|
||||
.side a[aria-current="page"] {
|
||||
color: var(--mint);
|
||||
border-left-color: var(--mint);
|
||||
background: rgba(141, 240, 176, .08);
|
||||
}
|
||||
|
||||
.command-layout .section {
|
||||
padding: 30px 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.command-layout .section + .section { border-top: 1px solid rgba(238, 246, 238, .08); }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border-bottom: 1px solid rgba(238, 246, 238, .08);
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
th { color: var(--ink); width: 40%; }
|
||||
td { color: var(--muted); }
|
||||
|
||||
code {
|
||||
color: #e9f7ec;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .92em;
|
||||
}
|
||||
|
||||
/* ───── Footer ───── */
|
||||
.footer {
|
||||
padding: 44px 0 56px;
|
||||
color: var(--faint);
|
||||
border-top: 1px solid rgba(238, 246, 238, .08);
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, .25));
|
||||
}
|
||||
|
||||
.foot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr repeat(3, 1fr);
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.foot-grid h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.foot-grid a { display: block; color: var(--muted); padding: 4px 0; font-size: 14px; }
|
||||
.foot-grid a:hover { color: var(--ink); }
|
||||
|
||||
.foot-bar {
|
||||
margin-top: 36px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid rgba(238, 246, 238, .06);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ───── Responsive ───── */
|
||||
@media (max-width: 1040px) {
|
||||
.grid, .map { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.foot-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.hero, .split, .command-layout, .section-head { grid-template-columns: 1fr; }
|
||||
.hero { padding-top: 48px; gap: 36px; }
|
||||
.section { padding: 64px 0; }
|
||||
.side { position: static; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.shell { width: calc(100% - 28px); max-width: 1200px; }
|
||||
.nav { align-items: flex-start; flex-wrap: wrap; padding: 10px 0; gap: 10px; }
|
||||
.links { flex: 1 1 100%; width: 100%; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||||
.links a { padding: 6px 10px; font-size: 13px; }
|
||||
h1 { font-size: 52px; }
|
||||
.lede { font-size: 17.5px; }
|
||||
.grid, .map { grid-template-columns: 1fr; }
|
||||
.actions { display: grid; grid-template-columns: 1fr; }
|
||||
.button { text-align: center; }
|
||||
.stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.stat .num { font-size: 24px; }
|
||||
.qi { max-width: 100%; }
|
||||
.qi .text { font-size: 12.5px; }
|
||||
.tabs { width: 100%; overflow-x: auto; border-radius: var(--radius); }
|
||||
.tab { white-space: nowrap; }
|
||||
.side { grid-template-columns: 1fr; }
|
||||
.window pre, .panel pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body::before { animation: none; }
|
||||
.cursor { animation: none; }
|
||||
.card, .button, .links a { transition: none; }
|
||||
}
|
||||
50
docs/assets/site.js
Normal file
50
docs/assets/site.js
Normal file
@ -0,0 +1,50 @@
|
||||
// Copy buttons
|
||||
document.querySelectorAll("[data-copy]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const text = button.dataset.copyText || document.querySelector(button.dataset.copy)?.innerText.trim();
|
||||
if (!text) return;
|
||||
const original = button.textContent;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
button.textContent = "Copied";
|
||||
} catch {
|
||||
button.textContent = "Select";
|
||||
}
|
||||
window.setTimeout(() => { button.textContent = original; }, 1300);
|
||||
});
|
||||
});
|
||||
|
||||
// Tabs
|
||||
document.querySelectorAll("[data-tabs]").forEach((group) => {
|
||||
const tabs = group.querySelectorAll("[role='tab']");
|
||||
const panels = group.querySelectorAll("[role='tabpanel']");
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
tabs.forEach((t) => t.setAttribute("aria-selected", t === tab ? "true" : "false"));
|
||||
const id = tab.getAttribute("aria-controls");
|
||||
panels.forEach((p) => p.classList.toggle("is-active", p.id === id));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Card spotlight follows the cursor
|
||||
document.querySelectorAll(".card").forEach((card) => {
|
||||
card.addEventListener("pointermove", (e) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
card.style.setProperty("--mx", `${e.clientX - rect.left}px`);
|
||||
card.style.setProperty("--my", `${e.clientY - rect.top}px`);
|
||||
});
|
||||
});
|
||||
|
||||
// Smooth-scroll same-page anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach((a) => {
|
||||
a.addEventListener("click", (e) => {
|
||||
const id = a.getAttribute("href").slice(1);
|
||||
if (!id) return;
|
||||
const target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
history.replaceState(null, "", `#${id}`);
|
||||
});
|
||||
});
|
||||
42
docs/commands/autocomplete.html
Normal file
42
docs/commands/autocomplete.html
Normal file
@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>goplaces autocomplete</title>
|
||||
<meta name="description" content="Autocomplete place and query input with goplaces.">
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></nav></header>
|
||||
<main class="shell">
|
||||
<section class="command-hero"><span class="pill">Places Autocomplete</span><h1>autocomplete</h1><p class="lede">Return place and query suggestions from partial input, with session tokens and optional location bias for billing-friendly flows.</p></section>
|
||||
<div class="command-layout">
|
||||
<aside class="side"><a href="search.html">search</a><a href="nearby.html">nearby</a><a href="autocomplete.html" aria-current="page">autocomplete</a><a href="details.html">details</a><a href="photo.html">photo</a><a href="resolve.html">resolve</a><a href="route.html">route</a><a href="directions.html">directions</a></aside>
|
||||
<article>
|
||||
<section class="section">
|
||||
<h2>Usage</h2>
|
||||
<p><code>goplaces autocomplete <input> [flags]</code></p>
|
||||
<div class="panel"><div class="codebar"><span class="pill">shell</span><button class="copy" data-copy="#example">Copy</button></div><pre id="example">goplaces autocomplete "cof" --session-token "goplaces-demo" \
|
||||
--limit 5 --language en --region US</pre></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Flags</h2>
|
||||
<table><tbody>
|
||||
<tr><th><code>--limit</code></th><td>Max suggestions, 1-20. Default: 5.</td></tr>
|
||||
<tr><th><code>--session-token</code></th><td>Session token for billing consistency across autocomplete and details.</td></tr>
|
||||
<tr><th><code>--language</code>, <code>--region</code></th><td>BCP-47 language and CLDR region hints.</td></tr>
|
||||
<tr><th><code>--lat</code>, <code>--lng</code>, <code>--radius-m</code></th><td>Optional location bias. Provide all three together.</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Location Bias</h2>
|
||||
<div class="panel"><pre>goplaces autocomplete "pizza" --lat 40.7411 --lng -73.9897 --radius-m 1500</pre></div>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="footer"><div class="shell foot-bar"><a href="../index.html">← Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></footer>
|
||||
<script src="../assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
docs/commands/details.html
Normal file
40
docs/commands/details.html
Normal file
@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>goplaces details</title>
|
||||
<meta name="description" content="Fetch place details by place ID with goplaces.">
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></nav></header>
|
||||
<main class="shell">
|
||||
<section class="command-hero"><span class="pill">Place Details</span><h1>details</h1><p class="lede">Fetch rich place data by place ID, including address, coordinates, contact links, opening hours, status, optional reviews, and optional photos.</p></section>
|
||||
<div class="command-layout">
|
||||
<aside class="side"><a href="search.html">search</a><a href="nearby.html">nearby</a><a href="autocomplete.html">autocomplete</a><a href="details.html" aria-current="page">details</a><a href="photo.html">photo</a><a href="resolve.html">resolve</a><a href="route.html">route</a><a href="directions.html">directions</a></aside>
|
||||
<article>
|
||||
<section class="section">
|
||||
<h2>Usage</h2>
|
||||
<p><code>goplaces details <place_id> [flags]</code></p>
|
||||
<div class="panel"><div class="codebar"><span class="pill">shell</span><button class="copy" data-copy="#example">Copy</button></div><pre id="example">goplaces details ChIJN1t_tDeuEmsRUsoyG83frY4 --reviews --photos</pre></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Flags</h2>
|
||||
<table><tbody>
|
||||
<tr><th><code>--language</code>, <code>--region</code></th><td>BCP-47 language and CLDR region hints.</td></tr>
|
||||
<tr><th><code>--reviews</code></th><td>Include reviews in the response.</td></tr>
|
||||
<tr><th><code>--photos</code></th><td>Include photo metadata in the response.</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Pair With</h2>
|
||||
<p>Use <a href="photo.html"><code>photo</code></a> with a returned photo resource name to get a media URL.</p>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="footer"><div class="shell foot-bar"><a href="../index.html">← Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></footer>
|
||||
<script src="../assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
49
docs/commands/directions.html
Normal file
49
docs/commands/directions.html
Normal file
@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>goplaces directions</title>
|
||||
<meta name="description" content="Get route directions, distance, and duration with goplaces.">
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></nav></header>
|
||||
<main class="shell">
|
||||
<section class="command-hero"><span class="pill">Routes</span><h1>directions</h1><p class="lede">Get route distance, duration, warnings, optional steps, unit formatting, mode comparison, and driving route modifiers.</p></section>
|
||||
<div class="command-layout">
|
||||
<aside class="side"><a href="search.html">search</a><a href="nearby.html">nearby</a><a href="autocomplete.html">autocomplete</a><a href="details.html">details</a><a href="photo.html">photo</a><a href="resolve.html">resolve</a><a href="route.html">route</a><a href="directions.html" aria-current="page">directions</a></aside>
|
||||
<article>
|
||||
<section class="section">
|
||||
<h2>Usage</h2>
|
||||
<p><code>goplaces directions [flags]</code></p>
|
||||
<div class="panel"><div class="codebar"><span class="pill">shell</span><button class="copy" data-copy="#example">Copy</button></div><pre id="example">goplaces directions --from "Pike Place Market" --to "Space Needle"
|
||||
goplaces directions --from-place-id FROM_ID --to-place-id TO_ID --compare drive --steps</pre></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Inputs</h2>
|
||||
<table><tbody>
|
||||
<tr><th><code>--from</code>, <code>--to</code></th><td>Origin and destination address or place name.</td></tr>
|
||||
<tr><th><code>--from-place-id</code>, <code>--to-place-id</code></th><td>Origin and destination place IDs.</td></tr>
|
||||
<tr><th><code>--from-lat</code>, <code>--from-lng</code></th><td>Origin coordinates. Provide both.</td></tr>
|
||||
<tr><th><code>--to-lat</code>, <code>--to-lng</code></th><td>Destination coordinates. Provide both.</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Options</h2>
|
||||
<table><tbody>
|
||||
<tr><th><code>--mode</code></th><td>walk, drive, bicycle, transit. Default: walk.</td></tr>
|
||||
<tr><th><code>--compare</code></th><td>Add a second mode comparison.</td></tr>
|
||||
<tr><th><code>--steps</code></th><td>Include step-by-step instructions.</td></tr>
|
||||
<tr><th><code>--units</code></th><td>metric or imperial. Default: metric.</td></tr>
|
||||
<tr><th><code>--avoid-tolls</code>, <code>--avoid-highways</code>, <code>--avoid-ferries</code></th><td>Driving route modifiers.</td></tr>
|
||||
<tr><th><code>--language</code>, <code>--region</code></th><td>BCP-47 language and CLDR region hints.</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="footer"><div class="shell foot-bar"><a href="../index.html">← Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></footer>
|
||||
<script src="../assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
docs/commands/nearby.html
Normal file
43
docs/commands/nearby.html
Normal file
@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>goplaces nearby</title>
|
||||
<meta name="description" content="Search nearby places by location with goplaces.">
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></nav></header>
|
||||
<main class="shell">
|
||||
<section class="command-hero"><span class="pill">Nearby Search</span><h1>nearby</h1><p class="lede">Find places inside a required latitude, longitude, and radius restriction. Good for local discovery and map-near-me workflows.</p></section>
|
||||
<div class="command-layout">
|
||||
<aside class="side"><a href="search.html">search</a><a href="nearby.html" aria-current="page">nearby</a><a href="autocomplete.html">autocomplete</a><a href="details.html">details</a><a href="photo.html">photo</a><a href="resolve.html">resolve</a><a href="route.html">route</a><a href="directions.html">directions</a></aside>
|
||||
<article>
|
||||
<section class="section">
|
||||
<h2>Usage</h2>
|
||||
<p><code>goplaces nearby [flags]</code></p>
|
||||
<div class="panel"><div class="codebar"><span class="pill">shell</span><button class="copy" data-copy="#example">Copy</button></div><pre id="example">goplaces nearby --lat 47.6062 --lng -122.3321 --radius-m 1500 \
|
||||
--type cafe --type bakery --limit 5</pre></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Flags</h2>
|
||||
<table><tbody>
|
||||
<tr><th><code>--lat</code>, <code>--lng</code>, <code>--radius-m</code></th><td>Required location restriction.</td></tr>
|
||||
<tr><th><code>--limit</code></th><td>Max results, 1-20. Default: 10.</td></tr>
|
||||
<tr><th><code>--type</code></th><td>Included place types. Repeatable.</td></tr>
|
||||
<tr><th><code>--exclude-type</code></th><td>Excluded place types. Repeatable.</td></tr>
|
||||
<tr><th><code>--language</code>, <code>--region</code></th><td>BCP-47 language and CLDR region hints.</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Notes</h2>
|
||||
<p>Use <code>--json</code> for stable result arrays. Results include <code>business_status</code> when Google returns it.</p>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="footer"><div class="shell foot-bar"><a href="../index.html">← Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></footer>
|
||||
<script src="../assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
docs/commands/photo.html
Normal file
39
docs/commands/photo.html
Normal file
@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>goplaces photo</title>
|
||||
<meta name="description" content="Fetch a Places photo media URL with goplaces.">
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></nav></header>
|
||||
<main class="shell">
|
||||
<section class="command-hero"><span class="pill">Photo Media</span><h1>photo</h1><p class="lede">Convert a Places photo resource name into a media URL, optionally constrained by maximum width or height.</p></section>
|
||||
<div class="command-layout">
|
||||
<aside class="side"><a href="search.html">search</a><a href="nearby.html">nearby</a><a href="autocomplete.html">autocomplete</a><a href="details.html">details</a><a href="photo.html" aria-current="page">photo</a><a href="resolve.html">resolve</a><a href="route.html">route</a><a href="directions.html">directions</a></aside>
|
||||
<article>
|
||||
<section class="section">
|
||||
<h2>Usage</h2>
|
||||
<p><code>goplaces photo <photo_name> [flags]</code></p>
|
||||
<div class="panel"><div class="codebar"><span class="pill">shell</span><button class="copy" data-copy="#example">Copy</button></div><pre id="example">goplaces photo "places/PLACE_ID/photos/PHOTO_ID" --max-width 1200</pre></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Flags</h2>
|
||||
<table><tbody>
|
||||
<tr><th><code>--max-width</code></th><td>Maximum returned image width in pixels.</td></tr>
|
||||
<tr><th><code>--max-height</code></th><td>Maximum returned image height in pixels.</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Source Names</h2>
|
||||
<p>Fetch metadata first with <a href="details.html"><code>details --photos</code></a>, then pass one returned <code>places/.../photos/...</code> resource name here.</p>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="footer"><div class="shell foot-bar"><a href="../index.html">← Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></footer>
|
||||
<script src="../assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
docs/commands/resolve.html
Normal file
39
docs/commands/resolve.html
Normal file
@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>goplaces resolve</title>
|
||||
<meta name="description" content="Resolve free-form location text to candidate places with goplaces.">
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></nav></header>
|
||||
<main class="shell">
|
||||
<section class="command-hero"><span class="pill">Resolve</span><h1>resolve</h1><p class="lede">Turn free-form location text into candidate places. Useful before details, directions, or scripts that need place IDs.</p></section>
|
||||
<div class="command-layout">
|
||||
<aside class="side"><a href="search.html">search</a><a href="nearby.html">nearby</a><a href="autocomplete.html">autocomplete</a><a href="details.html">details</a><a href="photo.html">photo</a><a href="resolve.html" aria-current="page">resolve</a><a href="route.html">route</a><a href="directions.html">directions</a></aside>
|
||||
<article>
|
||||
<section class="section">
|
||||
<h2>Usage</h2>
|
||||
<p><code>goplaces resolve <location> [flags]</code></p>
|
||||
<div class="panel"><div class="codebar"><span class="pill">shell</span><button class="copy" data-copy="#example">Copy</button></div><pre id="example">goplaces resolve "Riverside Park, New York" --limit 5 --region US</pre></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Flags</h2>
|
||||
<table><tbody>
|
||||
<tr><th><code>--limit</code></th><td>Max candidates, 1-10. Default: 5.</td></tr>
|
||||
<tr><th><code>--language</code>, <code>--region</code></th><td>BCP-47 language and CLDR region hints.</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Automation</h2>
|
||||
<p>Use <code>--json</code> when another tool needs to select a candidate place ID.</p>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="footer"><div class="shell foot-bar"><a href="../index.html">← Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></footer>
|
||||
<script src="../assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
docs/commands/route.html
Normal file
43
docs/commands/route.html
Normal file
@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>goplaces route</title>
|
||||
<meta name="description" content="Search for places along a route with goplaces.">
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></nav></header>
|
||||
<main class="shell">
|
||||
<section class="command-hero"><span class="pill">Routes + Places</span><h1>route</h1><p class="lede">Compute a route, sample waypoints along its polyline, then search for places near each waypoint.</p></section>
|
||||
<div class="command-layout">
|
||||
<aside class="side"><a href="search.html">search</a><a href="nearby.html">nearby</a><a href="autocomplete.html">autocomplete</a><a href="details.html">details</a><a href="photo.html">photo</a><a href="resolve.html">resolve</a><a href="route.html" aria-current="page">route</a><a href="directions.html">directions</a></aside>
|
||||
<article>
|
||||
<section class="section">
|
||||
<h2>Usage</h2>
|
||||
<p><code>goplaces route <query> [flags]</code></p>
|
||||
<div class="panel"><div class="codebar"><span class="pill">shell</span><button class="copy" data-copy="#example">Copy</button></div><pre id="example">goplaces route "coffee" --from "Seattle, WA" --to "Portland, OR" --max-waypoints 5</pre></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Flags</h2>
|
||||
<table><tbody>
|
||||
<tr><th><code>--from</code>, <code>--to</code></th><td>Origin and destination address or place name.</td></tr>
|
||||
<tr><th><code>--mode</code></th><td>Travel mode: DRIVE, WALK, BICYCLE, TWO_WHEELER, TRANSIT. Default: DRIVE.</td></tr>
|
||||
<tr><th><code>--radius-m</code></th><td>Search radius per sampled waypoint. Default: 1000.</td></tr>
|
||||
<tr><th><code>--max-waypoints</code></th><td>Max sampled waypoints along the route. Default: 5.</td></tr>
|
||||
<tr><th><code>--limit</code></th><td>Max results per waypoint, 1-20. Default: 5.</td></tr>
|
||||
<tr><th><code>--language</code>, <code>--region</code></th><td>BCP-47 language and CLDR region hints.</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Requirements</h2>
|
||||
<p>Requires both Places API (New) and Routes API enabled on the key.</p>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="footer"><div class="shell foot-bar"><a href="../index.html">← Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></footer>
|
||||
<script src="../assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
49
docs/commands/search.html
Normal file
49
docs/commands/search.html
Normal file
@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>goplaces search</title>
|
||||
<meta name="description" content="Search places by text query with goplaces.">
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></nav></header>
|
||||
<main class="shell">
|
||||
<section class="command-hero"><span class="pill">Places Text Search</span><h1>search</h1><p class="lede">Find places by text query, then narrow results by type, rating, price, current open state, pagination, and location bias.</p></section>
|
||||
<div class="command-layout">
|
||||
<aside class="side"><a href="search.html" aria-current="page">search</a><a href="nearby.html">nearby</a><a href="autocomplete.html">autocomplete</a><a href="details.html">details</a><a href="photo.html">photo</a><a href="resolve.html">resolve</a><a href="route.html">route</a><a href="directions.html">directions</a></aside>
|
||||
<article>
|
||||
<section class="section">
|
||||
<h2>Usage</h2>
|
||||
<p><code>goplaces search <query> [flags]</code></p>
|
||||
<div class="panel"><div class="codebar"><span class="pill">shell</span><button class="copy" data-copy="#example">Copy</button></div><pre id="example">goplaces search "coffee" --min-rating 4 --open-now --limit 5 \
|
||||
--lat 40.8065 --lng -73.9719 --radius-m 3000 --language en --region US</pre></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Flags</h2>
|
||||
<table><tbody>
|
||||
<tr><th><code>--limit</code></th><td>Max results, 1-20. Default: 10.</td></tr>
|
||||
<tr><th><code>--page-token</code></th><td>Continue a paginated Places response.</td></tr>
|
||||
<tr><th><code>--language</code>, <code>--region</code></th><td>BCP-47 language and CLDR region hints.</td></tr>
|
||||
<tr><th><code>--keyword</code></th><td>Append a keyword to the text query.</td></tr>
|
||||
<tr><th><code>--type</code></th><td>Place type filter. Repeatable; Google receives the included type.</td></tr>
|
||||
<tr><th><code>--open-now</code></th><td>Return currently open places only.</td></tr>
|
||||
<tr><th><code>--min-rating</code></th><td>Minimum rating, 0-5.</td></tr>
|
||||
<tr><th><code>--price-level</code></th><td>Price levels 0-4. Repeatable.</td></tr>
|
||||
<tr><th><code>--lat</code>, <code>--lng</code>, <code>--radius-m</code></th><td>Optional location bias. Provide all three together.</td></tr>
|
||||
</tbody></table>
|
||||
<p>Global flags include <code>--json</code>, <code>--api-key</code>, <code>--timeout</code>, <code>--no-color</code>, and endpoint overrides.</p>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>Automation</h2>
|
||||
<div class="panel"><pre>goplaces search "sushi" --json
|
||||
goplaces search "pizza" --page-token "$NEXT_PAGE_TOKEN"</pre></div>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="footer"><div class="shell foot-bar"><a href="../index.html">← Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a></div></footer>
|
||||
<script src="../assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
49
docs/directions.md
Normal file
49
docs/directions.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Directions
|
||||
|
||||
Use the Routes API (`computeRoutes`) to get distance, duration, and step-by-step instructions.
|
||||
|
||||
## Enable the API
|
||||
|
||||
- Enable **Routes API** in Google Cloud Console for the same project as Places.
|
||||
- Use the same `GOOGLE_PLACES_API_KEY` (recommended).
|
||||
|
||||
## Examples
|
||||
|
||||
Walking summary:
|
||||
|
||||
```bash
|
||||
goplaces directions --from "Pike Place Market" --to "Space Needle"
|
||||
```
|
||||
|
||||
Place ID driven:
|
||||
|
||||
```bash
|
||||
goplaces directions --from-place-id <fromId> --to-place-id <toId>
|
||||
```
|
||||
|
||||
Walking with driving comparison + steps:
|
||||
|
||||
```bash
|
||||
goplaces directions --from-place-id <fromId> --to-place-id <toId> --compare drive --steps
|
||||
```
|
||||
|
||||
Imperial units:
|
||||
|
||||
```bash
|
||||
goplaces directions --from-place-id <fromId> --to-place-id <toId> --units imperial
|
||||
```
|
||||
|
||||
Driving route modifiers:
|
||||
|
||||
```bash
|
||||
goplaces directions --from "Paris" --to "Brest" --mode drive --avoid-tolls
|
||||
goplaces directions --from "Paris" --to "Brest" --mode drive --avoid-highways --avoid-ferries
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Default mode is walking.
|
||||
- Default units are metric (use `--units imperial` for miles/feet).
|
||||
- Use `--steps` for turn-by-turn instructions.
|
||||
- Use `--compare drive` to add a driving ETA.
|
||||
- Use `--avoid-tolls`, `--avoid-highways`, and `--avoid-ferries` with `--mode drive` to request drive routes that avoid those features when reasonable.
|
||||
353
docs/index.html
Normal file
353
docs/index.html
Normal file
@ -0,0 +1,353 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>goplaces — Go client + CLI for Google Places API (New)</title>
|
||||
<meta name="description" content="A modern Go client and CLI for Google Places API (New) and Routes. Search, autocomplete, details, photos, route-aware queries — typed, JSON-friendly, one binary.">
|
||||
<meta property="og:title" content="goplaces">
|
||||
<meta property="og:description" content="Modern Go client + CLI for Google Places API (New) and Routes.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta name="theme-color" content="#0b100e">
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<nav class="nav shell" aria-label="Primary">
|
||||
<a class="brand" href="index.html"><span class="mark"></span><span>goplaces</span></a>
|
||||
<div class="links">
|
||||
<a href="#commands">Commands</a>
|
||||
<a href="#setup">Setup</a>
|
||||
<a href="#library">Go API</a>
|
||||
<a href="#architecture">Architecture</a>
|
||||
<a href="https://github.com/steipete/goplaces" rel="noopener">GitHub ↗</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ─────────────── Hero ─────────────── -->
|
||||
<section class="hero shell">
|
||||
<div>
|
||||
<div class="eyebrow">Google Places API (New) · Routes</div>
|
||||
<h1>Places &<br>routes, in Go.</h1>
|
||||
<p class="lede">A typed Go client and a single-binary CLI for finding places, resolving locations, fetching details and photos, autocompleting input, and running route-aware searches.</p>
|
||||
<div class="actions">
|
||||
<a class="button primary" href="#commands">Browse commands <span class="arrow">→</span></a>
|
||||
<a class="button" href="#setup">Install</a>
|
||||
<a class="button" href="https://github.com/steipete/goplaces" rel="noopener">View source</a>
|
||||
</div>
|
||||
<div class="qi" role="group" aria-label="Quick install">
|
||||
<span class="dollar">$</span>
|
||||
<span class="text" id="qi-cmd">brew install steipete/tap/goplaces</span>
|
||||
<button class="copy" data-copy="#qi-cmd" aria-label="Copy install command">Copy</button>
|
||||
</div>
|
||||
<div class="stats" aria-label="Project at a glance">
|
||||
<div class="stat"><span class="num">8</span><span class="label">Commands</span></div>
|
||||
<div class="stat"><span class="num">1</span><span class="label">Static binary</span></div>
|
||||
<div class="stat"><span class="num">--json</span><span class="label">Stable output</span></div>
|
||||
<div class="stat"><span class="num">MIT</span><span class="label">License</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="window" aria-label="Terminal preview">
|
||||
<div class="window-bar">
|
||||
<span class="dots"><i></i><i></i><i></i></span>
|
||||
<span class="title">~/ goplaces</span>
|
||||
<span class="right">zsh</span>
|
||||
</div>
|
||||
<pre><span class="tk-c"># Find well-rated coffee around Central Park, open right now</span>
|
||||
<span class="tk-prompt">$ </span><span class="tk-cmd">goplaces search</span> <span class="tk-str">"coffee"</span> <span class="tk-flag">--open-now</span> <span class="tk-flag">--min-rating</span> <span class="tk-num">4</span> \
|
||||
<span class="tk-flag">--lat</span> <span class="tk-num">40.8065</span> <span class="tk-flag">--lng</span> <span class="tk-num">-73.9719</span> <span class="tk-flag">--radius-m</span> <span class="tk-num">3000</span>
|
||||
|
||||
Blue Bottle Coffee <span class="tk-num">4.5</span> <span class="tk-ok">open</span>
|
||||
Birch Coffee <span class="tk-num">4.4</span> <span class="tk-ok">open</span>
|
||||
Daily Provisions <span class="tk-num">4.6</span> <span class="tk-ok">open</span>
|
||||
Joe Coffee Company <span class="tk-num">4.3</span> <span class="tk-warn">closes 6pm</span>
|
||||
|
||||
<span class="tk-prompt">$ </span><span class="tk-cmd">goplaces directions</span> <span class="tk-flag">--from</span> <span class="tk-str">"Pike Place Market"</span> <span class="tk-flag">--to</span> <span class="tk-str">"Space Needle"</span><span class="cursor"></span></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─────────────── Commands ─────────────── -->
|
||||
<section class="section shell" id="commands">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="eyebrow">Command docs</div>
|
||||
<h2>One page per workflow.</h2>
|
||||
</div>
|
||||
<p>Each command supports human-readable output by default and stable JSON with <code>--json</code>. Global flags — <code>--api-key</code>, <code>--timeout</code>, <code>--no-color</code>, endpoint overrides — work everywhere.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<a class="card" href="commands/search.html">
|
||||
<span class="card-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||
</span>
|
||||
<span class="pill">Places Text Search</span>
|
||||
<h3><code>search</code></h3>
|
||||
<p>Find places by text, type, rating, price, open state, and location bias.</p>
|
||||
<div class="card-foot"><span>Read docs</span><span class="arrow">→</span></div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="commands/nearby.html">
|
||||
<span class="card-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M3 12h3M18 12h3M12 3v3M12 18v3"/></svg>
|
||||
</span>
|
||||
<span class="pill">Nearby Search</span>
|
||||
<h3><code>nearby</code></h3>
|
||||
<p>Search around a required latitude, longitude, and radius.</p>
|
||||
<div class="card-foot"><span>Read docs</span><span class="arrow">→</span></div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="commands/autocomplete.html">
|
||||
<span class="card-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h12"/><path d="M4 12h8"/><path d="M4 18h16"/><path d="M16 9l3 3-3 3"/></svg>
|
||||
</span>
|
||||
<span class="pill">Autocomplete</span>
|
||||
<h3><code>autocomplete</code></h3>
|
||||
<p>Return place and query suggestions from partial input.</p>
|
||||
<div class="card-foot"><span>Read docs</span><span class="arrow">→</span></div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="commands/details.html">
|
||||
<span class="card-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/><path d="M8 13h8M8 17h5"/></svg>
|
||||
</span>
|
||||
<span class="pill">Place Details</span>
|
||||
<h3><code>details</code></h3>
|
||||
<p>Address, coordinates, phone, website, hours, photos, reviews, status.</p>
|
||||
<div class="card-foot"><span>Read docs</span><span class="arrow">→</span></div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="commands/photo.html">
|
||||
<span class="card-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="m3 18 5-5 4 4 3-3 6 6"/></svg>
|
||||
</span>
|
||||
<span class="pill amber">Photo Media</span>
|
||||
<h3><code>photo</code></h3>
|
||||
<p>Turn a Places photo resource name into a media URL.</p>
|
||||
<div class="card-foot"><span>Read docs</span><span class="arrow">→</span></div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="commands/resolve.html">
|
||||
<span class="card-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s7-7 7-12a7 7 0 1 0-14 0c0 5 7 12 7 12z"/><circle cx="12" cy="10" r="2.5"/></svg>
|
||||
</span>
|
||||
<span class="pill amber">Resolve</span>
|
||||
<h3><code>resolve</code></h3>
|
||||
<p>Convert free-form location text into candidate place IDs.</p>
|
||||
<div class="card-foot"><span>Read docs</span><span class="arrow">→</span></div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="commands/route.html">
|
||||
<span class="card-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="19" r="2"/><circle cx="18" cy="5" r="2"/><path d="M8 19h6a4 4 0 0 0 0-8h-4a4 4 0 0 1 0-8h6"/></svg>
|
||||
</span>
|
||||
<span class="pill blue">Routes · Places</span>
|
||||
<h3><code>route</code></h3>
|
||||
<p>Sample a route and search for places near waypoints.</p>
|
||||
<div class="card-foot"><span>Read docs</span><span class="arrow">→</span></div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="commands/directions.html">
|
||||
<span class="card-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12l9-9 9 9-9 9z"/><path d="M9 14v-3h6"/><path d="M15 11l3 3-3 3"/></svg>
|
||||
</span>
|
||||
<span class="pill blue">Routes</span>
|
||||
<h3><code>directions</code></h3>
|
||||
<p>Distance, duration, warnings, steps, units, drive modifiers.</p>
|
||||
<div class="card-foot"><span>Read docs</span><span class="arrow">→</span></div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─────────────── Setup ─────────────── -->
|
||||
<section class="section shell" id="setup">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="eyebrow">Setup</div>
|
||||
<h2>Install, key, run.</h2>
|
||||
</div>
|
||||
<p>Enable Places API (New). Enable Routes API for <code>route</code> and <code>directions</code>. Then export one API key — the CLI and Go client both pick it up.</p>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<h3>Three steps to your first call</h3>
|
||||
<ol class="steps">
|
||||
<li><b>1</b><div>
|
||||
<p>Install the binary.</p>
|
||||
<small>Pick Homebrew, <code>go install</code>, or grab a release archive.</small>
|
||||
</div></li>
|
||||
<li><b>2</b><div>
|
||||
<p>Enable the APIs in Google Cloud.</p>
|
||||
<small>Places API (New) is required. Routes API is needed for <code>route</code> and <code>directions</code>.</small>
|
||||
</div></li>
|
||||
<li><b>3</b><div>
|
||||
<p>Export your key, then run any command.</p>
|
||||
<small><code>export GOOGLE_PLACES_API_KEY="..."</code></small>
|
||||
</div></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="panel" data-tabs>
|
||||
<div role="tablist" class="tabs" aria-label="Install method">
|
||||
<button class="tab" role="tab" aria-selected="true" aria-controls="t-brew">Homebrew</button>
|
||||
<button class="tab" role="tab" aria-selected="false" aria-controls="t-go">go install</button>
|
||||
<button class="tab" role="tab" aria-selected="false" aria-controls="t-bin">macOS/Linux</button>
|
||||
</div>
|
||||
|
||||
<div role="tabpanel" id="t-brew" class="tab-panel is-active">
|
||||
<div class="codebar"><span class="pill">shell</span><button class="copy" data-copy="#code-brew" data-copy-text="brew install steipete/tap/goplaces
|
||||
export GOOGLE_PLACES_API_KEY="..."
|
||||
goplaces search "bookstore"">Copy</button></div>
|
||||
<pre id="code-brew"><span class="tk-c"># Install via the steipete tap</span>
|
||||
<span class="tk-prompt">$ </span>brew install steipete/tap/goplaces
|
||||
<span class="tk-prompt">$ </span>export GOOGLE_PLACES_API_KEY=<span class="tk-str">"..."</span>
|
||||
<span class="tk-prompt">$ </span>goplaces search <span class="tk-str">"bookstore"</span></pre>
|
||||
</div>
|
||||
|
||||
<div role="tabpanel" id="t-go" class="tab-panel">
|
||||
<div class="codebar"><span class="pill">shell</span><button class="copy" data-copy="#code-go" data-copy-text="go install github.com/steipete/goplaces/cmd/goplaces@latest
|
||||
export GOOGLE_PLACES_API_KEY="..."
|
||||
goplaces details ChIJN1t_tDeuEmsRUsoyG83frY4">Copy</button></div>
|
||||
<pre id="code-go"><span class="tk-c"># Direct install via the Go toolchain</span>
|
||||
<span class="tk-prompt">$ </span>go install github.com/steipete/goplaces/cmd/goplaces@latest
|
||||
<span class="tk-prompt">$ </span>export GOOGLE_PLACES_API_KEY=<span class="tk-str">"..."</span>
|
||||
<span class="tk-prompt">$ </span>goplaces details ChIJN1t_tDeuEmsRUsoyG83frY4</pre>
|
||||
</div>
|
||||
|
||||
<div role="tabpanel" id="t-bin" class="tab-panel">
|
||||
<div class="codebar"><span class="pill">shell</span><button class="copy" data-copy="#code-bin" data-copy-text="gh release download --repo steipete/goplaces --pattern "goplaces_*_$(go env GOOS)_$(go env GOARCH).tar.gz" --output - | tar xz
|
||||
./goplaces --help">Copy</button></div>
|
||||
<pre id="code-bin"><span class="tk-c"># Stream the matching archive from the latest GitHub release</span>
|
||||
<span class="tk-prompt">$ </span>gh release download <span class="tk-flag">--repo</span> steipete/goplaces \
|
||||
<span class="tk-flag">--pattern</span> <span class="tk-str">"goplaces_*_$(go env GOOS)_$(go env GOARCH).tar.gz"</span> \
|
||||
<span class="tk-flag">--output</span> - | tar xz
|
||||
<span class="tk-prompt">$ </span>./goplaces --help</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─────────────── Go library ─────────────── -->
|
||||
<section class="section shell" id="library">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="eyebrow">Go package</div>
|
||||
<h2>Same workflows, typed.</h2>
|
||||
</div>
|
||||
<p>The root package is <code>github.com/steipete/goplaces</code>. Requests mirror the CLI concepts and own their field masks. No wrapper layer over a giant generated client — small surface, idiomatic types.</p>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<h3>What you get</h3>
|
||||
<ul class="steps">
|
||||
<li><b>·</b><div><p>Typed request and response structs</p><small>Place, Photo, Route, Step, OpeningHours — all explicit.</small></div></li>
|
||||
<li><b>·</b><div><p>Deterministic field masks</p><small>Each request owns the fields it asks Google for. No surprise costs.</small></div></li>
|
||||
<li><b>·</b><div><p>Context everywhere</p><small>Honor cancellation, deadlines, and request-scoped values.</small></div></li>
|
||||
<li><b>·</b><div><p>Pluggable HTTP</p><small>Inject your own <code>http.Client</code> for retries, tracing, or fakes.</small></div></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="codebar"><span class="pill">go</span><button class="copy" data-copy="#goapi">Copy</button></div>
|
||||
<pre id="goapi"><span class="tk-key">import</span> <span class="tk-pun">(</span>
|
||||
<span class="tk-str">"context"</span>
|
||||
<span class="tk-str">"os"</span>
|
||||
<span class="tk-str">"time"</span>
|
||||
|
||||
<span class="tk-str">"github.com/steipete/goplaces"</span>
|
||||
<span class="tk-pun">)</span>
|
||||
|
||||
client <span class="tk-pun">:=</span> goplaces<span class="tk-pun">.</span><span class="tk-fn">NewClient</span><span class="tk-pun">(</span>goplaces<span class="tk-pun">.</span><span class="tk-type">Options</span><span class="tk-pun">{</span>
|
||||
APIKey<span class="tk-pun">:</span> os<span class="tk-pun">.</span><span class="tk-fn">Getenv</span><span class="tk-pun">(</span><span class="tk-str">"GOOGLE_PLACES_API_KEY"</span><span class="tk-pun">),</span>
|
||||
Timeout<span class="tk-pun">:</span> <span class="tk-num">8</span> <span class="tk-pun">*</span> time<span class="tk-pun">.</span>Second<span class="tk-pun">,</span>
|
||||
<span class="tk-pun">})</span>
|
||||
|
||||
resp<span class="tk-pun">,</span> err <span class="tk-pun">:=</span> client<span class="tk-pun">.</span><span class="tk-fn">Search</span><span class="tk-pun">(</span>ctx<span class="tk-pun">,</span> goplaces<span class="tk-pun">.</span><span class="tk-type">SearchRequest</span><span class="tk-pun">{</span>
|
||||
Query<span class="tk-pun">:</span> <span class="tk-str">"italian restaurant"</span><span class="tk-pun">,</span>
|
||||
Limit<span class="tk-pun">:</span> <span class="tk-num">10</span><span class="tk-pun">,</span>
|
||||
LocationBias<span class="tk-pun">:</span> <span class="tk-pun">&</span>goplaces<span class="tk-pun">.</span><span class="tk-type">LocationBias</span><span class="tk-pun">{</span>
|
||||
Lat<span class="tk-pun">:</span> <span class="tk-num">40.8065</span><span class="tk-pun">,</span> Lng<span class="tk-pun">:</span> <span class="tk-num">-73.9719</span><span class="tk-pun">,</span> RadiusM<span class="tk-pun">:</span> <span class="tk-num">3000</span><span class="tk-pun">,</span>
|
||||
<span class="tk-pun">},</span>
|
||||
<span class="tk-pun">})</span></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─────────────── Architecture ─────────────── -->
|
||||
<section class="section shell" id="architecture">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="eyebrow">Project map</div>
|
||||
<h2>Small surface, clear split.</h2>
|
||||
</div>
|
||||
<p>Two entry points — the CLI and the Go client — share one place and route implementation. Easy to read in an afternoon.</p>
|
||||
</div>
|
||||
|
||||
<div class="map">
|
||||
<div class="card">
|
||||
<span class="path">cmd/goplaces</span>
|
||||
<h3>CLI entry</h3>
|
||||
<p>The thin <code>main</code>. Wires up Kong, flags, version info, and exits.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="path">internal/cli</span>
|
||||
<h3>Commands & output</h3>
|
||||
<p>Per-command Kong structs, renderers, and the <code>--json</code> machine output.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="path">github.com/steipete/goplaces</span>
|
||||
<h3>Public client</h3>
|
||||
<p>Typed requests and responses. Owns field masks and retries.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="path">internal/places</span>
|
||||
<h3>HTTP & mapping</h3>
|
||||
<p>The actual Places + Routes calls and the JSON → Go mapping.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- ─────────────── Footer ─────────────── -->
|
||||
<footer class="footer">
|
||||
<div class="shell foot-grid">
|
||||
<div>
|
||||
<a class="brand" href="index.html"><span class="mark"></span><span>goplaces</span></a>
|
||||
<p>A Go client and CLI for Google Places API (New) and Routes. Small surface, explicit types, JSON when you need it.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Commands</h4>
|
||||
<a href="commands/search.html">search</a>
|
||||
<a href="commands/nearby.html">nearby</a>
|
||||
<a href="commands/autocomplete.html">autocomplete</a>
|
||||
<a href="commands/details.html">details</a>
|
||||
</div>
|
||||
<div>
|
||||
<h4>More</h4>
|
||||
<a href="commands/photo.html">photo</a>
|
||||
<a href="commands/resolve.html">resolve</a>
|
||||
<a href="commands/route.html">route</a>
|
||||
<a href="commands/directions.html">directions</a>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Project</h4>
|
||||
<a href="https://github.com/steipete/goplaces" rel="noopener">GitHub</a>
|
||||
<a href="https://github.com/steipete/goplaces/releases" rel="noopener">Releases</a>
|
||||
<a href="https://github.com/steipete/goplaces/blob/main/CHANGELOG.md" rel="noopener">Changelog</a>
|
||||
<a href="https://github.com/steipete/goplaces/blob/main/LICENSE" rel="noopener">MIT License</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shell foot-bar">
|
||||
<span>Built for GitHub Pages from <code>docs/</code>.</span>
|
||||
<span>© Peter Steinberger</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -34,3 +34,4 @@ response, err := client.NearbySearch(ctx, goplaces.NearbySearchRequest{
|
||||
|
||||
- Location restriction (lat/lng/radius) is required.
|
||||
- Use `IncludedTypes`/`--type` to filter result types.
|
||||
- Results include `business_status` when Google returns it.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# goplaces Homebrew Release Playbook
|
||||
|
||||
Manual/local tap update (no GitHub token). This doc mirrors camsnap style.
|
||||
Manual/local tap update from GitHub release assets.
|
||||
|
||||
## Prereqs
|
||||
|
||||
@ -10,8 +10,8 @@ Manual/local tap update (no GitHub token). This doc mirrors camsnap style.
|
||||
## Release
|
||||
|
||||
1) Tag + push: `git tag vX.Y.Z && git push origin vX.Y.Z`
|
||||
2) GitHub Actions builds binaries (workflow artifacts only).
|
||||
3) Host artifacts somewhere public (e.g., attach to a manual GitHub release).
|
||||
2) GitHub Actions builds binaries (workflow artifacts).
|
||||
3) Create/publish GitHub release `vX.Y.Z` and upload archives.
|
||||
4) Update the tap locally:
|
||||
- In `../homebrew-tap/Formula/goplaces.rb`, set `version`, `url`, `sha256`.
|
||||
- Commit + push in `../homebrew-tap`.
|
||||
@ -24,4 +24,4 @@ brew update && brew install steipete/tap/goplaces
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- CI does not publish releases or Homebrew.
|
||||
- CI does not publish GitHub releases or Homebrew automatically.
|
||||
|
||||
@ -7,9 +7,9 @@ Quick, repeatable release checklist. Mirrors gifgrep cadence.
|
||||
- Update `CHANGELOG.md` for the new version.
|
||||
- Run gate: `./scripts/check-coverage.sh` + `golangci-lint run ./...`.
|
||||
- Ensure `main` is clean and pushed.
|
||||
- Ensure `HOMEBREW_TAP_GITHUB_TOKEN` secret is set (pushes formula to `steipete/homebrew-tap`).
|
||||
- Ensure `gh` is authenticated for `steipete/goplaces` + `steipete/homebrew-tap`.
|
||||
|
||||
## Tag + Release
|
||||
## Tag + Build
|
||||
|
||||
```bash
|
||||
git tag vX.Y.Z
|
||||
@ -17,9 +17,17 @@ git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
GitHub Actions runs GoReleaser build on tag push (`.github/workflows/release.yml`).
|
||||
Artifacts are stored on the workflow run (no GitHub release publish).
|
||||
Artifacts are stored on the workflow run.
|
||||
|
||||
Homebrew (local tap update): see `docs/releasing-homebrew.md`.
|
||||
## Publish GitHub Release
|
||||
|
||||
Create a release from the tag and upload built archives (`goplaces_<version>_<os>_<arch>.tar.gz|zip`):
|
||||
|
||||
```bash
|
||||
gh release create vX.Y.Z ./dist-archives/* --repo steipete/goplaces --title vX.Y.Z --notes-file /tmp/release-notes.md
|
||||
```
|
||||
|
||||
Homebrew update: see `docs/releasing-homebrew.md`.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
2
go.mod
2
go.mod
@ -2,4 +2,4 @@ module github.com/steipete/goplaces
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require github.com/alecthomas/kong v1.13.0
|
||||
require github.com/alecthomas/kong v1.15.0
|
||||
|
||||
4
go.sum
4
go.sum
@ -1,7 +1,7 @@
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA=
|
||||
github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
|
||||
github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI=
|
||||
github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
|
||||
@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@ -115,3 +116,101 @@ func TestRunRouteWithEqualsFlags(t *testing.T) {
|
||||
t.Fatalf("unexpected stdout: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectionsWithEqualsFlags(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != directionsPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload["travelMode"] != directionsModeWalkAPI {
|
||||
t.Fatalf("unexpected mode: %#v", payload["travelMode"])
|
||||
}
|
||||
if payload["units"] != "METRIC" {
|
||||
t.Fatalf("unexpected units: %#v", payload["units"])
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"routes":[{"legs":[{"distanceMeters":1000,"duration":"600s","localizedValues":{"distance":{"text":"1 km"},"duration":{"text":"10 mins"}},"steps":[]}]}]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{
|
||||
"directions",
|
||||
"--from=A",
|
||||
"--to=B",
|
||||
"--api-key=test-key",
|
||||
"--directions-base-url=" + server.URL,
|
||||
"--mode=walk",
|
||||
"--units=metric",
|
||||
"--json",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d (stdout=%s stderr=%s)", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("unexpected stderr: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"mode\": \"WALKING\"") {
|
||||
t.Fatalf("unexpected stdout: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectionsWithAvoidFlags(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != directionsPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload["travelMode"] != directionsModeDriveAPI {
|
||||
t.Fatalf("unexpected mode: %#v", payload["travelMode"])
|
||||
}
|
||||
modifiers, ok := payload["routeModifiers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing routeModifiers: %#v", payload)
|
||||
}
|
||||
if modifiers["avoidTolls"] != true || modifiers["avoidHighways"] != true || modifiers["avoidFerries"] != true {
|
||||
t.Fatalf("unexpected routeModifiers: %#v", modifiers)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"routes":[{"legs":[{"distanceMeters":1000,"duration":"600s","localizedValues":{"distance":{"text":"1 km"},"duration":{"text":"10 mins"}},"steps":[]}]}]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{
|
||||
"directions",
|
||||
"--from=A",
|
||||
"--to=B",
|
||||
"--api-key=test-key",
|
||||
"--directions-base-url=" + server.URL,
|
||||
"--mode=drive",
|
||||
"--avoid-tolls",
|
||||
"--avoid-highways",
|
||||
"--avoid-ferries",
|
||||
"--json",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d (stdout=%s stderr=%s)", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("unexpected stderr: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"mode\": \"DRIVING\"") {
|
||||
t.Fatalf("unexpected stdout: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,9 +13,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
placesSearchPath = "/places:searchText"
|
||||
placesNearbyPath = "/places:searchNearby"
|
||||
routesComputePath = "/directions/v2:computeRoutes"
|
||||
placesSearchPath = "/places:searchText"
|
||||
placesNearbyPath = "/places:searchNearby"
|
||||
routesComputePath = "/directions/v2:computeRoutes"
|
||||
directionsPath = routesComputePath
|
||||
directionsModeWalkAPI = "WALK"
|
||||
directionsModeDriveAPI = "DRIVE"
|
||||
directionsModeWalking = "walking"
|
||||
directionsModeDriving = "driving"
|
||||
)
|
||||
|
||||
func TestRunSearchJSON(t *testing.T) {
|
||||
@ -348,6 +353,209 @@ func TestRunRouteMissingFrom(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectionsJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != directionsPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Goog-Api-Key") != "test-key" {
|
||||
t.Fatalf("unexpected api key header: %s", r.Header.Get("X-Goog-Api-Key"))
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload["travelMode"] != directionsModeWalkAPI {
|
||||
t.Fatalf("unexpected mode: %#v", payload["travelMode"])
|
||||
}
|
||||
if payload["units"] != "METRIC" {
|
||||
t.Fatalf("unexpected units: %#v", payload["units"])
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"routes":[{"description":"Main","legs":[{"distanceMeters":1000,"duration":"600s","localizedValues":{"distance":{"text":"1 km"},"duration":{"text":"10 mins"}},"steps":[]}]}]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{
|
||||
"directions",
|
||||
"--from", "A",
|
||||
"--to", "B",
|
||||
"--api-key", "test-key",
|
||||
"--directions-base-url", server.URL,
|
||||
"--json",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d (stdout=%s stderr=%s)", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"mode\": \"WALKING\"") {
|
||||
t.Fatalf("unexpected stdout: %s", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("unexpected stderr: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectionsCompareJSON(t *testing.T) {
|
||||
seenModes := make(map[string]int)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != directionsPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
mode, _ := payload["travelMode"].(string)
|
||||
seenModes[mode]++
|
||||
responseBody := `{
|
||||
"routes":[{"description":"Main","legs":[{"distanceMeters":1000,"duration":"600s","localizedValues":{"distance":{"text":"1 km"},"duration":{"text":"10 mins"}},"steps":[]}]}]
|
||||
}`
|
||||
if mode == directionsModeDriveAPI {
|
||||
responseBody = `{
|
||||
"routes":[{"description":"Main","legs":[{"distanceMeters":1000,"duration":"240s","localizedValues":{"distance":{"text":"1 km"},"duration":{"text":"4 mins"}},"steps":[]}]}]
|
||||
}`
|
||||
}
|
||||
_, _ = w.Write([]byte(responseBody))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{
|
||||
"directions",
|
||||
"--from", "A",
|
||||
"--to", "B",
|
||||
"--compare", "drive",
|
||||
"--api-key", "test-key",
|
||||
"--directions-base-url", server.URL,
|
||||
"--json",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d (stdout=%s stderr=%s)", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
var results []goplaces.DirectionsResponse
|
||||
if err := json.Unmarshal(stdout.Bytes(), &results); err != nil {
|
||||
t.Fatalf("decode output: %v (stdout=%s)", err, stdout.String())
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 directions results, got %d", len(results))
|
||||
}
|
||||
if results[0].Mode != "WALKING" || results[1].Mode != "DRIVING" {
|
||||
t.Fatalf("unexpected mode order: %#v", results)
|
||||
}
|
||||
if seenModes[directionsModeWalkAPI] != 1 || seenModes[directionsModeDriveAPI] != 1 {
|
||||
t.Fatalf("expected both modes requested once, got: %#v", seenModes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectionsHumanCompareWithSteps(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != directionsPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
mode, _ := payload["travelMode"].(string)
|
||||
if mode == "" {
|
||||
t.Fatalf("missing mode")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"routes":[{"description":"Main","legs":[{"distanceMeters":1000,"duration":"600s","localizedValues":{"distance":{"text":"1 km"},"duration":{"text":"10 mins"}},"steps":[{"distanceMeters":200,"staticDuration":"120s","localizedValues":{"distance":{"text":"0.2 km"},"staticDuration":{"text":"2 mins"}},"navigationInstruction":{"instructions":"Head north"}}]}]}]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{
|
||||
"directions",
|
||||
"--from", "A",
|
||||
"--to", "B",
|
||||
"--compare", "drive",
|
||||
"--steps",
|
||||
"--api-key", "test-key",
|
||||
"--directions-base-url", server.URL,
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d (stdout=%s stderr=%s)", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Directions (WALKING)") || !strings.Contains(stdout.String(), "Directions (DRIVING)") {
|
||||
t.Fatalf("missing compare output: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Head north") {
|
||||
t.Fatalf("missing steps output: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectionsValidationErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "invalid mode",
|
||||
args: []string{"directions", "--from", "A", "--to", "B", "--mode", "plane", "--api-key", "x"},
|
||||
},
|
||||
{
|
||||
name: "invalid compare",
|
||||
args: []string{"directions", "--from", "A", "--to", "B", "--compare", "plane", "--api-key", "x"},
|
||||
},
|
||||
{
|
||||
name: "same compare mode",
|
||||
args: []string{"directions", "--from", "A", "--to", "B", "--mode", "walk", "--compare", directionsModeWalking, "--api-key", "x"},
|
||||
},
|
||||
{
|
||||
name: "partial from latlng",
|
||||
args: []string{"directions", "--from-lat", "1", "--to", "B", "--api-key", "x"},
|
||||
},
|
||||
{
|
||||
name: "partial to latlng",
|
||||
args: []string{"directions", "--from", "A", "--to-lng", "2", "--api-key", "x"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := Run(testCase.args, &stdout, &stderr)
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("expected validation exit code 2, got %d (stdout=%s stderr=%s)", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDirectionsMode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"walk": directionsModeWalking,
|
||||
"walking": directionsModeWalking,
|
||||
"drive": directionsModeDriving,
|
||||
"driving": directionsModeDriving,
|
||||
"bike": "bicycling",
|
||||
"bicycle": "bicycling",
|
||||
"bicycling": "bicycling",
|
||||
"transit": "transit",
|
||||
"plane": "",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := normalizeDirectionsMode(input); got != want {
|
||||
t.Fatalf("normalizeDirectionsMode(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDetailsJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/places/place-1" {
|
||||
|
||||
@ -40,7 +40,7 @@ func (c Color) Dim(value string) string {
|
||||
return c.wrap("2", value)
|
||||
}
|
||||
|
||||
func (c Color) wrap(code string, value string) string {
|
||||
func (c Color) wrap(code, value string) string {
|
||||
if !c.enabled {
|
||||
return value
|
||||
}
|
||||
|
||||
126
internal/cli/directions.go
Normal file
126
internal/cli/directions.go
Normal file
@ -0,0 +1,126 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/steipete/goplaces"
|
||||
)
|
||||
|
||||
// DirectionsCmd fetches directions between two points.
|
||||
type DirectionsCmd struct {
|
||||
From string `help:"Origin address or place name."`
|
||||
To string `help:"Destination address or place name."`
|
||||
FromPlaceID string `help:"Origin place ID." name:"from-place-id"`
|
||||
ToPlaceID string `help:"Destination place ID." name:"to-place-id"`
|
||||
FromLat *float64 `help:"Origin latitude." name:"from-lat"`
|
||||
FromLng *float64 `help:"Origin longitude." name:"from-lng"`
|
||||
ToLat *float64 `help:"Destination latitude." name:"to-lat"`
|
||||
ToLng *float64 `help:"Destination longitude." name:"to-lng"`
|
||||
Mode string `help:"Travel mode: walk, drive, bicycle, transit." default:"walk"`
|
||||
Compare string `help:"Compare with another mode: walk, drive, bicycle, transit."`
|
||||
Steps bool `help:"Include step-by-step instructions."`
|
||||
Units string `help:"Units: metric or imperial." default:"metric"`
|
||||
AvoidTolls bool `help:"Avoid toll roads when driving."`
|
||||
AvoidHighways bool `help:"Avoid highways when driving."`
|
||||
AvoidFerries bool `help:"Avoid ferries when driving."`
|
||||
Language string `help:"BCP-47 language code (e.g. en, en-US)."`
|
||||
Region string `help:"CLDR region code (e.g. US, DE)."`
|
||||
}
|
||||
|
||||
// Run executes the directions command.
|
||||
func (c *DirectionsCmd) Run(app *App) error {
|
||||
primaryMode := normalizeDirectionsMode(c.Mode)
|
||||
if primaryMode == "" {
|
||||
return goplaces.ValidationError{Field: "mode", Message: "must be walk, drive, bicycle, or transit"}
|
||||
}
|
||||
compareMode := ""
|
||||
if strings.TrimSpace(c.Compare) != "" {
|
||||
compareMode = normalizeDirectionsMode(c.Compare)
|
||||
if compareMode == "" {
|
||||
return goplaces.ValidationError{Field: "compare", Message: "must be walk, drive, bicycle, or transit"}
|
||||
}
|
||||
if compareMode == primaryMode {
|
||||
return goplaces.ValidationError{Field: "compare", Message: "must be different from mode"}
|
||||
}
|
||||
}
|
||||
|
||||
request := goplaces.DirectionsRequest{
|
||||
From: c.From,
|
||||
To: c.To,
|
||||
FromPlaceID: c.FromPlaceID,
|
||||
ToPlaceID: c.ToPlaceID,
|
||||
Mode: primaryMode,
|
||||
Units: c.Units,
|
||||
AvoidTolls: c.AvoidTolls,
|
||||
AvoidHighways: c.AvoidHighways,
|
||||
AvoidFerries: c.AvoidFerries,
|
||||
Language: c.Language,
|
||||
Region: c.Region,
|
||||
}
|
||||
if c.FromLat != nil || c.FromLng != nil {
|
||||
if c.FromLat == nil || c.FromLng == nil {
|
||||
return goplaces.ValidationError{Field: "from_location", Message: "lat and lng required"}
|
||||
}
|
||||
request.FromLocation = &goplaces.LatLng{Lat: *c.FromLat, Lng: *c.FromLng}
|
||||
}
|
||||
if c.ToLat != nil || c.ToLng != nil {
|
||||
if c.ToLat == nil || c.ToLng == nil {
|
||||
return goplaces.ValidationError{Field: "to_location", Message: "lat and lng required"}
|
||||
}
|
||||
request.ToLocation = &goplaces.LatLng{Lat: *c.ToLat, Lng: *c.ToLng}
|
||||
}
|
||||
|
||||
response, err := app.client.Directions(context.Background(), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var compareResponse *goplaces.DirectionsResponse
|
||||
if compareMode != "" {
|
||||
compareRequest := request
|
||||
compareRequest.Mode = compareMode
|
||||
compareRequest.AvoidTolls = false
|
||||
compareRequest.AvoidHighways = false
|
||||
compareRequest.AvoidFerries = false
|
||||
second, err := app.client.Directions(context.Background(), compareRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
compareResponse = &second
|
||||
}
|
||||
|
||||
if app.json {
|
||||
if compareResponse != nil {
|
||||
return writeJSON(app.out, []goplaces.DirectionsResponse{response, *compareResponse})
|
||||
}
|
||||
return writeJSON(app.out, response)
|
||||
}
|
||||
|
||||
if compareResponse != nil {
|
||||
_, err = app.out.Write([]byte(renderDirections(app.color, response, c.Steps)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = app.out.Write([]byte("\n\n" + renderDirections(app.color, *compareResponse, c.Steps)))
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = app.out.Write([]byte(renderDirections(app.color, response, c.Steps)))
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeDirectionsMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "walk", "walking":
|
||||
return "walking"
|
||||
case "drive", "driving":
|
||||
return "driving"
|
||||
case "bike", "bicycle", "bicycling":
|
||||
return "bicycling"
|
||||
case "transit":
|
||||
return "transit"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,7 @@ func renderSearch(color Color, response goplaces.SearchResponse) string {
|
||||
out.WriteString("\n")
|
||||
|
||||
for i, place := range response.Results {
|
||||
out.WriteString(fmt.Sprintf("%d. %s\n", i+1, formatTitle(color, place.Name, place.Address)))
|
||||
fmt.Fprintf(&out, "%d. %s\n", i+1, formatTitle(color, place.Name, place.Address))
|
||||
writePlaceSummary(&out, color, place)
|
||||
if i < count-1 {
|
||||
out.WriteString("\n")
|
||||
@ -47,7 +47,7 @@ func renderAutocomplete(color Color, response goplaces.AutocompleteResponse) str
|
||||
|
||||
for i, suggestion := range response.Suggestions {
|
||||
title := formatTitle(color, autocompleteTitle(suggestion), autocompleteSubtitle(suggestion))
|
||||
out.WriteString(fmt.Sprintf("%d. %s\n", i+1, title))
|
||||
fmt.Fprintf(&out, "%d. %s\n", i+1, title)
|
||||
writeAutocompleteSuggestion(&out, color, suggestion)
|
||||
if i < count-1 {
|
||||
out.WriteString("\n")
|
||||
@ -66,7 +66,7 @@ func renderNearby(color Color, response goplaces.NearbySearchResponse) string {
|
||||
out.WriteString("\n")
|
||||
|
||||
for i, place := range response.Results {
|
||||
out.WriteString(fmt.Sprintf("%d. %s\n", i+1, formatTitle(color, place.Name, place.Address)))
|
||||
fmt.Fprintf(&out, "%d. %s\n", i+1, formatTitle(color, place.Name, place.Address))
|
||||
writePlaceSummary(&out, color, place)
|
||||
if i < count-1 {
|
||||
out.WriteString("\n")
|
||||
@ -110,7 +110,7 @@ func renderResolve(color Color, response goplaces.LocationResolveResponse) strin
|
||||
out.WriteString("\n")
|
||||
|
||||
for i, place := range response.Results {
|
||||
out.WriteString(fmt.Sprintf("%d. %s\n", i+1, formatTitle(color, place.Name, place.Address)))
|
||||
fmt.Fprintf(&out, "%d. %s\n", i+1, formatTitle(color, place.Name, place.Address))
|
||||
writeResolvedLocation(&out, color, place)
|
||||
if i < count-1 {
|
||||
out.WriteString("\n")
|
||||
@ -139,7 +139,7 @@ func renderRoute(color Color, response goplaces.RouteResponse) string {
|
||||
out.WriteString("\n")
|
||||
} else {
|
||||
for j, place := range waypoint.Results {
|
||||
out.WriteString(fmt.Sprintf("%d. %s\n", j+1, formatTitle(color, place.Name, place.Address)))
|
||||
fmt.Fprintf(&out, "%d. %s\n", j+1, formatTitle(color, place.Name, place.Address))
|
||||
writePlaceSummary(&out, color, place)
|
||||
if j < len(waypoint.Results)-1 {
|
||||
out.WriteString("\n")
|
||||
@ -155,7 +155,53 @@ func renderRoute(color Color, response goplaces.RouteResponse) string {
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func formatTitle(color Color, name string, address string) string {
|
||||
func renderDirections(color Color, response goplaces.DirectionsResponse, includeSteps bool) string {
|
||||
var out bytes.Buffer
|
||||
mode := strings.TrimSpace(response.Mode)
|
||||
header := "Directions"
|
||||
if mode != "" {
|
||||
header = fmt.Sprintf("Directions (%s)", mode)
|
||||
}
|
||||
out.WriteString(color.Bold(header))
|
||||
out.WriteString("\n")
|
||||
writeLine(&out, color, "From", response.StartAddress)
|
||||
writeLine(&out, color, "To", response.EndAddress)
|
||||
writeLine(&out, color, "Summary", response.Summary)
|
||||
writeLine(&out, color, "Distance", response.DistanceText)
|
||||
writeLine(&out, color, "Duration", response.DurationText)
|
||||
if len(response.Warnings) > 0 {
|
||||
out.WriteString(color.Dim("Warnings:"))
|
||||
out.WriteString("\n")
|
||||
for _, warning := range response.Warnings {
|
||||
if strings.TrimSpace(warning) == "" {
|
||||
continue
|
||||
}
|
||||
out.WriteString(" - ")
|
||||
out.WriteString(warning)
|
||||
out.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if includeSteps {
|
||||
out.WriteString(color.Dim("Steps:"))
|
||||
out.WriteString("\n")
|
||||
if len(response.Steps) == 0 {
|
||||
out.WriteString(" - ")
|
||||
out.WriteString(emptyResultsMessage)
|
||||
out.WriteString("\n")
|
||||
} else {
|
||||
for i, step := range response.Steps {
|
||||
line := directionsStepLine(step)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&out, " %d. %s\n", i+1, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func formatTitle(color Color, name, address string) string {
|
||||
display := strings.TrimSpace(name)
|
||||
if display == "" {
|
||||
display = "(no name)"
|
||||
@ -188,9 +234,10 @@ func autocompleteSubtitle(suggestion goplaces.AutocompleteSuggestion) string {
|
||||
func writePlaceSummary(out *bytes.Buffer, color Color, place goplaces.PlaceSummary) {
|
||||
writeLine(out, color, "ID", place.PlaceID)
|
||||
writeLocation(out, color, place.Location)
|
||||
writeRating(out, color, place.Rating, place.PriceLevel)
|
||||
writeRating(out, color, place.Rating, place.UserRatingCount, place.PriceLevel)
|
||||
writeTypes(out, color, place.Types)
|
||||
writeOpenNow(out, color, place.OpenNow)
|
||||
writeLine(out, color, "Status", place.BusinessStatus)
|
||||
}
|
||||
|
||||
func writeAutocompleteSuggestion(out *bytes.Buffer, color Color, suggestion goplaces.AutocompleteSuggestion) {
|
||||
@ -206,9 +253,10 @@ func writeAutocompleteSuggestion(out *bytes.Buffer, color Color, suggestion gopl
|
||||
func writePlaceDetails(out *bytes.Buffer, color Color, place goplaces.PlaceDetails) {
|
||||
writeLine(out, color, "ID", place.PlaceID)
|
||||
writeLocation(out, color, place.Location)
|
||||
writeRating(out, color, place.Rating, place.PriceLevel)
|
||||
writeRating(out, color, place.Rating, place.UserRatingCount, place.PriceLevel)
|
||||
writeTypes(out, color, place.Types)
|
||||
writeOpenNow(out, color, place.OpenNow)
|
||||
writeLine(out, color, "Status", place.BusinessStatus)
|
||||
writeLine(out, color, "Phone", place.Phone)
|
||||
writeLine(out, color, "Website", place.Website)
|
||||
writePhotos(out, color, place.Photos)
|
||||
@ -300,13 +348,19 @@ func writeLocation(out *bytes.Buffer, color Color, loc *goplaces.LatLng) {
|
||||
writeLine(out, color, "Location", fmt.Sprintf("%.6f, %.6f", loc.Lat, loc.Lng))
|
||||
}
|
||||
|
||||
func writeRating(out *bytes.Buffer, color Color, rating *float64, priceLevel *int) {
|
||||
if rating == nil && priceLevel == nil {
|
||||
func writeRating(out *bytes.Buffer, color Color, rating *float64, userRatingCount, priceLevel *int) {
|
||||
if rating == nil && userRatingCount == nil && priceLevel == nil {
|
||||
return
|
||||
}
|
||||
parts := make([]string, 0, 2)
|
||||
parts := make([]string, 0, 3)
|
||||
if rating != nil {
|
||||
parts = append(parts, fmt.Sprintf("%.1f", *rating))
|
||||
ratingStr := fmt.Sprintf("%.1f", *rating)
|
||||
if userRatingCount != nil {
|
||||
ratingStr += fmt.Sprintf(" (%d)", *userRatingCount)
|
||||
}
|
||||
parts = append(parts, ratingStr)
|
||||
} else if userRatingCount != nil {
|
||||
parts = append(parts, fmt.Sprintf("%d ratings", *userRatingCount))
|
||||
}
|
||||
if priceLevel != nil {
|
||||
parts = append(parts, fmt.Sprintf("$%d", *priceLevel))
|
||||
@ -333,7 +387,7 @@ func writeOpenNow(out *bytes.Buffer, color Color, openNow *bool) {
|
||||
writeLine(out, color, "Open now", value)
|
||||
}
|
||||
|
||||
func writeLine(out *bytes.Buffer, color Color, label string, value string) {
|
||||
func writeLine(out *bytes.Buffer, color Color, label, value string) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return
|
||||
}
|
||||
@ -398,6 +452,21 @@ func truncateText(value string, maxLen int) string {
|
||||
return strings.TrimSpace(value[:maxLen]) + "..."
|
||||
}
|
||||
|
||||
func directionsStepLine(step goplaces.DirectionsStep) string {
|
||||
instruction := strings.TrimSpace(step.Instruction)
|
||||
if instruction == "" {
|
||||
instruction = "(no instruction)"
|
||||
}
|
||||
parts := []string{instruction}
|
||||
if strings.TrimSpace(step.DistanceText) != "" {
|
||||
parts = append(parts, step.DistanceText)
|
||||
}
|
||||
if strings.TrimSpace(step.DurationText) != "" {
|
||||
parts = append(parts, step.DurationText)
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
|
||||
@ -12,17 +12,20 @@ import (
|
||||
func TestRenderSearch(t *testing.T) {
|
||||
open := true
|
||||
level := 2
|
||||
ratingCount := 532
|
||||
response := goplaces.SearchResponse{
|
||||
Results: []goplaces.PlaceSummary{
|
||||
{
|
||||
PlaceID: "abc",
|
||||
Name: "Cafe",
|
||||
Address: "123 Street",
|
||||
Location: &goplaces.LatLng{Lat: 1, Lng: 2},
|
||||
Rating: floatPtr(4.5),
|
||||
PriceLevel: &level,
|
||||
Types: []string{"cafe", "coffee_shop"},
|
||||
OpenNow: &open,
|
||||
PlaceID: "abc",
|
||||
Name: "Cafe",
|
||||
Address: "123 Street",
|
||||
Location: &goplaces.LatLng{Lat: 1, Lng: 2},
|
||||
Rating: floatPtr(4.5),
|
||||
UserRatingCount: &ratingCount,
|
||||
PriceLevel: &level,
|
||||
Types: []string{"cafe", "coffee_shop"},
|
||||
OpenNow: &open,
|
||||
BusinessStatus: "OPERATIONAL",
|
||||
},
|
||||
},
|
||||
NextPageToken: "next",
|
||||
@ -35,14 +38,38 @@ func TestRenderSearch(t *testing.T) {
|
||||
if !strings.Contains(output, "Rating") {
|
||||
t.Fatalf("missing rating")
|
||||
}
|
||||
if !strings.Contains(output, "4.5 (532)") {
|
||||
t.Fatalf("missing rating count")
|
||||
}
|
||||
if !strings.Contains(output, "Open now") {
|
||||
t.Fatalf("missing open now")
|
||||
}
|
||||
if !strings.Contains(output, "Status: OPERATIONAL") {
|
||||
t.Fatalf("missing status")
|
||||
}
|
||||
if !strings.Contains(output, "next") {
|
||||
t.Fatalf("missing next page token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSearchRatingCountOnly(t *testing.T) {
|
||||
ratingCount := 12
|
||||
response := goplaces.SearchResponse{
|
||||
Results: []goplaces.PlaceSummary{
|
||||
{
|
||||
PlaceID: "abc",
|
||||
Name: "Cafe",
|
||||
UserRatingCount: &ratingCount,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
output := renderSearch(NewColor(false), response)
|
||||
if !strings.Contains(output, "12 ratings") {
|
||||
t.Fatalf("missing rating count-only output: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSearchEmpty(t *testing.T) {
|
||||
output := renderSearch(NewColor(false), goplaces.SearchResponse{})
|
||||
if !strings.Contains(output, "No results") {
|
||||
@ -131,6 +158,55 @@ func TestRenderRouteEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDirections(t *testing.T) {
|
||||
response := goplaces.DirectionsResponse{
|
||||
Mode: "WALKING",
|
||||
StartAddress: "Start",
|
||||
EndAddress: "End",
|
||||
DistanceText: "1 km",
|
||||
DurationText: "10 mins",
|
||||
Steps: []goplaces.DirectionsStep{
|
||||
{Instruction: "Head north", DistanceText: "0.2 km", DurationText: "2 mins"},
|
||||
},
|
||||
}
|
||||
output := renderDirections(NewColor(false), response, true)
|
||||
if !strings.Contains(output, "Directions") {
|
||||
t.Fatalf("missing directions header")
|
||||
}
|
||||
if !strings.Contains(output, "Head north") {
|
||||
t.Fatalf("missing step")
|
||||
}
|
||||
if !strings.Contains(output, "Distance") {
|
||||
t.Fatalf("missing distance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDirectionsWarningsAndEmptySteps(t *testing.T) {
|
||||
response := goplaces.DirectionsResponse{
|
||||
StartAddress: "Start",
|
||||
EndAddress: "End",
|
||||
Warnings: []string{"", "Use caution"},
|
||||
}
|
||||
|
||||
output := renderDirections(NewColor(false), response, true)
|
||||
if !strings.Contains(output, "Warnings:") {
|
||||
t.Fatalf("missing warnings header: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "Use caution") {
|
||||
t.Fatalf("missing warning entry: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "No results.") {
|
||||
t.Fatalf("missing empty steps message: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionsStepLineFallback(t *testing.T) {
|
||||
line := directionsStepLine(goplaces.DirectionsStep{})
|
||||
if line != "(no instruction)" {
|
||||
t.Fatalf("unexpected step line: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTitleFallback(t *testing.T) {
|
||||
title := formatTitle(NewColor(false), "", "")
|
||||
if !strings.Contains(title, "(no name)") {
|
||||
@ -154,16 +230,17 @@ func TestRenderDetailsAndResolve(t *testing.T) {
|
||||
open := false
|
||||
level := 0
|
||||
details := goplaces.PlaceDetails{
|
||||
PlaceID: "place-1",
|
||||
Name: "Park",
|
||||
Address: "Central",
|
||||
Rating: floatPtr(4.2),
|
||||
PriceLevel: &level,
|
||||
Types: []string{"park"},
|
||||
Phone: "+1 555",
|
||||
Website: "https://example.com",
|
||||
Hours: []string{"Mon: 9-5"},
|
||||
OpenNow: &open,
|
||||
PlaceID: "place-1",
|
||||
Name: "Park",
|
||||
Address: "Central",
|
||||
Rating: floatPtr(4.2),
|
||||
PriceLevel: &level,
|
||||
Types: []string{"park"},
|
||||
Phone: "+1 555",
|
||||
Website: "https://example.com",
|
||||
Hours: []string{"Mon: 9-5"},
|
||||
OpenNow: &open,
|
||||
BusinessStatus: "CLOSED_TEMPORARILY",
|
||||
Photos: []goplaces.Photo{
|
||||
{Name: "places/place-1/photos/photo-1", WidthPx: 1200, HeightPx: 800},
|
||||
},
|
||||
@ -186,6 +263,9 @@ func TestRenderDetailsAndResolve(t *testing.T) {
|
||||
if !strings.Contains(output, "Reviews:") || !strings.Contains(output, "Alice") {
|
||||
t.Fatalf("missing reviews output: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "Status: CLOSED_TEMPORARILY") {
|
||||
t.Fatalf("missing status output: %s", output)
|
||||
}
|
||||
|
||||
resolve := goplaces.LocationResolveResponse{
|
||||
Results: []goplaces.ResolvedLocation{{PlaceID: "loc-1", Name: "Downtown"}},
|
||||
|
||||
@ -11,6 +11,7 @@ type Root struct {
|
||||
Nearby NearbyCmd `cmd:"" help:"Search nearby places by location."`
|
||||
Search SearchCmd `cmd:"" help:"Search places by text query."`
|
||||
Route RouteCmd `cmd:"" help:"Search places along a route."`
|
||||
Directions DirectionsCmd `cmd:"" help:"Get directions and travel time between two points."`
|
||||
Details DetailsCmd `cmd:"" help:"Fetch place details by place ID."`
|
||||
Photo PhotoCmd `cmd:"" help:"Fetch a photo URL by photo name."`
|
||||
Resolve ResolveCmd `cmd:"" help:"Resolve a location string to candidate places."`
|
||||
@ -18,14 +19,15 @@ type Root struct {
|
||||
|
||||
// GlobalOptions are flags shared by all commands.
|
||||
type GlobalOptions struct {
|
||||
APIKey string `help:"Google Places API key." env:"GOOGLE_PLACES_API_KEY"`
|
||||
BaseURL string `help:"Places API base URL." env:"GOOGLE_PLACES_BASE_URL" default:"https://places.googleapis.com/v1"`
|
||||
RoutesBaseURL string `help:"Routes API base URL." env:"GOOGLE_ROUTES_BASE_URL" default:"https://routes.googleapis.com"`
|
||||
Timeout time.Duration `help:"HTTP timeout." default:"10s"`
|
||||
JSON bool `help:"Output JSON."`
|
||||
NoColor bool `help:"Disable color output."`
|
||||
Verbose bool `help:"Verbose logging."`
|
||||
Version VersionFlag `name:"version" help:"Print version and exit."`
|
||||
APIKey string `help:"Google Places API key." env:"GOOGLE_PLACES_API_KEY"`
|
||||
BaseURL string `help:"Places API base URL." env:"GOOGLE_PLACES_BASE_URL" default:"https://places.googleapis.com/v1"`
|
||||
RoutesBaseURL string `help:"Routes API base URL." env:"GOOGLE_ROUTES_BASE_URL" default:"https://routes.googleapis.com"`
|
||||
DirectionsBaseURL string `help:"Directions Routes API base URL." env:"GOOGLE_DIRECTIONS_BASE_URL" default:"https://routes.googleapis.com"`
|
||||
Timeout time.Duration `help:"HTTP timeout." default:"10s"`
|
||||
JSON bool `help:"Output JSON."`
|
||||
NoColor bool `help:"Disable color output."`
|
||||
Verbose bool `help:"Verbose logging."`
|
||||
Version VersionFlag `name:"version" help:"Print version and exit."`
|
||||
}
|
||||
|
||||
// SearchCmd runs text search queries.
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
|
||||
"github.com/steipete/goplaces"
|
||||
)
|
||||
|
||||
@ -22,7 +23,7 @@ type App struct {
|
||||
}
|
||||
|
||||
// Run executes the CLI with the provided arguments.
|
||||
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
func Run(args []string, stdout, stderr io.Writer) int {
|
||||
if stdout == nil {
|
||||
stdout = os.Stdout
|
||||
}
|
||||
@ -69,10 +70,11 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
}
|
||||
|
||||
client := goplaces.NewClient(goplaces.Options{
|
||||
APIKey: root.Global.APIKey,
|
||||
BaseURL: root.Global.BaseURL,
|
||||
RoutesBaseURL: root.Global.RoutesBaseURL,
|
||||
Timeout: root.Global.Timeout,
|
||||
APIKey: root.Global.APIKey,
|
||||
BaseURL: root.Global.BaseURL,
|
||||
RoutesBaseURL: root.Global.RoutesBaseURL,
|
||||
DirectionsBaseURL: root.Global.DirectionsBaseURL,
|
||||
Timeout: root.Global.Timeout,
|
||||
})
|
||||
|
||||
app := &App{
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
120
internal/places/autocomplete_test.go
Normal file
120
internal/places/autocomplete_test.go
Normal file
@ -0,0 +1,120 @@
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAutocompleteSuccess(t *testing.T) {
|
||||
var gotRequest map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/places:autocomplete" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Goog-FieldMask") != autocompleteFieldMask {
|
||||
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &gotRequest); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"suggestions": [
|
||||
{
|
||||
"placePrediction": {
|
||||
"placeId": "place-1",
|
||||
"text": {"text": "Coffee Bar"},
|
||||
"structuredFormat": {
|
||||
"mainText": {"text": "Coffee"},
|
||||
"secondaryText": {"text": "Seattle"}
|
||||
},
|
||||
"types": ["cafe"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"queryPrediction": {
|
||||
"text": {"text": "coffee beans"},
|
||||
"structuredFormat": {
|
||||
"mainText": {"text": "coffee beans"},
|
||||
"secondaryText": {"text": "query"}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
|
||||
response, err := client.Autocomplete(context.Background(), AutocompleteRequest{
|
||||
Input: "cof",
|
||||
Limit: 5,
|
||||
SessionToken: "session",
|
||||
Language: "en",
|
||||
Region: "US",
|
||||
LocationBias: &LocationBias{Lat: 1.1, Lng: 2.2, RadiusM: 100},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("autocomplete error: %v", err)
|
||||
}
|
||||
if len(response.Suggestions) != 2 {
|
||||
t.Fatalf("expected 2 suggestions, got %d", len(response.Suggestions))
|
||||
}
|
||||
if response.Suggestions[0].Kind != "place" || response.Suggestions[0].PlaceID != "place-1" {
|
||||
t.Fatalf("unexpected place suggestion: %#v", response.Suggestions[0])
|
||||
}
|
||||
if response.Suggestions[1].Kind != "query" || response.Suggestions[1].Text != "coffee beans" {
|
||||
t.Fatalf("unexpected query suggestion: %#v", response.Suggestions[1])
|
||||
}
|
||||
|
||||
if gotRequest["input"] != "cof" {
|
||||
t.Fatalf("unexpected input: %#v", gotRequest["input"])
|
||||
}
|
||||
if gotRequest["sessionToken"] != "session" {
|
||||
t.Fatalf("unexpected session token: %#v", gotRequest["sessionToken"])
|
||||
}
|
||||
if gotRequest["languageCode"] != "en" {
|
||||
t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"])
|
||||
}
|
||||
if gotRequest["regionCode"] != "US" {
|
||||
t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"])
|
||||
}
|
||||
locationBias := gotRequest["locationBias"].(map[string]any)
|
||||
if locationBias["circle"] == nil {
|
||||
t.Fatalf("missing location bias circle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutocompleteLimitTrims(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{
|
||||
"suggestions": [
|
||||
{"queryPrediction": {"text": {"text": "a"}}},
|
||||
{"queryPrediction": {"text": {"text": "b"}}}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL})
|
||||
response, err := client.Autocomplete(context.Background(), AutocompleteRequest{
|
||||
Input: "cof",
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("autocomplete error: %v", err)
|
||||
}
|
||||
if len(response.Suggestions) != 1 {
|
||||
t.Fatalf("expected 1 suggestion, got %d", len(response.Suggestions))
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
// Package goplaces provides a Go client for the Google Places API (New).
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@ -19,19 +19,21 @@ const DefaultBaseURL = "https://places.googleapis.com/v1"
|
||||
|
||||
// Client wraps access to the Google Places API.
|
||||
type Client struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
routesBaseURL string
|
||||
httpClient *http.Client
|
||||
apiKey string
|
||||
baseURL string
|
||||
routesBaseURL string
|
||||
directionsBaseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Options configures the Places client.
|
||||
type Options struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
RoutesBaseURL string
|
||||
HTTPClient *http.Client
|
||||
Timeout time.Duration
|
||||
APIKey string
|
||||
BaseURL string
|
||||
RoutesBaseURL string
|
||||
DirectionsBaseURL string
|
||||
HTTPClient *http.Client
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewClient builds a client with sane defaults.
|
||||
@ -44,6 +46,10 @@ func NewClient(opts Options) *Client {
|
||||
if routesBaseURL == "" {
|
||||
routesBaseURL = defaultRoutesBaseURL
|
||||
}
|
||||
directionsBaseURL := strings.TrimRight(opts.DirectionsBaseURL, "/")
|
||||
if directionsBaseURL == "" {
|
||||
directionsBaseURL = defaultDirectionsBaseURL
|
||||
}
|
||||
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
@ -55,10 +61,11 @@ func NewClient(opts Options) *Client {
|
||||
}
|
||||
|
||||
return &Client{
|
||||
apiKey: opts.APIKey,
|
||||
baseURL: baseURL,
|
||||
routesBaseURL: routesBaseURL,
|
||||
httpClient: client,
|
||||
apiKey: opts.APIKey,
|
||||
baseURL: baseURL,
|
||||
routesBaseURL: routesBaseURL,
|
||||
directionsBaseURL: directionsBaseURL,
|
||||
httpClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
137
internal/places/client_options_test.go
Normal file
137
internal/places/client_options_test.go
Normal file
@ -0,0 +1,137 @@
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMissingAPIKey(t *testing.T) {
|
||||
client := NewClient(Options{})
|
||||
_, err := client.Search(context.Background(), SearchRequest{Query: "coffee"})
|
||||
if !errors.Is(err, ErrMissingAPIKey) {
|
||||
t.Fatalf("expected missing api key error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrors(t *testing.T) {
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: "http://example.com"})
|
||||
|
||||
_, err := client.Search(context.Background(), SearchRequest{Query: ""})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
|
||||
minRating := 9.0
|
||||
_, err = client.Search(context.Background(), SearchRequest{Query: "coffee", Filters: &Filters{MinRating: &minRating}})
|
||||
if err == nil {
|
||||
t.Fatalf("expected rating error")
|
||||
}
|
||||
|
||||
_, err = client.Search(context.Background(), SearchRequest{Query: "coffee", Limit: 42})
|
||||
if err == nil {
|
||||
t.Fatalf("expected limit error")
|
||||
}
|
||||
|
||||
_, err = client.Search(context.Background(), SearchRequest{Query: "coffee", Filters: &Filters{PriceLevels: []int{9}}})
|
||||
if err == nil {
|
||||
t.Fatalf("expected price level error")
|
||||
}
|
||||
|
||||
_, err = client.Search(context.Background(), SearchRequest{Query: "coffee", LocationBias: &LocationBias{Lat: 200, Lng: 0, RadiusM: 1}})
|
||||
if err == nil {
|
||||
t.Fatalf("expected location error")
|
||||
}
|
||||
|
||||
_, err = client.Resolve(context.Background(), LocationResolveRequest{LocationText: ""})
|
||||
if err == nil {
|
||||
t.Fatalf("expected resolve error")
|
||||
}
|
||||
|
||||
_, err = client.Resolve(context.Background(), LocationResolveRequest{LocationText: "x", Limit: 99})
|
||||
if err == nil {
|
||||
t.Fatalf("expected resolve limit error")
|
||||
}
|
||||
|
||||
_, err = client.Autocomplete(context.Background(), AutocompleteRequest{Input: ""})
|
||||
if err == nil {
|
||||
t.Fatalf("expected autocomplete input error")
|
||||
}
|
||||
|
||||
_, err = client.Autocomplete(context.Background(), AutocompleteRequest{Input: "x", Limit: 99})
|
||||
if err == nil {
|
||||
t.Fatalf("expected autocomplete limit error")
|
||||
}
|
||||
|
||||
_, err = client.NearbySearch(context.Background(), NearbySearchRequest{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected nearby location error")
|
||||
}
|
||||
|
||||
_, err = client.NearbySearch(context.Background(), NearbySearchRequest{
|
||||
LocationRestriction: &LocationBias{Lat: 1, Lng: 2, RadiusM: 3},
|
||||
Limit: 99,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected nearby limit error")
|
||||
}
|
||||
|
||||
_, err = client.PhotoMedia(context.Background(), PhotoMediaRequest{Name: ""})
|
||||
if err == nil {
|
||||
t.Fatalf("expected photo media name error")
|
||||
}
|
||||
|
||||
_, err = client.Details(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected details error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientDefaults(t *testing.T) {
|
||||
client := NewClient(Options{APIKey: "test-key"})
|
||||
if client.baseURL != DefaultBaseURL {
|
||||
t.Fatalf("unexpected baseURL: %s", client.baseURL)
|
||||
}
|
||||
if client.routesBaseURL != defaultRoutesBaseURL {
|
||||
t.Fatalf("unexpected routesBaseURL: %s", client.routesBaseURL)
|
||||
}
|
||||
if client.directionsBaseURL != defaultDirectionsBaseURL {
|
||||
t.Fatalf("unexpected directionsBaseURL: %s", client.directionsBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientCustomDirectionsBaseURL(t *testing.T) {
|
||||
client := NewClient(Options{
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://example.com/v1/",
|
||||
RoutesBaseURL: "https://routes.example.com/",
|
||||
DirectionsBaseURL: "https://maps.example.com/directions/",
|
||||
})
|
||||
if client.baseURL != "https://example.com/v1" {
|
||||
t.Fatalf("unexpected baseURL: %s", client.baseURL)
|
||||
}
|
||||
if client.routesBaseURL != "https://routes.example.com" {
|
||||
t.Fatalf("unexpected routesBaseURL: %s", client.routesBaseURL)
|
||||
}
|
||||
if client.directionsBaseURL != "https://maps.example.com/directions" {
|
||||
t.Fatalf("unexpected directionsBaseURL: %s", client.directionsBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappingHelpers(t *testing.T) {
|
||||
if mapLatLng(nil) != nil {
|
||||
t.Fatalf("expected nil location")
|
||||
}
|
||||
if displayName(nil) != "" {
|
||||
t.Fatalf("expected empty display name")
|
||||
}
|
||||
if openNow(nil) != nil {
|
||||
t.Fatalf("expected nil open now")
|
||||
}
|
||||
if weekdayDescriptions(nil) != nil {
|
||||
t.Fatalf("expected nil hours")
|
||||
}
|
||||
if mapPriceLevel("UNKNOWN") != nil {
|
||||
t.Fatalf("expected nil price level")
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
detailsFieldMaskBase = "id,displayName,formattedAddress,location,rating,priceLevel,types,regularOpeningHours,currentOpeningHours,nationalPhoneNumber,websiteUri"
|
||||
detailsFieldMaskBase = "id,displayName,formattedAddress,location,rating,userRatingCount,priceLevel,types,regularOpeningHours,currentOpeningHours,businessStatus,nationalPhoneNumber,websiteUri"
|
||||
detailsFieldMaskReview = "reviews"
|
||||
detailsFieldMaskPhotos = "photos"
|
||||
)
|
||||
@ -61,18 +61,20 @@ func detailsFieldMaskForRequest(req DetailsRequest) string {
|
||||
|
||||
func mapPlaceDetails(place placeItem) PlaceDetails {
|
||||
return PlaceDetails{
|
||||
PlaceID: place.ID,
|
||||
Name: displayName(place.DisplayName),
|
||||
Address: place.FormattedAddress,
|
||||
Location: mapLatLng(place.Location),
|
||||
Rating: place.Rating,
|
||||
PriceLevel: mapPriceLevel(place.PriceLevel),
|
||||
Types: place.Types,
|
||||
Phone: place.NationalPhoneNumber,
|
||||
Website: place.WebsiteURI,
|
||||
Hours: weekdayDescriptions(place.RegularOpeningHours),
|
||||
OpenNow: openNow(place.CurrentOpeningHours),
|
||||
Reviews: mapReviews(place.Reviews),
|
||||
Photos: mapPhotos(place.Photos),
|
||||
PlaceID: place.ID,
|
||||
Name: displayName(place.DisplayName),
|
||||
Address: place.FormattedAddress,
|
||||
Location: mapLatLng(place.Location),
|
||||
Rating: place.Rating,
|
||||
UserRatingCount: place.UserRatingCount,
|
||||
PriceLevel: mapPriceLevel(place.PriceLevel),
|
||||
Types: place.Types,
|
||||
Phone: place.NationalPhoneNumber,
|
||||
Website: place.WebsiteURI,
|
||||
Hours: weekdayDescriptions(place.RegularOpeningHours),
|
||||
OpenNow: openNow(place.CurrentOpeningHours),
|
||||
BusinessStatus: strings.TrimSpace(place.BusinessStatus),
|
||||
Reviews: mapReviews(place.Reviews),
|
||||
Photos: mapPhotos(place.Photos),
|
||||
}
|
||||
}
|
||||
173
internal/places/details_test.go
Normal file
173
internal/places/details_test.go
Normal file
@ -0,0 +1,173 @@
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetailsSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/places/place-123" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("languageCode") != "en" {
|
||||
t.Fatalf("unexpected languageCode: %s", r.URL.Query().Get("languageCode"))
|
||||
}
|
||||
if r.URL.Query().Get("regionCode") != "US" {
|
||||
t.Fatalf("unexpected regionCode: %s", r.URL.Query().Get("regionCode"))
|
||||
}
|
||||
if r.Header.Get("X-Goog-FieldMask") != detailsFieldMaskBase {
|
||||
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"id": "place-123",
|
||||
"displayName": {"text": "Park"},
|
||||
"formattedAddress": "Central",
|
||||
"location": {"latitude": 10, "longitude": 20},
|
||||
"rating": 4.2,
|
||||
"userRatingCount": 1234,
|
||||
"priceLevel": "PRICE_LEVEL_FREE",
|
||||
"types": ["park"],
|
||||
"regularOpeningHours": {"weekdayDescriptions": ["Mon: 9-5"]},
|
||||
"currentOpeningHours": {"openNow": false},
|
||||
"businessStatus": "CLOSED_TEMPORARILY",
|
||||
"nationalPhoneNumber": "+1 555",
|
||||
"websiteUri": "https://example.com"
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
|
||||
place, err := client.DetailsWithOptions(context.Background(), DetailsRequest{
|
||||
PlaceID: "place-123",
|
||||
Language: "en",
|
||||
Region: "US",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("details error: %v", err)
|
||||
}
|
||||
if place.PlaceID != "place-123" {
|
||||
t.Fatalf("unexpected id: %s", place.PlaceID)
|
||||
}
|
||||
if place.UserRatingCount == nil || *place.UserRatingCount != 1234 {
|
||||
t.Fatalf("unexpected user rating count: %#v", place.UserRatingCount)
|
||||
}
|
||||
if place.OpenNow == nil || *place.OpenNow != false {
|
||||
t.Fatalf("unexpected openNow")
|
||||
}
|
||||
if place.BusinessStatus != "CLOSED_TEMPORARILY" {
|
||||
t.Fatalf("unexpected business status: %s", place.BusinessStatus)
|
||||
}
|
||||
if len(place.Hours) != 1 {
|
||||
t.Fatalf("unexpected hours")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailsWithReviews(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("X-Goog-FieldMask"), "reviews") {
|
||||
t.Fatalf("expected reviews in field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"id": "place-123",
|
||||
"reviews": [
|
||||
{
|
||||
"name": "places/place-123/reviews/1",
|
||||
"rating": 4.5,
|
||||
"text": {"text": "Great coffee", "languageCode": "en"},
|
||||
"authorAttribution": {"displayName": "Alice", "uri": "https://example.com"},
|
||||
"relativePublishTimeDescription": "2 weeks ago",
|
||||
"publishTime": "2024-01-01T00:00:00Z",
|
||||
"visitDate": {"year": 2024, "month": 1, "day": 2}
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
|
||||
details, err := client.DetailsWithOptions(context.Background(), DetailsRequest{
|
||||
PlaceID: "place-123",
|
||||
IncludeReviews: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("details error: %v", err)
|
||||
}
|
||||
if len(details.Reviews) != 1 {
|
||||
t.Fatalf("expected 1 review")
|
||||
}
|
||||
review := details.Reviews[0]
|
||||
if review.Author == nil || review.Author.DisplayName != "Alice" {
|
||||
t.Fatalf("unexpected author: %#v", review.Author)
|
||||
}
|
||||
if review.Text == nil || review.Text.Text != "Great coffee" {
|
||||
t.Fatalf("unexpected text: %#v", review.Text)
|
||||
}
|
||||
if review.VisitDate == nil || review.VisitDate.Year != 2024 {
|
||||
t.Fatalf("unexpected visit date: %#v", review.VisitDate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailsWithPhotos(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("X-Goog-FieldMask"), "photos") {
|
||||
t.Fatalf("expected photos in field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"id": "place-123",
|
||||
"photos": [
|
||||
{
|
||||
"name": "places/place-123/photos/photo-1",
|
||||
"widthPx": 1200,
|
||||
"heightPx": 800,
|
||||
"authorAttributions": [{"displayName": "Alice", "uri": "https://example.com"}]
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
|
||||
details, err := client.DetailsWithOptions(context.Background(), DetailsRequest{
|
||||
PlaceID: "place-123",
|
||||
IncludePhotos: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("details error: %v", err)
|
||||
}
|
||||
if len(details.Photos) != 1 {
|
||||
t.Fatalf("expected 1 photo")
|
||||
}
|
||||
photo := details.Photos[0]
|
||||
if photo.Name == "" || photo.WidthPx != 1200 {
|
||||
t.Fatalf("unexpected photo: %#v", photo)
|
||||
}
|
||||
if len(photo.AuthorAttributions) != 1 {
|
||||
t.Fatalf("unexpected photo authors: %#v", photo.AuthorAttributions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailsFieldMaskForRequest(t *testing.T) {
|
||||
req := DetailsRequest{}
|
||||
if got := detailsFieldMaskForRequest(req); got != detailsFieldMaskBase {
|
||||
t.Fatalf("unexpected field mask: %s", got)
|
||||
}
|
||||
req.IncludeReviews = true
|
||||
got := detailsFieldMaskForRequest(req)
|
||||
if !strings.Contains(got, "reviews") {
|
||||
t.Fatalf("expected reviews in field mask: %s", got)
|
||||
}
|
||||
req = DetailsRequest{IncludePhotos: true}
|
||||
got = detailsFieldMaskForRequest(req)
|
||||
if !strings.Contains(got, "photos") {
|
||||
t.Fatalf("expected photos in field mask: %s", got)
|
||||
}
|
||||
req = DetailsRequest{IncludeReviews: true, IncludePhotos: true}
|
||||
got = detailsFieldMaskForRequest(req)
|
||||
if !strings.Contains(got, "reviews") || !strings.Contains(got, "photos") {
|
||||
t.Fatalf("expected reviews and photos in field mask: %s", got)
|
||||
}
|
||||
}
|
||||
359
internal/places/directions.go
Normal file
359
internal/places/directions.go
Normal file
@ -0,0 +1,359 @@
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDirectionsBaseURL = defaultRoutesBaseURL
|
||||
)
|
||||
|
||||
const directionsFieldMask = "routes.description,routes.warnings,routes.legs.distanceMeters,routes.legs.duration,routes.legs.localizedValues.distance,routes.legs.localizedValues.duration,routes.legs.steps.distanceMeters,routes.legs.steps.staticDuration,routes.legs.steps.localizedValues.distance,routes.legs.steps.localizedValues.staticDuration,routes.legs.steps.navigationInstruction.instructions,routes.legs.steps.navigationInstruction.maneuver,routes.legs.steps.travelMode"
|
||||
|
||||
const (
|
||||
directionsModeWalk = "walking"
|
||||
directionsModeDrive = "driving"
|
||||
directionsModeBicycle = "bicycling"
|
||||
directionsModeTransit = "transit"
|
||||
)
|
||||
|
||||
const (
|
||||
directionsUnitsMetric = "metric"
|
||||
directionsUnitsImperial = "imperial"
|
||||
)
|
||||
|
||||
const (
|
||||
routesUnitsMetric = "METRIC"
|
||||
routesUnitsImperial = "IMPERIAL"
|
||||
)
|
||||
|
||||
var directionsUnits = map[string]struct{}{
|
||||
directionsUnitsMetric: {},
|
||||
directionsUnitsImperial: {},
|
||||
}
|
||||
|
||||
// DirectionsRequest describes a directions query between two locations.
|
||||
type DirectionsRequest struct {
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
FromPlaceID string `json:"from_place_id,omitempty"`
|
||||
ToPlaceID string `json:"to_place_id,omitempty"`
|
||||
FromLocation *LatLng `json:"from_location,omitempty"`
|
||||
ToLocation *LatLng `json:"to_location,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Units string `json:"units,omitempty"`
|
||||
AvoidTolls bool `json:"avoid_tolls,omitempty"`
|
||||
AvoidHighways bool `json:"avoid_highways,omitempty"`
|
||||
AvoidFerries bool `json:"avoid_ferries,omitempty"`
|
||||
}
|
||||
|
||||
// DirectionsResponse contains a single route summary and steps.
|
||||
type DirectionsResponse struct {
|
||||
Mode string `json:"mode"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
StartAddress string `json:"start_address,omitempty"`
|
||||
EndAddress string `json:"end_address,omitempty"`
|
||||
DistanceText string `json:"distance_text,omitempty"`
|
||||
DistanceMeters int `json:"distance_meters,omitempty"`
|
||||
DurationText string `json:"duration_text,omitempty"`
|
||||
DurationSeconds int `json:"duration_seconds,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Steps []DirectionsStep `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// DirectionsStep is a single navigation step.
|
||||
type DirectionsStep struct {
|
||||
Instruction string `json:"instruction,omitempty"`
|
||||
DistanceText string `json:"distance_text,omitempty"`
|
||||
DistanceMeters int `json:"distance_meters,omitempty"`
|
||||
DurationText string `json:"duration_text,omitempty"`
|
||||
DurationSeconds int `json:"duration_seconds,omitempty"`
|
||||
TravelMode string `json:"travel_mode,omitempty"`
|
||||
Maneuver string `json:"maneuver,omitempty"`
|
||||
}
|
||||
|
||||
// Directions fetches directions between two locations using the Routes API.
|
||||
func (c *Client) Directions(ctx context.Context, req DirectionsRequest) (DirectionsResponse, error) {
|
||||
req = applyDirectionsDefaults(req)
|
||||
if err := validateDirectionsRequest(req); err != nil {
|
||||
return DirectionsResponse{}, err
|
||||
}
|
||||
|
||||
body := buildDirectionsBody(req)
|
||||
endpoint := directionsEndpoint(c.directionsBaseURL)
|
||||
payload, err := c.doRequest(ctx, http.MethodPost, endpoint, body, directionsFieldMask)
|
||||
if err != nil {
|
||||
return DirectionsResponse{}, err
|
||||
}
|
||||
|
||||
var apiResponse directionsRoutesResponse
|
||||
if err := json.Unmarshal(payload, &apiResponse); err != nil {
|
||||
return DirectionsResponse{}, fmt.Errorf("goplaces: decode directions response: %w", err)
|
||||
}
|
||||
if len(apiResponse.Routes) == 0 || len(apiResponse.Routes[0].Legs) == 0 {
|
||||
return DirectionsResponse{}, errors.New("goplaces: no directions returned")
|
||||
}
|
||||
|
||||
route := apiResponse.Routes[0]
|
||||
leg := route.Legs[0]
|
||||
steps := make([]DirectionsStep, 0, len(leg.Steps))
|
||||
for _, step := range leg.Steps {
|
||||
steps = append(steps, DirectionsStep{
|
||||
Instruction: strings.TrimSpace(step.NavigationInstruction.Instructions),
|
||||
DistanceText: strings.TrimSpace(step.LocalizedValues.Distance.Text),
|
||||
DistanceMeters: step.DistanceMeters,
|
||||
DurationText: strings.TrimSpace(step.LocalizedValues.StaticDuration.Text),
|
||||
DurationSeconds: parseDurationSeconds(step.StaticDuration),
|
||||
TravelMode: strings.TrimSpace(step.TravelMode),
|
||||
Maneuver: strings.TrimSpace(step.NavigationInstruction.Maneuver),
|
||||
})
|
||||
}
|
||||
|
||||
return DirectionsResponse{
|
||||
Mode: strings.ToUpper(req.Mode),
|
||||
Summary: strings.TrimSpace(route.Description),
|
||||
StartAddress: directionsLocationLabel(req.FromPlaceID, req.FromLocation, req.From),
|
||||
EndAddress: directionsLocationLabel(req.ToPlaceID, req.ToLocation, req.To),
|
||||
DistanceText: strings.TrimSpace(leg.LocalizedValues.Distance.Text),
|
||||
DistanceMeters: leg.DistanceMeters,
|
||||
DurationText: strings.TrimSpace(leg.LocalizedValues.Duration.Text),
|
||||
DurationSeconds: parseDurationSeconds(leg.Duration),
|
||||
Warnings: route.Warnings,
|
||||
Steps: steps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyDirectionsDefaults(req DirectionsRequest) DirectionsRequest {
|
||||
req.From = strings.TrimSpace(req.From)
|
||||
req.To = strings.TrimSpace(req.To)
|
||||
req.FromPlaceID = strings.TrimSpace(req.FromPlaceID)
|
||||
req.ToPlaceID = strings.TrimSpace(req.ToPlaceID)
|
||||
req.Mode = strings.ToLower(strings.TrimSpace(req.Mode))
|
||||
if req.Mode == "" {
|
||||
req.Mode = directionsModeWalk
|
||||
}
|
||||
if normalized := normalizeDirectionsMode(req.Mode); normalized != "" {
|
||||
req.Mode = normalized
|
||||
}
|
||||
if req.Units != "" {
|
||||
req.Units = strings.ToLower(strings.TrimSpace(req.Units))
|
||||
}
|
||||
if req.Units == "" {
|
||||
req.Units = directionsUnitsMetric
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func validateDirectionsRequest(req DirectionsRequest) error {
|
||||
if normalizeDirectionsMode(req.Mode) == "" {
|
||||
return ValidationError{Field: "mode", Message: "must be walk, drive, bicycle, or transit"}
|
||||
}
|
||||
if err := validateDirectionsLocation("from", req.FromPlaceID, req.FromLocation, req.From); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDirectionsLocation("to", req.ToPlaceID, req.ToLocation, req.To); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Units != "" {
|
||||
if _, ok := directionsUnits[req.Units]; !ok {
|
||||
return ValidationError{Field: "units", Message: "must be metric or imperial"}
|
||||
}
|
||||
}
|
||||
if (req.AvoidTolls || req.AvoidHighways || req.AvoidFerries) && req.Mode != directionsModeDrive {
|
||||
return ValidationError{Field: "route_modifiers", Message: "avoid tolls/highways/ferries require drive mode"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDirectionsLocation(label, placeID string, location *LatLng, text string) error {
|
||||
provided := 0
|
||||
if strings.TrimSpace(placeID) != "" {
|
||||
provided++
|
||||
}
|
||||
if location != nil {
|
||||
provided++
|
||||
if location.Lat < -90 || location.Lat > 90 {
|
||||
return ValidationError{Field: label + ".lat", Message: "must be -90..90"}
|
||||
}
|
||||
if location.Lng < -180 || location.Lng > 180 {
|
||||
return ValidationError{Field: label + ".lng", Message: "must be -180..180"}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(text) != "" {
|
||||
provided++
|
||||
}
|
||||
if provided == 0 {
|
||||
return ValidationError{Field: label, Message: "required"}
|
||||
}
|
||||
if provided > 1 {
|
||||
return ValidationError{Field: label, Message: "use only one of text, place_id, or lat/lng"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDirectionsLocation(label, placeID string, location *LatLng, text string) (string, error) {
|
||||
if err := validateDirectionsLocation(label, placeID, location, text); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(placeID) != "" {
|
||||
return "place_id:" + strings.TrimSpace(placeID), nil
|
||||
}
|
||||
if location != nil {
|
||||
return fmt.Sprintf("%.6f,%.6f", location.Lat, location.Lng), nil
|
||||
}
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
|
||||
func directionsLocationLabel(placeID string, location *LatLng, text string) string {
|
||||
label, err := resolveDirectionsLocation("location", placeID, location, text)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func normalizeDirectionsMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "walk", "walking":
|
||||
return directionsModeWalk
|
||||
case "drive", "driving":
|
||||
return directionsModeDrive
|
||||
case "bike", "bicycle", "bicycling":
|
||||
return directionsModeBicycle
|
||||
case "transit":
|
||||
return directionsModeTransit
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func directionsEndpoint(base string) string {
|
||||
if strings.HasSuffix(base, routesPath) {
|
||||
return base
|
||||
}
|
||||
return base + routesPath
|
||||
}
|
||||
|
||||
func directionsTravelMode(mode string) string {
|
||||
switch normalizeDirectionsMode(mode) {
|
||||
case directionsModeWalk:
|
||||
return travelModeWalk
|
||||
case directionsModeDrive:
|
||||
return travelModeDrive
|
||||
case directionsModeBicycle:
|
||||
return travelModeBicycle
|
||||
case directionsModeTransit:
|
||||
return travelModeTransit
|
||||
default:
|
||||
return travelModeWalk
|
||||
}
|
||||
}
|
||||
|
||||
func directionsRouteUnits(units string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(units)) {
|
||||
case directionsUnitsImperial:
|
||||
return routesUnitsImperial
|
||||
default:
|
||||
return routesUnitsMetric
|
||||
}
|
||||
}
|
||||
|
||||
func directionsWaypoint(placeID string, location *LatLng, text string) map[string]any {
|
||||
if trimmed := strings.TrimSpace(placeID); trimmed != "" {
|
||||
return map[string]any{"placeId": trimmed}
|
||||
}
|
||||
if location != nil {
|
||||
return map[string]any{
|
||||
"location": map[string]any{
|
||||
"latLng": map[string]any{
|
||||
"latitude": location.Lat,
|
||||
"longitude": location.Lng,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return map[string]any{"address": strings.TrimSpace(text)}
|
||||
}
|
||||
|
||||
func buildDirectionsBody(req DirectionsRequest) map[string]any {
|
||||
body := map[string]any{
|
||||
"origin": directionsWaypoint(req.FromPlaceID, req.FromLocation, req.From),
|
||||
"destination": directionsWaypoint(req.ToPlaceID, req.ToLocation, req.To),
|
||||
"travelMode": directionsTravelMode(req.Mode),
|
||||
"units": directionsRouteUnits(req.Units),
|
||||
}
|
||||
if strings.TrimSpace(req.Language) != "" {
|
||||
body["languageCode"] = strings.TrimSpace(req.Language)
|
||||
}
|
||||
if strings.TrimSpace(req.Region) != "" {
|
||||
body["regionCode"] = strings.TrimSpace(req.Region)
|
||||
}
|
||||
if req.AvoidTolls || req.AvoidHighways || req.AvoidFerries {
|
||||
body["routeModifiers"] = map[string]any{
|
||||
"avoidTolls": req.AvoidTolls,
|
||||
"avoidHighways": req.AvoidHighways,
|
||||
"avoidFerries": req.AvoidFerries,
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func parseDurationSeconds(duration string) int {
|
||||
parsed, err := time.ParseDuration(strings.TrimSpace(duration))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(parsed.Seconds())
|
||||
}
|
||||
|
||||
type directionsRoutesResponse struct {
|
||||
Routes []directionsRoutesRoute `json:"routes"`
|
||||
}
|
||||
|
||||
type directionsRoutesRoute struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Legs []directionsRoutesLeg `json:"legs"`
|
||||
}
|
||||
|
||||
type directionsRoutesLeg struct {
|
||||
DistanceMeters int `json:"distanceMeters"`
|
||||
Duration string `json:"duration"`
|
||||
LocalizedValues directionsLegLocalizedValues `json:"localizedValues"`
|
||||
Steps []directionsRoutesStep `json:"steps"`
|
||||
}
|
||||
|
||||
type directionsRoutesStep struct {
|
||||
DistanceMeters int `json:"distanceMeters"`
|
||||
StaticDuration string `json:"staticDuration"`
|
||||
TravelMode string `json:"travelMode,omitempty"`
|
||||
NavigationInstruction directionsNavigationInstruction `json:"navigationInstruction"`
|
||||
LocalizedValues directionsStepLocalizedValues `json:"localizedValues"`
|
||||
}
|
||||
|
||||
type directionsNavigationInstruction struct {
|
||||
Instructions string `json:"instructions,omitempty"`
|
||||
Maneuver string `json:"maneuver,omitempty"`
|
||||
}
|
||||
|
||||
type directionsLegLocalizedValues struct {
|
||||
Distance directionsLocalizedText `json:"distance"`
|
||||
Duration directionsLocalizedText `json:"duration"`
|
||||
}
|
||||
|
||||
type directionsStepLocalizedValues struct {
|
||||
Distance directionsLocalizedText `json:"distance"`
|
||||
StaticDuration directionsLocalizedText `json:"staticDuration"`
|
||||
}
|
||||
|
||||
type directionsLocalizedText struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
355
internal/places/directions_test.go
Normal file
355
internal/places/directions_test.go
Normal file
@ -0,0 +1,355 @@
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDirectionsRequestPlaceID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != routesPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Goog-Api-Key") != "test-key" {
|
||||
t.Fatalf("unexpected api key header: %s", r.Header.Get("X-Goog-Api-Key"))
|
||||
}
|
||||
if r.Header.Get("X-Goog-FieldMask") != directionsFieldMask {
|
||||
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
var payload map[string]any
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if payload["travelMode"] != travelModeWalk {
|
||||
t.Fatalf("unexpected travel mode: %#v", payload["travelMode"])
|
||||
}
|
||||
if payload["units"] != routesUnitsMetric {
|
||||
t.Fatalf("unexpected units: %#v", payload["units"])
|
||||
}
|
||||
origin, ok := payload["origin"].(map[string]any)
|
||||
if !ok || origin["placeId"] != "from" {
|
||||
t.Fatalf("unexpected origin payload: %#v", payload["origin"])
|
||||
}
|
||||
destination, ok := payload["destination"].(map[string]any)
|
||||
if !ok || destination["placeId"] != "to" {
|
||||
t.Fatalf("unexpected destination payload: %#v", payload["destination"])
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(`{
|
||||
"routes": [{
|
||||
"description": "Main",
|
||||
"warnings": ["test"],
|
||||
"legs": [{
|
||||
"distanceMeters": 1000,
|
||||
"duration": "600s",
|
||||
"localizedValues": {
|
||||
"distance": {"text": "1 km"},
|
||||
"duration": {"text": "10 mins"}
|
||||
},
|
||||
"steps": [{
|
||||
"distanceMeters": 200,
|
||||
"staticDuration": "120s",
|
||||
"localizedValues": {
|
||||
"distance": {"text": "0.2 km"},
|
||||
"staticDuration": {"text": "2 mins"}
|
||||
},
|
||||
"travelMode": "WALK",
|
||||
"navigationInstruction": {
|
||||
"instructions": "Head north",
|
||||
"maneuver": "TURN_LEFT"
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", DirectionsBaseURL: server.URL})
|
||||
response, err := client.Directions(context.Background(), DirectionsRequest{
|
||||
FromPlaceID: "from",
|
||||
ToPlaceID: "to",
|
||||
Mode: "walk",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Directions error: %v", err)
|
||||
}
|
||||
if response.DistanceMeters != 1000 {
|
||||
t.Fatalf("unexpected distance: %d", response.DistanceMeters)
|
||||
}
|
||||
if response.DurationSeconds != 600 {
|
||||
t.Fatalf("unexpected duration seconds: %d", response.DurationSeconds)
|
||||
}
|
||||
if response.StartAddress != "place_id:from" || response.EndAddress != "place_id:to" {
|
||||
t.Fatalf("unexpected start/end labels: %q -> %q", response.StartAddress, response.EndAddress)
|
||||
}
|
||||
if len(response.Steps) != 1 || response.Steps[0].Instruction != "Head north" {
|
||||
t.Fatalf("unexpected steps: %#v", response.Steps)
|
||||
}
|
||||
if response.Steps[0].Maneuver != "TURN_LEFT" {
|
||||
t.Fatalf("unexpected step maneuver: %#v", response.Steps[0])
|
||||
}
|
||||
if response.Mode != "WALKING" {
|
||||
t.Fatalf("unexpected mode: %s", response.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionsModeValidation(t *testing.T) {
|
||||
if normalizeDirectionsMode("plane") != "" {
|
||||
t.Fatalf("expected empty normalization")
|
||||
}
|
||||
req := DirectionsRequest{From: "A", To: "B", Mode: "plane"}
|
||||
if err := validateDirectionsRequest(applyDirectionsDefaults(req)); err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionsUnitsValidation(t *testing.T) {
|
||||
req := DirectionsRequest{From: "A", To: "B", Units: "fathoms"}
|
||||
if err := validateDirectionsRequest(applyDirectionsDefaults(req)); err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionsRouteModifiersRequireDrive(t *testing.T) {
|
||||
req := DirectionsRequest{From: "A", To: "B", Mode: "walk", AvoidTolls: true}
|
||||
err := validateDirectionsRequest(applyDirectionsDefaults(req))
|
||||
if err == nil || !strings.Contains(err.Error(), "route_modifiers") {
|
||||
t.Fatalf("expected route modifier validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionsLocationValidation(t *testing.T) {
|
||||
req := DirectionsRequest{FromPlaceID: "a", From: "b", To: "c"}
|
||||
if err := validateDirectionsRequest(applyDirectionsDefaults(req)); err == nil {
|
||||
t.Fatalf("expected validation error for multiple origin inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDirectionsModeAliases(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"walk": directionsModeWalk,
|
||||
"walking": directionsModeWalk,
|
||||
"drive": directionsModeDrive,
|
||||
"driving": directionsModeDrive,
|
||||
"bike": directionsModeBicycle,
|
||||
"bicycle": directionsModeBicycle,
|
||||
"bicycling": directionsModeBicycle,
|
||||
"transit": directionsModeTransit,
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := normalizeDirectionsMode(input); got != want {
|
||||
t.Fatalf("normalizeDirectionsMode(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirectionsLocationVariants(t *testing.T) {
|
||||
value, err := resolveDirectionsLocation("from", "pid", nil, "")
|
||||
if err != nil || value != "place_id:pid" {
|
||||
t.Fatalf("unexpected place id resolution: value=%q err=%v", value, err)
|
||||
}
|
||||
|
||||
value, err = resolveDirectionsLocation("to", "", &LatLng{Lat: 1.23, Lng: 4.56}, "")
|
||||
if err != nil || value != "1.230000,4.560000" {
|
||||
t.Fatalf("unexpected lat/lng resolution: value=%q err=%v", value, err)
|
||||
}
|
||||
|
||||
value, err = resolveDirectionsLocation("to", "", nil, " Seattle ")
|
||||
if err != nil || value != "Seattle" {
|
||||
t.Fatalf("unexpected text resolution: value=%q err=%v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionsEndpointCompatibility(t *testing.T) {
|
||||
if got := directionsEndpoint("https://routes.googleapis.com"); got != "https://routes.googleapis.com"+routesPath {
|
||||
t.Fatalf("unexpected endpoint: %s", got)
|
||||
}
|
||||
full := "https://routes.googleapis.com" + routesPath
|
||||
if got := directionsEndpoint(full); got != full {
|
||||
t.Fatalf("unexpected full endpoint handling: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationSeconds(t *testing.T) {
|
||||
if got := parseDurationSeconds("600s"); got != 600 {
|
||||
t.Fatalf("unexpected parsed duration: %d", got)
|
||||
}
|
||||
if got := parseDurationSeconds("not-a-duration"); got != 0 {
|
||||
t.Fatalf("unexpected parsed invalid duration: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionsHTTPErrorWithEmptyBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", DirectionsBaseURL: server.URL})
|
||||
_, err := client.Directions(context.Background(), DirectionsRequest{From: "A", To: "B"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got: %v", err)
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("unexpected status: %d", apiErr.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionsNoRoutesReturned(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"routes":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", DirectionsBaseURL: server.URL})
|
||||
_, err := client.Directions(context.Background(), DirectionsRequest{From: "A", To: "B"})
|
||||
if err == nil || !strings.Contains(err.Error(), "no directions returned") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionsEmptyBodySuccessStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", DirectionsBaseURL: server.URL})
|
||||
_, err := client.Directions(context.Background(), DirectionsRequest{From: "A", To: "B"})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty response") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionsRequestLocaleAndImperialUnits(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != routesPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
var payload map[string]any
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if payload["travelMode"] != travelModeDrive {
|
||||
t.Fatalf("unexpected mode: %#v", payload["travelMode"])
|
||||
}
|
||||
if payload["languageCode"] != "en-US" {
|
||||
t.Fatalf("unexpected language: %#v", payload["languageCode"])
|
||||
}
|
||||
if payload["regionCode"] != "US" {
|
||||
t.Fatalf("unexpected region: %#v", payload["regionCode"])
|
||||
}
|
||||
if payload["units"] != routesUnitsImperial {
|
||||
t.Fatalf("unexpected units: %#v", payload["units"])
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"routes":[{
|
||||
"legs":[{
|
||||
"distanceMeters":1609,
|
||||
"duration":"300s",
|
||||
"localizedValues":{"distance":{"text":"1 mi"},"duration":{"text":"5 mins"}},
|
||||
"steps":[]
|
||||
}]
|
||||
}]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", DirectionsBaseURL: server.URL})
|
||||
_, err := client.Directions(context.Background(), DirectionsRequest{
|
||||
From: "Seattle",
|
||||
To: "Portland",
|
||||
Mode: "drive",
|
||||
Language: "en-US",
|
||||
Region: "US",
|
||||
Units: "imperial",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Directions error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionsRequestRouteModifiers(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != routesPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
modifiers, ok := payload["routeModifiers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing routeModifiers: %#v", payload)
|
||||
}
|
||||
if modifiers["avoidTolls"] != true || modifiers["avoidHighways"] != true || modifiers["avoidFerries"] != true {
|
||||
t.Fatalf("unexpected routeModifiers: %#v", modifiers)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"routes":[{
|
||||
"legs":[{
|
||||
"distanceMeters":1609,
|
||||
"duration":"300s",
|
||||
"localizedValues":{"distance":{"text":"1 mi"},"duration":{"text":"5 mins"}},
|
||||
"steps":[]
|
||||
}]
|
||||
}]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", DirectionsBaseURL: server.URL})
|
||||
_, err := client.Directions(context.Background(), DirectionsRequest{
|
||||
From: "Seattle",
|
||||
To: "Portland",
|
||||
Mode: "drive",
|
||||
AvoidTolls: true,
|
||||
AvoidHighways: true,
|
||||
AvoidFerries: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Directions error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionsLocationBoundsValidation(t *testing.T) {
|
||||
req := DirectionsRequest{
|
||||
FromLocation: &LatLng{Lat: 91, Lng: 0},
|
||||
To: "B",
|
||||
}
|
||||
err := validateDirectionsRequest(applyDirectionsDefaults(req))
|
||||
if err == nil || !strings.Contains(err.Error(), "from.lat") {
|
||||
t.Fatalf("unexpected error for latitude: %v", err)
|
||||
}
|
||||
|
||||
req = DirectionsRequest{
|
||||
From: "A",
|
||||
ToLocation: &LatLng{Lat: 0, Lng: 181},
|
||||
}
|
||||
err = validateDirectionsRequest(applyDirectionsDefaults(req))
|
||||
if err == nil || !strings.Contains(err.Error(), "to.lng") {
|
||||
t.Fatalf("unexpected error for longitude: %v", err)
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
//go:build e2e
|
||||
// +build e2e
|
||||
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -148,6 +148,46 @@ func TestE2ENearbySearch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2EDirectionsAvoidTolls(t *testing.T) {
|
||||
apiKey := os.Getenv("GOOGLE_PLACES_API_KEY")
|
||||
if apiKey == "" {
|
||||
t.Skip("GOOGLE_PLACES_API_KEY not set")
|
||||
}
|
||||
|
||||
from := os.Getenv("GOOGLE_PLACES_E2E_DIRECTIONS_FROM")
|
||||
if from == "" {
|
||||
from = "Paris, France"
|
||||
}
|
||||
to := os.Getenv("GOOGLE_PLACES_E2E_DIRECTIONS_TO")
|
||||
if to == "" {
|
||||
to = "Brest, France"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := NewClient(Options{
|
||||
APIKey: apiKey,
|
||||
DirectionsBaseURL: os.Getenv("GOOGLE_DIRECTIONS_E2E_BASE_URL"),
|
||||
Timeout: 15 * time.Second,
|
||||
})
|
||||
|
||||
response, err := client.Directions(ctx, DirectionsRequest{
|
||||
From: from,
|
||||
To: to,
|
||||
Mode: "drive",
|
||||
AvoidTolls: true,
|
||||
Language: "en",
|
||||
Region: "FR",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("directions error: %v", err)
|
||||
}
|
||||
if response.DistanceMeters == 0 || response.DurationSeconds == 0 {
|
||||
t.Fatalf("expected distance and duration, got %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2EPhotoMedia(t *testing.T) {
|
||||
apiKey := os.Getenv("GOOGLE_PLACES_API_KEY")
|
||||
if apiKey == "" {
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import "fmt"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
const (
|
||||
defaultSearchLimit = 10
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import "strings"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const nearbyFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.priceLevel,places.types,places.currentOpeningHours"
|
||||
const nearbyFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.userRatingCount,places.priceLevel,places.types,places.currentOpeningHours,places.businessStatus"
|
||||
|
||||
// NearbySearch performs a nearby search around a location restriction.
|
||||
func (c *Client) NearbySearch(ctx context.Context, req NearbySearchRequest) (NearbySearchResponse, error) {
|
||||
84
internal/places/nearby_test.go
Normal file
84
internal/places/nearby_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNearbySearchSuccess(t *testing.T) {
|
||||
var gotRequest map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/places:searchNearby" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Goog-FieldMask") != nearbyFieldMask {
|
||||
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &gotRequest); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"places": [
|
||||
{
|
||||
"id": "abc",
|
||||
"displayName": {"text": "Cafe"},
|
||||
"formattedAddress": "123 Street",
|
||||
"location": {"latitude": 1.23, "longitude": 4.56},
|
||||
"rating": 4.7,
|
||||
"userRatingCount": 42,
|
||||
"priceLevel": "PRICE_LEVEL_MODERATE",
|
||||
"types": ["cafe"],
|
||||
"currentOpeningHours": {"openNow": true}
|
||||
}
|
||||
],
|
||||
"nextPageToken": "next"
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
|
||||
response, err := client.NearbySearch(context.Background(), NearbySearchRequest{
|
||||
LocationRestriction: &LocationBias{Lat: 40.0, Lng: -70.0, RadiusM: 500},
|
||||
Limit: 5,
|
||||
IncludedTypes: []string{"cafe"},
|
||||
ExcludedTypes: []string{"bar"},
|
||||
Language: "en",
|
||||
Region: "US",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("nearby error: %v", err)
|
||||
}
|
||||
if len(response.Results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(response.Results))
|
||||
}
|
||||
if response.Results[0].UserRatingCount == nil || *response.Results[0].UserRatingCount != 42 {
|
||||
t.Fatalf("unexpected user rating count: %#v", response.Results[0].UserRatingCount)
|
||||
}
|
||||
if response.NextPageToken != "next" {
|
||||
t.Fatalf("unexpected token: %s", response.NextPageToken)
|
||||
}
|
||||
|
||||
if gotRequest["maxResultCount"].(float64) != 5 {
|
||||
t.Fatalf("unexpected maxResultCount: %#v", gotRequest["maxResultCount"])
|
||||
}
|
||||
if gotRequest["languageCode"] != "en" {
|
||||
t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"])
|
||||
}
|
||||
if gotRequest["regionCode"] != "US" {
|
||||
t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"])
|
||||
}
|
||||
if _, ok := gotRequest["locationRestriction"].(map[string]any); !ok {
|
||||
t.Fatalf("unexpected locationRestriction: %#v", gotRequest["locationRestriction"])
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
type searchResponse struct {
|
||||
Places []placeItem `json:"places"`
|
||||
@ -11,10 +11,12 @@ type placeItem struct {
|
||||
FormattedAddress string `json:"formattedAddress,omitempty"`
|
||||
Location *location `json:"location,omitempty"`
|
||||
Rating *float64 `json:"rating,omitempty"`
|
||||
UserRatingCount *int `json:"userRatingCount,omitempty"`
|
||||
PriceLevel string `json:"priceLevel,omitempty"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
CurrentOpeningHours *openingHours `json:"currentOpeningHours,omitempty"`
|
||||
RegularOpeningHours *openingHours `json:"regularOpeningHours,omitempty"`
|
||||
BusinessStatus string `json:"businessStatus,omitempty"`
|
||||
NationalPhoneNumber string `json:"nationalPhoneNumber,omitempty"`
|
||||
WebsiteURI string `json:"websiteUri,omitempty"`
|
||||
Reviews []reviewPayload `json:"reviews,omitempty"`
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
41
internal/places/photo_test.go
Normal file
41
internal/places/photo_test.go
Normal file
@ -0,0 +1,41 @@
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPhotoMediaSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/places/place-1/photos/photo-1/media" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
query := r.URL.Query()
|
||||
if query.Get("skipHttpRedirect") != "true" {
|
||||
t.Fatalf("unexpected skipHttpRedirect: %s", query.Get("skipHttpRedirect"))
|
||||
}
|
||||
if query.Get("maxWidthPx") != "800" {
|
||||
t.Fatalf("unexpected maxWidthPx: %s", query.Get("maxWidthPx"))
|
||||
}
|
||||
if query.Get("maxHeightPx") != "600" {
|
||||
t.Fatalf("unexpected maxHeightPx: %s", query.Get("maxHeightPx"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"name": "places/place-1/photos/photo-1", "photoUri": "https://example.com/photo.jpg"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
|
||||
response, err := client.PhotoMedia(context.Background(), PhotoMediaRequest{
|
||||
Name: "places/place-1/photos/photo-1",
|
||||
MaxWidthPx: 800,
|
||||
MaxHeightPx: 600,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("photo media error: %v", err)
|
||||
}
|
||||
if response.PhotoURI == "" {
|
||||
t.Fatalf("expected photo uri")
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
const (
|
||||
priceLevelFree = "PRICE_LEVEL_FREE"
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
func circlePayload(bias *LocationBias) map[string]any {
|
||||
return map[string]any{
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
57
internal/places/resolve_test.go
Normal file
57
internal/places/resolve_test.go
Normal file
@ -0,0 +1,57 @@
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveSuccess(t *testing.T) {
|
||||
var gotRequest map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Goog-FieldMask") != resolveFieldMask {
|
||||
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &gotRequest); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"places": [
|
||||
{
|
||||
"id": "loc-1",
|
||||
"displayName": {"text": "Downtown"},
|
||||
"formattedAddress": "Main",
|
||||
"location": {"latitude": 1, "longitude": 2},
|
||||
"types": ["neighborhood"]
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL})
|
||||
response, err := client.Resolve(context.Background(), LocationResolveRequest{
|
||||
LocationText: "Downtown",
|
||||
Language: "en",
|
||||
Region: "US",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve error: %v", err)
|
||||
}
|
||||
if len(response.Results) != 1 {
|
||||
t.Fatalf("expected 1 result")
|
||||
}
|
||||
if gotRequest["languageCode"] != "en" {
|
||||
t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"])
|
||||
}
|
||||
if gotRequest["regionCode"] != "US" {
|
||||
t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"])
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const searchFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.priceLevel,places.types,places.currentOpeningHours,nextPageToken"
|
||||
const searchFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.userRatingCount,places.priceLevel,places.types,places.currentOpeningHours,places.businessStatus,nextPageToken"
|
||||
|
||||
// Search performs a text search with optional filters.
|
||||
func (c *Client) Search(ctx context.Context, req SearchRequest) (SearchResponse, error) {
|
||||
@ -100,14 +100,16 @@ func buildSearchBody(req SearchRequest) map[string]any {
|
||||
|
||||
func mapPlaceSummary(place placeItem) PlaceSummary {
|
||||
return PlaceSummary{
|
||||
PlaceID: place.ID,
|
||||
Name: displayName(place.DisplayName),
|
||||
Address: place.FormattedAddress,
|
||||
Location: mapLatLng(place.Location),
|
||||
Rating: place.Rating,
|
||||
PriceLevel: mapPriceLevel(place.PriceLevel),
|
||||
Types: place.Types,
|
||||
OpenNow: openNow(place.CurrentOpeningHours),
|
||||
PlaceID: place.ID,
|
||||
Name: displayName(place.DisplayName),
|
||||
Address: place.FormattedAddress,
|
||||
Location: mapLatLng(place.Location),
|
||||
Rating: place.Rating,
|
||||
UserRatingCount: place.UserRatingCount,
|
||||
PriceLevel: mapPriceLevel(place.PriceLevel),
|
||||
Types: place.Types,
|
||||
OpenNow: openNow(place.CurrentOpeningHours),
|
||||
BusinessStatus: strings.TrimSpace(place.BusinessStatus),
|
||||
}
|
||||
}
|
||||
|
||||
170
internal/places/search_test.go
Normal file
170
internal/places/search_test.go
Normal file
@ -0,0 +1,170 @@
|
||||
package places
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSearchSuccess(t *testing.T) {
|
||||
var gotRequest map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/places:searchText" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Goog-Api-Key") != "test-key" {
|
||||
t.Fatalf("missing api key header")
|
||||
}
|
||||
if r.Header.Get("X-Goog-FieldMask") != searchFieldMask {
|
||||
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &gotRequest); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"places": [
|
||||
{
|
||||
"id": "abc",
|
||||
"displayName": {"text": "Cafe"},
|
||||
"formattedAddress": "123 Street",
|
||||
"location": {"latitude": 1.23, "longitude": 4.56},
|
||||
"rating": 4.7,
|
||||
"userRatingCount": 532,
|
||||
"priceLevel": "PRICE_LEVEL_MODERATE",
|
||||
"types": ["cafe"],
|
||||
"currentOpeningHours": {"openNow": true},
|
||||
"businessStatus": "OPERATIONAL"
|
||||
}
|
||||
],
|
||||
"nextPageToken": "next"
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{
|
||||
APIKey: "test-key",
|
||||
BaseURL: server.URL + "/v1",
|
||||
Timeout: time.Second,
|
||||
})
|
||||
|
||||
open := true
|
||||
minRating := 4.0
|
||||
request := SearchRequest{
|
||||
Query: "coffee",
|
||||
Limit: 5,
|
||||
PageToken: "token",
|
||||
Language: "en",
|
||||
Region: "US",
|
||||
Filters: &Filters{
|
||||
Keyword: "best",
|
||||
Types: []string{"cafe"},
|
||||
OpenNow: &open,
|
||||
MinRating: &minRating,
|
||||
PriceLevels: []int{2},
|
||||
},
|
||||
LocationBias: &LocationBias{Lat: 40.0, Lng: -70.0, RadiusM: 500},
|
||||
}
|
||||
|
||||
response, err := client.Search(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("search error: %v", err)
|
||||
}
|
||||
if len(response.Results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(response.Results))
|
||||
}
|
||||
result := response.Results[0]
|
||||
if result.PlaceID != "abc" {
|
||||
t.Fatalf("unexpected place id: %s", result.PlaceID)
|
||||
}
|
||||
if result.Name != "Cafe" {
|
||||
t.Fatalf("unexpected name: %s", result.Name)
|
||||
}
|
||||
if result.PriceLevel == nil || *result.PriceLevel != 2 {
|
||||
t.Fatalf("unexpected price level: %#v", result.PriceLevel)
|
||||
}
|
||||
if result.UserRatingCount == nil || *result.UserRatingCount != 532 {
|
||||
t.Fatalf("unexpected user rating count: %#v", result.UserRatingCount)
|
||||
}
|
||||
if result.OpenNow == nil || *result.OpenNow != true {
|
||||
t.Fatalf("unexpected openNow: %#v", result.OpenNow)
|
||||
}
|
||||
if result.BusinessStatus != "OPERATIONAL" {
|
||||
t.Fatalf("unexpected business status: %s", result.BusinessStatus)
|
||||
}
|
||||
if response.NextPageToken != "next" {
|
||||
t.Fatalf("unexpected token: %s", response.NextPageToken)
|
||||
}
|
||||
|
||||
if gotRequest["textQuery"] != "coffee best" {
|
||||
t.Fatalf("unexpected textQuery: %#v", gotRequest["textQuery"])
|
||||
}
|
||||
if gotRequest["pageSize"].(float64) != 5 {
|
||||
t.Fatalf("unexpected pageSize: %#v", gotRequest["pageSize"])
|
||||
}
|
||||
if gotRequest["pageToken"] != "token" {
|
||||
t.Fatalf("unexpected pageToken: %#v", gotRequest["pageToken"])
|
||||
}
|
||||
if gotRequest["languageCode"] != "en" {
|
||||
t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"])
|
||||
}
|
||||
if gotRequest["regionCode"] != "US" {
|
||||
t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("bad"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL})
|
||||
_, err := client.Search(context.Background(), SearchRequest{Query: "coffee"})
|
||||
var apiErr *APIError
|
||||
if err == nil || !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected api error, got %v", err)
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected status: %d", apiErr.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchInvalidJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("not-json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL})
|
||||
_, err := client.Search(context.Background(), SearchRequest{Query: "coffee"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchBodyOmitsEmptyPriceLevels(t *testing.T) {
|
||||
request := SearchRequest{Query: "coffee", Filters: &Filters{PriceLevels: []int{9}}}
|
||||
body := buildSearchBody(request)
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if bytes.Contains(payload, []byte("priceLevels")) {
|
||||
t.Fatalf("unexpected priceLevels in payload")
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
// SearchRequest defines a text search with optional filters.
|
||||
type SearchRequest struct {
|
||||
@ -84,31 +84,35 @@ type NearbySearchResponse struct {
|
||||
|
||||
// PlaceSummary is a compact view of a place.
|
||||
type PlaceSummary struct {
|
||||
PlaceID string `json:"place_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Location *LatLng `json:"location,omitempty"`
|
||||
Rating *float64 `json:"rating,omitempty"`
|
||||
PriceLevel *int `json:"price_level,omitempty"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
OpenNow *bool `json:"open_now,omitempty"`
|
||||
PlaceID string `json:"place_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Location *LatLng `json:"location,omitempty"`
|
||||
Rating *float64 `json:"rating,omitempty"`
|
||||
UserRatingCount *int `json:"user_rating_count,omitempty"`
|
||||
PriceLevel *int `json:"price_level,omitempty"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
OpenNow *bool `json:"open_now,omitempty"`
|
||||
BusinessStatus string `json:"business_status,omitempty"`
|
||||
}
|
||||
|
||||
// PlaceDetails is a detailed view of a place.
|
||||
type PlaceDetails struct {
|
||||
PlaceID string `json:"place_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Location *LatLng `json:"location,omitempty"`
|
||||
Rating *float64 `json:"rating,omitempty"`
|
||||
PriceLevel *int `json:"price_level,omitempty"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Hours []string `json:"hours,omitempty"`
|
||||
OpenNow *bool `json:"open_now,omitempty"`
|
||||
Reviews []Review `json:"reviews,omitempty"`
|
||||
Photos []Photo `json:"photos,omitempty"`
|
||||
PlaceID string `json:"place_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Location *LatLng `json:"location,omitempty"`
|
||||
Rating *float64 `json:"rating,omitempty"`
|
||||
UserRatingCount *int `json:"user_rating_count,omitempty"`
|
||||
PriceLevel *int `json:"price_level,omitempty"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Hours []string `json:"hours,omitempty"`
|
||||
OpenNow *bool `json:"open_now,omitempty"`
|
||||
BusinessStatus string `json:"business_status,omitempty"`
|
||||
Reviews []Review `json:"reviews,omitempty"`
|
||||
Photos []Photo `json:"photos,omitempty"`
|
||||
}
|
||||
|
||||
// LocationResolveRequest resolves a text location into place candidates.
|
||||
@ -1,4 +1,4 @@
|
||||
package goplaces
|
||||
package places
|
||||
|
||||
func validateLocationBias(bias *LocationBias) error {
|
||||
if bias == nil {
|
||||
Loading…
Reference in New Issue
Block a user