Compare commits

..

2 Commits

Author SHA1 Message Date
Peter Steinberger
39a4f09700 fix: handle rating-count rendering fallback and add regressions
Some checks failed
ci / test (push) Has been cancelled
2026-02-14 05:11:52 +01:00
aligurelli
de52de72c1 feat: add userRatingCount to search, nearby, and details
Add the userRatingCount field from the Google Places API (New) to
PlaceSummary and PlaceDetails types. This allows consumers to see
the total number of user ratings for each place.

Changes:
- Add userRatingCount to search, nearby, and details field masks
- Add UserRatingCount field to placeItem payload struct
- Add UserRatingCount to PlaceSummary and PlaceDetails types
- Map the field in mapPlaceSummary and mapPlaceDetails
- Display rating count in CLI output as 'Rating: 4.5 (532)'
- Include as 'user_rating_count' in JSON output
2026-01-28 00:57:33 +03:00
64 changed files with 832 additions and 4065 deletions

View File

@ -1,36 +0,0 @@
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

View File

@ -1,18 +1,12 @@
name: release
name: build
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to (re)release (e.g. v0.1.0)"
required: true
type: string
permissions:
contents: write
contents: read
jobs:
goreleaser:
@ -25,51 +19,12 @@ 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: 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
args: build --clean
- uses: actions/upload-artifact@v4
with:
name: goplaces-${{ github.ref_name }}
path: dist/**
if-no-files-found: error

View File

@ -17,29 +17,31 @@ 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
settings:
gofumpt:
extra-rules: true
goimports:
local-prefixes:
- github.com/steipete/goplaces
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

View File

@ -1,14 +1,8 @@
# Changelog
## 0.4.0 - 2026-05-04
## 0.2.2 - Unreleased
- 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
- (TBD)
## 0.2.1 - 2026-01-23

121
README.md
View File

@ -1,46 +1,31 @@
# goplaces
# 📍 goplaces — Modern Places for Go
Modern Go client and CLI for the Google Places API (New), plus selected Routes API workflows.
Modern Go client + CLI for the Google Places API (New). Fast for humans, tidy for scripts.
Docs site: https://goplaces.sh
## Highlights
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.
- 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`).
## Install / Run
Latest release: v0.4.0 (2026-05-04).
Latest release: v0.2.1 (2026-01-23).
- Homebrew: `brew install steipete/tap/goplaces`
- Go: `go install github.com/steipete/goplaces/cmd/goplaces@latest`
- Source: `make goplaces`
## 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`.
## Config
```bash
export GOOGLE_PLACES_API_KEY="..."
@ -50,9 +35,8 @@ 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)
### Create a Key
### Getting a Google Places API Key
1. **Create a Google Cloud Project**
- Go to [Google Cloud Console](https://console.cloud.google.com/)
@ -64,7 +48,7 @@ Optional overrides:
- Search for "Places API (New)" — make sure it says **(New)**!
- Click "Enable"
3. **Enable the Routes API (for `route` and `directions`)**
3. **Enable the Routes API (for `route`)**
- Search for "Routes API"
- Click "Enable"
@ -81,17 +65,17 @@ Optional overrides:
6. **(Recommended) Restrict the Key**
- Click on the key in Credentials
- Under "API restrictions", select "Restrict key" → add "Places API (New)" and "Routes API"
- Under "API restrictions", select "Restrict key" → "Places API (New)"
- 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 Overview
## CLI
Long flags accept `--flag value` or `--flag=value` (examples use space).
```text
goplaces [--api-key=KEY] [--base-url=URL] [--routes-base-url=URL] [--directions-base-url=URL] [--timeout=10s] [--json] [--no-color] [--verbose]
goplaces [--api-key=KEY] [--base-url=URL] [--routes-base-url=URL] [--timeout=10s] [--json] [--no-color] [--verbose]
<command>
Commands:
@ -99,27 +83,11 @@ 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
@ -151,31 +119,15 @@ Route search:
goplaces route "coffee" --from "Seattle, WA" --to "Portland, OR" --max-waypoints 5
```
Directions (walking with optional driving comparison):
Details (with reviews):
```bash
goplaces directions --from "Pike Place Market" --to "Space Needle"
goplaces directions --from-place-id <fromId> --to-place-id <toId> --compare drive --steps
```
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
```
Details (with photos):
```bash
goplaces details ChIJN1t_tDeuEmsRUsoyG83frY4 --photos
```
@ -197,20 +149,6 @@ 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
@ -276,8 +214,6 @@ 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.
@ -300,4 +236,3 @@ 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
View File

@ -1,91 +0,0 @@
// 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
)

View File

@ -1,10 +0,0 @@
package goplaces
import "testing"
func TestFacadeNewClient(t *testing.T) {
client := NewClient(Options{APIKey: "key"})
if client == nil {
t.Fatal("expected client")
}
}

View File

@ -1,4 +1,4 @@
package places
package goplaces
import (
"context"

View File

@ -1,5 +1,5 @@
// Package goplaces provides a Go client for the Google Places API (New).
package places
package goplaces
import (
"bytes"
@ -19,21 +19,19 @@ const DefaultBaseURL = "https://places.googleapis.com/v1"
// Client wraps access to the Google Places API.
type Client struct {
apiKey string
baseURL string
routesBaseURL string
directionsBaseURL string
httpClient *http.Client
apiKey string
baseURL string
routesBaseURL string
httpClient *http.Client
}
// Options configures the Places client.
type Options struct {
APIKey string
BaseURL string
RoutesBaseURL string
DirectionsBaseURL string
HTTPClient *http.Client
Timeout time.Duration
APIKey string
BaseURL string
RoutesBaseURL string
HTTPClient *http.Client
Timeout time.Duration
}
// NewClient builds a client with sane defaults.
@ -46,10 +44,6 @@ func NewClient(opts Options) *Client {
if routesBaseURL == "" {
routesBaseURL = defaultRoutesBaseURL
}
directionsBaseURL := strings.TrimRight(opts.DirectionsBaseURL, "/")
if directionsBaseURL == "" {
directionsBaseURL = defaultDirectionsBaseURL
}
client := opts.HTTPClient
if client == nil {
@ -61,11 +55,10 @@ func NewClient(opts Options) *Client {
}
return &Client{
apiKey: opts.APIKey,
baseURL: baseURL,
routesBaseURL: routesBaseURL,
directionsBaseURL: directionsBaseURL,
httpClient: client,
apiKey: opts.APIKey,
baseURL: baseURL,
routesBaseURL: routesBaseURL,
httpClient: client,
}
}

690
client_test.go Normal file
View File

@ -0,0 +1,690 @@
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,
"userRatingCount": 532,
"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.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 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,
"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"])
}
}
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,
"userRatingCount": 1234,
"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.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 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")
}
}

View File

@ -14,6 +14,6 @@ func main() {
exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
func run(args []string, stdout io.Writer, stderr io.Writer) int {
return cli.Run(args, stdout, stderr)
}

View File

@ -1,4 +1,4 @@
package places
package goplaces
import (
"context"
@ -9,7 +9,7 @@ import (
)
const (
detailsFieldMaskBase = "id,displayName,formattedAddress,location,rating,userRatingCount,priceLevel,types,regularOpeningHours,currentOpeningHours,businessStatus,nationalPhoneNumber,websiteUri"
detailsFieldMaskBase = "id,displayName,formattedAddress,location,rating,userRatingCount,priceLevel,types,regularOpeningHours,currentOpeningHours,nationalPhoneNumber,websiteUri"
detailsFieldMaskReview = "reviews"
detailsFieldMaskPhotos = "photos"
)
@ -73,7 +73,6 @@ func mapPlaceDetails(place placeItem) PlaceDetails {
Website: place.WebsiteURI,
Hours: weekdayDescriptions(place.RegularOpeningHours),
OpenNow: openNow(place.CurrentOpeningHours),
BusinessStatus: strings.TrimSpace(place.BusinessStatus),
Reviews: mapReviews(place.Reviews),
Photos: mapPhotos(place.Photos),
}

View File

@ -1 +0,0 @@

View File

@ -1 +0,0 @@
goplaces.sh

View File

@ -1,760 +0,0 @@
: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; }
}

View File

@ -1,50 +0,0 @@
// 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}`);
});
});

View File

@ -1,42 +0,0 @@
<!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 &nearr;</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 &lt;input&gt; [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">&larr; Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &nearr;</a></div></footer>
<script src="../assets/site.js"></script>
</body>
</html>

View File

@ -1,40 +0,0 @@
<!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 &nearr;</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 &lt;place_id&gt; [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">&larr; Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &nearr;</a></div></footer>
<script src="../assets/site.js"></script>
</body>
</html>

View File

@ -1,49 +0,0 @@
<!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 &nearr;</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">&larr; Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &nearr;</a></div></footer>
<script src="../assets/site.js"></script>
</body>
</html>

View File

@ -1,43 +0,0 @@
<!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 &nearr;</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">&larr; Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &nearr;</a></div></footer>
<script src="../assets/site.js"></script>
</body>
</html>

View File

@ -1,39 +0,0 @@
<!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 &nearr;</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 &lt;photo_name&gt; [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">&larr; Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &nearr;</a></div></footer>
<script src="../assets/site.js"></script>
</body>
</html>

View File

@ -1,39 +0,0 @@
<!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 &nearr;</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 &lt;location&gt; [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">&larr; Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &nearr;</a></div></footer>
<script src="../assets/site.js"></script>
</body>
</html>

View File

@ -1,43 +0,0 @@
<!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 &nearr;</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 &lt;query&gt; [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">&larr; Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &nearr;</a></div></footer>
<script src="../assets/site.js"></script>
</body>
</html>

View File

@ -1,49 +0,0 @@
<!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 &nearr;</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 &lt;query&gt; [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">&larr; Back to overview</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &nearr;</a></div></footer>
<script src="../assets/site.js"></script>
</body>
</html>

View File

@ -1,49 +0,0 @@
# 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.

View File

@ -1,353 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>goplaces &mdash; 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 &nearr;</a>
</div>
</nav>
</header>
<main>
<!-- ─────────────── Hero ─────────────── -->
<section class="hero shell">
<div>
<div class="eyebrow">Google Places API (New) &middot; Routes</div>
<h1>Places &amp;<br>routes, in&nbsp;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">&rarr;</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 &mdash; <code>--api-key</code>, <code>--timeout</code>, <code>--no-color</code>, endpoint overrides &mdash; 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">&rarr;</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">&rarr;</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">&rarr;</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">&rarr;</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">&rarr;</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">&rarr;</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 &middot; 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">&rarr;</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">&rarr;</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 &mdash; 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=&quot;...&quot;
goplaces search &quot;bookstore&quot;">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=&quot;...&quot;
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 &quot;goplaces_*_$(go env GOOS)_$(go env GOARCH).tar.gz&quot; --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 &mdash; small surface, idiomatic types.</p>
</div>
<div class="split">
<div class="panel">
<h3>What you get</h3>
<ul class="steps">
<li><b>&middot;</b><div><p>Typed request and response structs</p><small>Place, Photo, Route, Step, OpeningHours &mdash; all explicit.</small></div></li>
<li><b>&middot;</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>&middot;</b><div><p>Context everywhere</p><small>Honor cancellation, deadlines, and request-scoped values.</small></div></li>
<li><b>&middot;</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">&amp;</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 &mdash; the CLI and the Go client &mdash; 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 &amp; 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 &amp; mapping</h3>
<p>The actual Places + Routes calls and the JSON&hairsp;&rarr;&hairsp;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>&copy; Peter Steinberger</span>
</div>
</footer>
<script src="assets/site.js"></script>
</body>
</html>

View File

@ -34,4 +34,3 @@ 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.

View File

@ -1,6 +1,6 @@
# goplaces Homebrew Release Playbook
Manual/local tap update from GitHub release assets.
Manual/local tap update (no GitHub token). This doc mirrors camsnap style.
## Prereqs
@ -10,8 +10,8 @@ Manual/local tap update from GitHub release assets.
## Release
1) Tag + push: `git tag vX.Y.Z && git push origin vX.Y.Z`
2) GitHub Actions builds binaries (workflow artifacts).
3) Create/publish GitHub release `vX.Y.Z` and upload archives.
2) GitHub Actions builds binaries (workflow artifacts only).
3) Host artifacts somewhere public (e.g., attach to a manual GitHub release).
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 GitHub releases or Homebrew automatically.
- CI does not publish releases or Homebrew.

View File

@ -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 `gh` is authenticated for `steipete/goplaces` + `steipete/homebrew-tap`.
- Ensure `HOMEBREW_TAP_GITHUB_TOKEN` secret is set (pushes formula to `steipete/homebrew-tap`).
## Tag + Build
## Tag + Release
```bash
git tag vX.Y.Z
@ -17,17 +17,9 @@ 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.
Artifacts are stored on the workflow run (no GitHub release publish).
## 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`.
Homebrew (local tap update): see `docs/releasing-homebrew.md`.
## Notes

View File

@ -1,7 +1,7 @@
//go:build e2e
// +build e2e
package places
package goplaces
import (
"context"
@ -148,46 +148,6 @@ 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 == "" {

View File

@ -1,4 +1,4 @@
package places
package goplaces
import "fmt"

View File

@ -1,4 +1,4 @@
package places
package goplaces
import (
"strings"

2
go.mod
View File

@ -2,4 +2,4 @@ module github.com/steipete/goplaces
go 1.25.5
require github.com/alecthomas/kong v1.15.0
require github.com/alecthomas/kong v1.13.0

4
go.sum
View File

@ -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.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI=
github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
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/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=

View File

@ -2,7 +2,6 @@ package cli
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@ -116,101 +115,3 @@ 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())
}
}

View File

@ -13,14 +13,9 @@ import (
)
const (
placesSearchPath = "/places:searchText"
placesNearbyPath = "/places:searchNearby"
routesComputePath = "/directions/v2:computeRoutes"
directionsPath = routesComputePath
directionsModeWalkAPI = "WALK"
directionsModeDriveAPI = "DRIVE"
directionsModeWalking = "walking"
directionsModeDriving = "driving"
placesSearchPath = "/places:searchText"
placesNearbyPath = "/places:searchNearby"
routesComputePath = "/directions/v2:computeRoutes"
)
func TestRunSearchJSON(t *testing.T) {
@ -353,209 +348,6 @@ 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" {

View File

@ -40,7 +40,7 @@ func (c Color) Dim(value string) string {
return c.wrap("2", value)
}
func (c Color) wrap(code, value string) string {
func (c Color) wrap(code string, value string) string {
if !c.enabled {
return value
}

View File

@ -1,126 +0,0 @@
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 ""
}
}

View File

@ -19,7 +19,7 @@ func renderSearch(color Color, response goplaces.SearchResponse) string {
out.WriteString("\n")
for i, place := range response.Results {
fmt.Fprintf(&out, "%d. %s\n", i+1, formatTitle(color, place.Name, place.Address))
out.WriteString(fmt.Sprintf("%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))
fmt.Fprintf(&out, "%d. %s\n", i+1, title)
out.WriteString(fmt.Sprintf("%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 {
fmt.Fprintf(&out, "%d. %s\n", i+1, formatTitle(color, place.Name, place.Address))
out.WriteString(fmt.Sprintf("%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 {
fmt.Fprintf(&out, "%d. %s\n", i+1, formatTitle(color, place.Name, place.Address))
out.WriteString(fmt.Sprintf("%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 {
fmt.Fprintf(&out, "%d. %s\n", j+1, formatTitle(color, place.Name, place.Address))
out.WriteString(fmt.Sprintf("%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,53 +155,7 @@ func renderRoute(color Color, response goplaces.RouteResponse) string {
return out.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 {
func formatTitle(color Color, name string, address string) string {
display := strings.TrimSpace(name)
if display == "" {
display = "(no name)"
@ -237,7 +191,6 @@ func writePlaceSummary(out *bytes.Buffer, color Color, place goplaces.PlaceSumma
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) {
@ -256,7 +209,6 @@ func writePlaceDetails(out *bytes.Buffer, color Color, place goplaces.PlaceDetai
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)
@ -348,7 +300,7 @@ 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, userRatingCount, priceLevel *int) {
func writeRating(out *bytes.Buffer, color Color, rating *float64, userRatingCount *int, priceLevel *int) {
if rating == nil && userRatingCount == nil && priceLevel == nil {
return
}
@ -387,7 +339,7 @@ func writeOpenNow(out *bytes.Buffer, color Color, openNow *bool) {
writeLine(out, color, "Open now", value)
}
func writeLine(out *bytes.Buffer, color Color, label, value string) {
func writeLine(out *bytes.Buffer, color Color, label string, value string) {
if strings.TrimSpace(value) == "" {
return
}
@ -452,21 +404,6 @@ 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))

View File

@ -25,7 +25,6 @@ func TestRenderSearch(t *testing.T) {
PriceLevel: &level,
Types: []string{"cafe", "coffee_shop"},
OpenNow: &open,
BusinessStatus: "OPERATIONAL",
},
},
NextPageToken: "next",
@ -44,9 +43,6 @@ func TestRenderSearch(t *testing.T) {
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")
}
@ -158,55 +154,6 @@ 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)") {
@ -230,17 +177,16 @@ 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,
BusinessStatus: "CLOSED_TEMPORARILY",
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,
Photos: []goplaces.Photo{
{Name: "places/place-1/photos/photo-1", WidthPx: 1200, HeightPx: 800},
},
@ -263,9 +209,6 @@ 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"}},

View File

@ -11,7 +11,6 @@ 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."`
@ -19,15 +18,14 @@ 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"`
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."`
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."`
}
// SearchCmd runs text search queries.

View File

@ -9,7 +9,6 @@ import (
"os"
"github.com/alecthomas/kong"
"github.com/steipete/goplaces"
)
@ -23,7 +22,7 @@ type App struct {
}
// Run executes the CLI with the provided arguments.
func Run(args []string, stdout, stderr io.Writer) int {
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
if stdout == nil {
stdout = os.Stdout
}
@ -70,11 +69,10 @@ func Run(args []string, stdout, stderr io.Writer) int {
}
client := goplaces.NewClient(goplaces.Options{
APIKey: root.Global.APIKey,
BaseURL: root.Global.BaseURL,
RoutesBaseURL: root.Global.RoutesBaseURL,
DirectionsBaseURL: root.Global.DirectionsBaseURL,
Timeout: root.Global.Timeout,
APIKey: root.Global.APIKey,
BaseURL: root.Global.BaseURL,
RoutesBaseURL: root.Global.RoutesBaseURL,
Timeout: root.Global.Timeout,
})
app := &App{

View File

@ -1,120 +0,0 @@
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))
}
}

View File

@ -1,137 +0,0 @@
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")
}
}

View File

@ -1,173 +0,0 @@
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)
}
}

View File

@ -1,359 +0,0 @@
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"`
}

View File

@ -1,355 +0,0 @@
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)
}
}

View File

@ -1,84 +0,0 @@
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"])
}
}

View File

@ -1,41 +0,0 @@
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")
}
}

View File

@ -1,57 +0,0 @@
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"])
}
}

View File

@ -1,170 +0,0 @@
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")
}
}

View File

@ -1,4 +1,4 @@
package places
package goplaces
const (
defaultSearchLimit = 10

View File

@ -1,4 +1,4 @@
package places
package goplaces
import "strings"

View File

@ -1,4 +1,4 @@
package places
package goplaces
import (
"context"
@ -8,7 +8,7 @@ import (
"strings"
)
const nearbyFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.userRatingCount,places.priceLevel,places.types,places.currentOpeningHours,places.businessStatus"
const nearbyFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.userRatingCount,places.priceLevel,places.types,places.currentOpeningHours"
// NearbySearch performs a nearby search around a location restriction.
func (c *Client) NearbySearch(ctx context.Context, req NearbySearchRequest) (NearbySearchResponse, error) {

View File

@ -1,4 +1,4 @@
package places
package goplaces
type searchResponse struct {
Places []placeItem `json:"places"`
@ -16,7 +16,6 @@ type placeItem struct {
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"`

View File

@ -1,4 +1,4 @@
package places
package goplaces
import (
"context"

View File

@ -1,4 +1,4 @@
package places
package goplaces
const (
priceLevelFree = "PRICE_LEVEL_FREE"

View File

@ -1,4 +1,4 @@
package places
package goplaces
func circlePayload(bias *LocationBias) map[string]any {
return map[string]any{

View File

@ -1,4 +1,4 @@
package places
package goplaces
import (
"context"

View File

@ -1,4 +1,4 @@
package places
package goplaces
import (
"context"

View File

@ -1,4 +1,4 @@
package places
package goplaces
import (
"context"

View File

@ -1,4 +1,4 @@
package places
package goplaces
import (
"context"
@ -8,7 +8,7 @@ import (
"strings"
)
const searchFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.userRatingCount,places.priceLevel,places.types,places.currentOpeningHours,places.businessStatus,nextPageToken"
const searchFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.userRatingCount,places.priceLevel,places.types,places.currentOpeningHours,nextPageToken"
// Search performs a text search with optional filters.
func (c *Client) Search(ctx context.Context, req SearchRequest) (SearchResponse, error) {
@ -109,7 +109,6 @@ func mapPlaceSummary(place placeItem) PlaceSummary {
PriceLevel: mapPriceLevel(place.PriceLevel),
Types: place.Types,
OpenNow: openNow(place.CurrentOpeningHours),
BusinessStatus: strings.TrimSpace(place.BusinessStatus),
}
}

View File

@ -1,4 +1,4 @@
package places
package goplaces
// SearchRequest defines a text search with optional filters.
type SearchRequest struct {
@ -93,7 +93,6 @@ type PlaceSummary struct {
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.
@ -110,7 +109,6 @@ type PlaceDetails struct {
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"`
}

View File

@ -1,4 +1,4 @@
package places
package goplaces
func validateLocationBias(bias *LocationBias) error {
if bias == nil {