Compare commits

...

7 Commits
v0.4.0 ... main

Author SHA1 Message Date
Peter Steinberger
55170352c3
ci: update homebrew tap on release
Some checks failed
ci / test (push) Has been cancelled
2026-05-07 03:56:52 +01:00
Peter Steinberger
4a57c76e3e
style: improve command card spacing
Some checks failed
ci / test (push) Has been cancelled
pages / deploy (push) Has been cancelled
2026-05-05 01:36:26 +01:00
Peter Steinberger
328b7553a3
docs: refresh docs site 2026-05-04 23:12:11 +01:00
Peter Steinberger
d113626796
refactor: move client implementation under internal 2026-05-04 07:58:32 +01:00
Peter Steinberger
5ccbbbd7fe
ci: enable GitHub Pages deployments 2026-05-04 07:46:56 +01:00
Peter Steinberger
f050b6de1d
docs: add GitHub Pages docs site 2026-05-04 07:45:52 +01:00
Peter Steinberger
b0c4047024
docs: explain project in README 2026-05-04 04:07:05 +01:00
47 changed files with 2566 additions and 783 deletions

36
.github/workflows/pages.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: pages
on:
push:
branches:
- main
paths:
- "docs/**"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
with:
enablement: true
- uses: actions/upload-pages-artifact@v3
with:
path: docs
- id: deployment
uses: actions/deploy-pages@v4

View File

@ -1,12 +1,18 @@
name: build
name: release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to (re)release (e.g. v0.1.0)"
required: true
type: string
permissions:
contents: read
contents: write
jobs:
goreleaser:
@ -19,12 +25,51 @@ jobs:
with:
go-version-file: go.mod
cache: true
- name: Stash GoReleaser config
run: cp .goreleaser.yml /tmp/.goreleaser.yml
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
run: git checkout ${{ inputs.tag }}
- name: Verify Homebrew tap token
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$HOMEBREW_TAP_TOKEN" ]; then
echo "::error::HOMEBREW_TAP_TOKEN is missing; cannot publish Homebrew formula"
exit 1
fi
code="$(curl -sS -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $HOMEBREW_TAP_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/steipete/homebrew-tap || true)"
if [ "$code" != "200" ]; then
echo "::error::HOMEBREW_TAP_TOKEN cannot access steipete/homebrew-tap (HTTP $code)"
exit 1
fi
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: build --clean
- uses: actions/upload-artifact@v4
with:
name: goplaces-${{ github.ref_name }}
path: dist/**
if-no-files-found: error
args: release --clean --config /tmp/.goreleaser.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- name: Verify Homebrew formula
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
release_tag="${{ inputs.tag }}"
else
release_tag="${{ github.ref_name }}"
fi
release_version="${release_tag#v}"
formula="$(gh api repos/steipete/homebrew-tap/contents/Formula/goplaces.rb --jq '.content' | base64 --decode)"
if ! grep -q "version \"${release_version}\"" <<<"$formula"; then
echo "::error::steipete/homebrew-tap Formula/goplaces.rb was not updated to $release_version"
exit 1
fi
if ! grep -q "releases/download/v${release_version}/" <<<"$formula"; then
echo "::error::steipete/homebrew-tap Formula/goplaces.rb does not point at v$release_version assets"
exit 1
fi

View File

@ -1,22 +1,31 @@
# 📍 goplaces — Modern Places for Go
# goplaces
Modern Go client + CLI for the Google Places API (New). Fast for humans, tidy for scripts.
Modern Go client and CLI for the Google Places API (New), plus selected Routes API workflows.
## Highlights
Docs site: https://goplaces.sh
- Text search with filters: keyword, type, open now, min rating, price levels.
- Autocomplete suggestions for places + queries (session tokens supported).
- Nearby search around a location restriction.
- Place photos in details + photo media URLs.
- Route search along a driving path (Routes API).
- Directions between two points with distance, duration, steps, and optional drive route modifiers (Routes API).
- Location bias (lat/lng/radius) and pagination tokens.
- Place details: hours, phone, website, rating, price, types.
- Optional reviews in details (`--reviews` / `IncludeReviews`).
- Resolve free-form location strings to candidate places.
- Locale hints (language + region) across search/resolve/details.
- Typed models, validation errors, and API error surfacing.
- CLI with color human output + `--json` (respects `NO_COLOR`).
Use it when you want Google place data from a terminal, shell script, agent, or Go program without hand-writing Places field masks and request payloads. `goplaces` keeps the human CLI pleasant, but the same commands also emit stable JSON for automation.
Typical jobs:
- Find places by text, type, rating, price, current open state, and location bias.
- Inspect a place: address, coordinates, phone, website, hours, photos, reviews, current open state, and business status.
- Autocomplete partial place/query input.
- Search nearby a lat/lng radius.
- Resolve free-form locations to place candidates.
- Search for places along a route.
- Get directions, travel time, distance, steps, units, and drive route modifiers.
## Project Shape
- `cmd/goplaces`: CLI entrypoint built around the library.
- Root package `github.com/steipete/goplaces`: stable public Go API.
- `internal/places`: Places + Routes implementation and focused client tests.
- `internal/cli`: command parsing, output rendering, and CLI tests.
- Places API (New): search, nearby, details, autocomplete, photo media, resolve.
- Routes API: route polyline sampling and directions.
- Output: compact color text by default, JSON with `--json`.
- Runtime config: environment variables or flags.
## Install / Run
@ -26,7 +35,12 @@ Latest release: v0.4.0 (2026-05-04).
- Go: `go install github.com/steipete/goplaces/cmd/goplaces@latest`
- Source: `make goplaces`
## Config
## API Setup
`goplaces` needs a Google API key with the right APIs enabled:
- Places API (New) for `search`, `nearby`, `autocomplete`, `details`, `photo`, and `resolve`.
- Routes API for `route` and `directions`.
```bash
export GOOGLE_PLACES_API_KEY="..."
@ -38,7 +52,7 @@ Optional overrides:
- `GOOGLE_ROUTES_BASE_URL` (testing Routes API or proxying)
- `GOOGLE_DIRECTIONS_BASE_URL` (testing Routes API directions calls or proxying)
### Getting a Google Places API Key
### Create a Key
1. **Create a Google Cloud Project**
- Go to [Google Cloud Console](https://console.cloud.google.com/)
@ -72,7 +86,7 @@ Optional overrides:
> **Note**: The Places API has usage costs. Check [pricing](https://developers.google.com/maps/documentation/places/web-service/usage-and-billing) and set budget alerts!
## CLI
## CLI Overview
Long flags accept `--flag value` or `--flag=value` (examples use space).
@ -91,6 +105,21 @@ Commands:
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
@ -142,15 +171,11 @@ Units (default metric):
goplaces directions --from "Pike Place Market" --to "Space Needle" --units imperial
```
Details (with reviews):
Details:
```bash
goplaces details ChIJ-bfVTh8VkFQRDZLQnmioK9s
goplaces details ChIJN1t_tDeuEmsRUsoyG83frY4 --reviews
```
Details (with photos):
```bash
goplaces details ChIJN1t_tDeuEmsRUsoyG83frY4 --photos
```
@ -172,6 +197,20 @@ JSON output:
goplaces search "sushi" --json
```
Example JSON result fields include:
```json
{
"place_id": "ChIJ-bfVTh8VkFQRDZLQnmioK9s",
"name": "Space Needle",
"address": "400 Broad St, Seattle, WA 98109, USA",
"rating": 4.6,
"user_rating_count": 58186,
"open_now": true,
"business_status": "OPERATIONAL"
}
```
## Library
```go

91
api.go Normal file
View File

@ -0,0 +1,91 @@
// Package goplaces provides a Go client for the Google Places API (New).
package goplaces
import "github.com/steipete/goplaces/internal/places"
// DefaultBaseURL is the default endpoint for the Places API (New).
const DefaultBaseURL = places.DefaultBaseURL
// ErrMissingAPIKey indicates a missing API key.
var ErrMissingAPIKey = places.ErrMissingAPIKey
// NewClient builds a client with sane defaults.
func NewClient(opts Options) *Client {
return places.NewClient(opts)
}
type (
// Client wraps access to the Google Places API.
Client = places.Client
// Options configures the Places client.
Options = places.Options
// ValidationError describes an invalid request payload.
ValidationError = places.ValidationError
// APIError represents an HTTP error from the Places API.
APIError = places.APIError
// SearchRequest defines a text search with optional filters.
SearchRequest = places.SearchRequest
// Filters are optional search refinements.
Filters = places.Filters
// LocationBias limits search results to a circular area.
LocationBias = places.LocationBias
// LatLng holds geographic coordinates.
LatLng = places.LatLng
// SearchResponse contains a list of places and optional pagination token.
SearchResponse = places.SearchResponse
// AutocompleteRequest defines input for autocomplete suggestions.
AutocompleteRequest = places.AutocompleteRequest
// AutocompleteResponse contains suggestions from autocomplete.
AutocompleteResponse = places.AutocompleteResponse
// AutocompleteSuggestion is a place or query prediction.
AutocompleteSuggestion = places.AutocompleteSuggestion
// NearbySearchRequest defines a nearby search query.
NearbySearchRequest = places.NearbySearchRequest
// NearbySearchResponse contains nearby search results.
NearbySearchResponse = places.NearbySearchResponse
// PlaceSummary is a compact view of a place.
PlaceSummary = places.PlaceSummary
// PlaceDetails is a detailed view of a place.
PlaceDetails = places.PlaceDetails
// DetailsRequest fetches place details with optional locale hints.
DetailsRequest = places.DetailsRequest
// Review represents a user review of a place.
Review = places.Review
// LocalizedText is a text value with an optional language code.
LocalizedText = places.LocalizedText
// AuthorAttribution describes a review author.
AuthorAttribution = places.AuthorAttribution
// ReviewVisitDate describes the date a reviewer visited a place.
ReviewVisitDate = places.ReviewVisitDate
// Photo describes photo metadata for a place.
Photo = places.Photo
// PhotoMediaRequest fetches a photo URL from a photo resource name.
PhotoMediaRequest = places.PhotoMediaRequest
// PhotoMediaResponse contains the photo URL for a photo name.
PhotoMediaResponse = places.PhotoMediaResponse
// LocationResolveRequest resolves a text location into place candidates.
LocationResolveRequest = places.LocationResolveRequest
// LocationResolveResponse contains resolved locations.
LocationResolveResponse = places.LocationResolveResponse
// ResolvedLocation is a place candidate for a location string.
ResolvedLocation = places.ResolvedLocation
// RouteRequest describes a query to search along a route.
RouteRequest = places.RouteRequest
// RouteResponse contains sampled waypoints with search results.
RouteResponse = places.RouteResponse
// RouteWaypoint ties a sampled route location to search results.
RouteWaypoint = places.RouteWaypoint
// DirectionsRequest describes a directions query between two locations.
DirectionsRequest = places.DirectionsRequest
// DirectionsResponse contains a single route summary and steps.
DirectionsResponse = places.DirectionsResponse
// DirectionsStep is a single navigation step.
DirectionsStep = places.DirectionsStep
)

10
api_test.go Normal file
View File

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

View File

@ -1,729 +0,0 @@
package goplaces
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestSearchSuccess(t *testing.T) {
var gotRequest map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/v1/places:searchText" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("X-Goog-Api-Key") != "test-key" {
t.Fatalf("missing api key header")
}
if r.Header.Get("X-Goog-FieldMask") != searchFieldMask {
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if err := json.Unmarshal(body, &gotRequest); err != nil {
t.Fatalf("decode body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"places": [
{
"id": "abc",
"displayName": {"text": "Cafe"},
"formattedAddress": "123 Street",
"location": {"latitude": 1.23, "longitude": 4.56},
"rating": 4.7,
"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 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},
"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)
}
}
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 TestNewClientDefaults(t *testing.T) {
client := NewClient(Options{APIKey: "test-key"})
if client.baseURL != DefaultBaseURL {
t.Fatalf("unexpected baseURL: %s", client.baseURL)
}
if client.routesBaseURL != defaultRoutesBaseURL {
t.Fatalf("unexpected routesBaseURL: %s", client.routesBaseURL)
}
if client.directionsBaseURL != defaultDirectionsBaseURL {
t.Fatalf("unexpected directionsBaseURL: %s", client.directionsBaseURL)
}
}
func TestNewClientCustomDirectionsBaseURL(t *testing.T) {
client := NewClient(Options{
APIKey: "test-key",
BaseURL: "https://example.com/v1/",
RoutesBaseURL: "https://routes.example.com/",
DirectionsBaseURL: "https://maps.example.com/directions/",
})
if client.baseURL != "https://example.com/v1" {
t.Fatalf("unexpected baseURL: %s", client.baseURL)
}
if client.routesBaseURL != "https://routes.example.com" {
t.Fatalf("unexpected routesBaseURL: %s", client.routesBaseURL)
}
if client.directionsBaseURL != "https://maps.example.com/directions" {
t.Fatalf("unexpected directionsBaseURL: %s", client.directionsBaseURL)
}
}
func TestMappingHelpers(t *testing.T) {
if mapLatLng(nil) != nil {
t.Fatalf("expected nil location")
}
if displayName(nil) != "" {
t.Fatalf("expected empty display name")
}
if openNow(nil) != nil {
t.Fatalf("expected nil open now")
}
if weekdayDescriptions(nil) != nil {
t.Fatalf("expected nil hours")
}
if mapPriceLevel("UNKNOWN") != nil {
t.Fatalf("expected nil price level")
}
}

1
docs/.nojekyll Normal file
View File

@ -0,0 +1 @@

1
docs/CNAME Normal file
View File

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

760
docs/assets/site.css Normal file
View File

@ -0,0 +1,760 @@
:root {
color-scheme: dark;
--ink: #eef6ee;
--muted: #a6b8ad;
--faint: #6f8175;
--bg: #0b100e;
--bg-2: #0e1512;
--panel: #131b17;
--panel-2: #182721;
--line: #233129;
--line-2: #304338;
--mint: #8df0b0;
--mint-deep: #4fd189;
--amber: #ffca6e;
--blue: #89c7ff;
--rose: #ff8f9f;
--violet: #c8a8ff;
--shadow-sm: 0 6px 18px rgba(0, 0, 0, .35);
--shadow-lg: 0 28px 80px rgba(0, 0, 0, .45);
--radius: 10px;
--radius-lg: 14px;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
font-feature-settings: "ss01", "cv11";
}
* { box-sizing: border-box; }
html {
background: var(--bg);
}
body {
margin: 0;
color: var(--ink);
font: 16px/1.6 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
position: relative;
}
body::before,
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
}
body::before {
background:
radial-gradient(1100px 700px at 12% -8%, rgba(141, 240, 176, .14), transparent 60%),
radial-gradient(900px 600px at 92% -2%, rgba(255, 202, 110, .10), transparent 60%),
radial-gradient(800px 500px at 60% 110%, rgba(137, 199, 255, .08), transparent 60%);
animation: drift 22s ease-in-out infinite alternate;
}
body::after {
background-image:
linear-gradient(rgba(141, 240, 176, .04) 1px, transparent 1px),
linear-gradient(90deg, rgba(141, 240, 176, .04) 1px, transparent 1px);
background-size: 44px 44px, 44px 44px;
mask-image: radial-gradient(ellipse at center, black 50%, transparent 95%);
opacity: .5;
}
@keyframes drift {
0% { transform: translate3d(0, 0, 0); }
100% { transform: translate3d(-3%, -2%, 0); }
}
a { color: inherit; text-decoration: none; }
::selection { background: rgba(141, 240, 176, .35); color: #08100b; }
.shell {
width: calc(100% - 40px);
max-width: 1200px;
margin: 0 auto;
}
/* ───── Top bar ───── */
.topbar {
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid rgba(141, 240, 176, .12);
background: rgba(11, 16, 14, .72);
backdrop-filter: saturate(160%) blur(18px);
-webkit-backdrop-filter: saturate(160%) blur(18px);
}
.nav {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
min-width: 0;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
font-weight: 800;
letter-spacing: -.01em;
font-size: 17px;
}
.brand:hover .mark { transform: rotate(45deg) scale(1.05); }
.mark {
width: 24px;
height: 24px;
border: 1px solid rgba(141, 240, 176, .55);
background:
radial-gradient(circle at 30% 30%, rgba(141, 240, 176, .55), rgba(141, 240, 176, .15) 60%, transparent 75%),
linear-gradient(135deg, rgba(141, 240, 176, .25), rgba(255, 202, 110, .15));
transform: rotate(45deg);
box-shadow:
inset 0 0 0 4px rgba(11, 16, 14, .8),
0 0 0 1px rgba(141, 240, 176, .3),
0 0 24px rgba(141, 240, 176, .25);
transition: transform .35s cubic-bezier(.2, .8, .2, 1);
}
.links {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
min-width: 0;
}
.links a, .button, .copy, .tab {
border: 1px solid rgba(238, 246, 238, .12);
border-radius: 999px;
padding: 8px 14px;
color: var(--muted);
background: rgba(255, 255, 255, .025);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: border-color .2s, color .2s, background .2s, transform .2s;
}
.links a:hover, .button:hover, .copy:hover, .tab:hover {
border-color: rgba(141, 240, 176, .55);
color: var(--ink);
background: rgba(141, 240, 176, .06);
}
/* ───── Hero ───── */
.hero {
min-height: calc(100vh - 64px);
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(360px, .95fr);
align-items: center;
gap: 56px;
padding: 72px 0 64px;
}
.hero > * { min-width: 0; }
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--amber);
font-size: 12px;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
}
.eyebrow::before {
content: "";
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--amber);
box-shadow: 0 0 12px rgba(255, 202, 110, .9);
}
h1, h2, h3 {
line-height: 1.02;
letter-spacing: -.02em;
margin: 0;
font-weight: 800;
}
h1 {
max-width: 820px;
font-size: clamp(56px, 9vw, 124px);
margin-top: 18px;
background: linear-gradient(180deg, #f4faf5 0%, #b9d5c0 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
h2 { font-size: clamp(32px, 4.4vw, 56px); margin-top: 6px; }
h3 { font-size: 20px; }
p { color: var(--muted); margin: 14px 0 0; }
.lede {
max-width: 700px;
font-size: 20px;
color: #d4e2d9;
line-height: 1.55;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 32px;
}
.button {
padding: 10px 18px;
font-weight: 600;
font-size: 14.5px;
}
.button.primary {
background: var(--mint);
color: #08100b;
border-color: var(--mint);
box-shadow: 0 0 0 1px rgba(141, 240, 176, .25), 0 14px 40px rgba(141, 240, 176, .25);
}
.button.primary:hover {
background: #a4f5c0;
border-color: #a4f5c0;
transform: translateY(-1px);
}
.button .arrow { display: inline-block; transition: transform .2s; }
.button:hover .arrow { transform: translateX(3px); }
.stats {
display: flex;
flex-wrap: wrap;
gap: 28px;
margin-top: 42px;
padding-top: 26px;
border-top: 1px solid rgba(238, 246, 238, .08);
}
.stat .num {
font-size: 28px;
font-weight: 800;
letter-spacing: -.02em;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.stat .label {
display: block;
font-size: 12px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--faint);
margin-top: 2px;
}
/* ───── Window / terminal ───── */
.window {
position: relative;
min-width: 0;
border: 1px solid rgba(141, 240, 176, .18);
border-radius: var(--radius-lg);
overflow: hidden;
background: linear-gradient(180deg, #0f1813 0%, #0a110d 100%);
box-shadow: var(--shadow-lg);
}
.window-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid rgba(238, 246, 238, .08);
background: rgba(255, 255, 255, .02);
}
.window-bar .dots {
display: inline-flex;
gap: 6px;
}
.window-bar .dots i {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255, 255, 255, .12);
}
.window-bar .dots i:nth-child(1) { background: #ff5f56; }
.window-bar .dots i:nth-child(2) { background: #ffbd2e; }
.window-bar .dots i:nth-child(3) { background: #27c93f; }
.window-bar .title {
margin-left: 8px;
color: var(--faint);
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.window-bar .right {
margin-left: auto;
color: var(--faint);
font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .08em;
}
.window pre {
margin: 0;
padding: 18px 18px 22px;
overflow: auto;
color: #dff6e5;
font: 13.5px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* token classes for syntax highlighting */
.tk-prompt { color: var(--mint); user-select: none; }
.tk-cmd { color: #ffffff; }
.tk-flag { color: var(--amber); }
.tk-str { color: #ffd9a6; }
.tk-num { color: var(--blue); }
.tk-c { color: var(--faint); font-style: italic; }
.tk-ok { color: var(--mint); }
.tk-warn { color: var(--amber); }
.tk-key { color: var(--blue); }
.tk-fn { color: var(--mint); }
.tk-type { color: var(--violet); }
.tk-pun { color: #6f8175; }
.cursor {
display: inline-block;
width: 8px;
height: 1.05em;
margin-left: 2px;
background: var(--mint);
vertical-align: -2px;
animation: blink 1.05s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
/* ───── Quick install bar ───── */
.qi {
display: flex;
align-items: center;
gap: 12px;
margin-top: 22px;
padding: 10px 12px 10px 16px;
border: 1px solid rgba(141, 240, 176, .25);
border-radius: 999px;
background: rgba(141, 240, 176, .06);
font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
width: 100%;
max-width: 560px;
min-width: 0;
box-shadow: 0 0 0 1px rgba(141, 240, 176, .1);
}
.qi .dollar { color: var(--mint); }
.qi .text { color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
.qi .copy { padding: 6px 12px; font-size: 12.5px; }
/* ───── Section headers ───── */
.section {
padding: 96px 0;
border-top: 1px solid rgba(238, 246, 238, .08);
position: relative;
}
.section-head {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
align-items: end;
gap: 36px;
margin-bottom: 36px;
}
.section-head p { max-width: 520px; }
/* ───── Command grid ───── */
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.card {
position: relative;
display: flex;
flex-direction: column;
min-height: 200px;
border: 1px solid rgba(238, 246, 238, .1);
border-radius: var(--radius);
padding: 20px;
background:
linear-gradient(180deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, .015));
overflow: hidden;
transition: transform .25s cubic-bezier(.2, .8, .2, 1), border-color .25s, background .25s;
}
.card::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
background: radial-gradient(280px 180px at var(--mx, 50%) var(--my, 0%), rgba(141, 240, 176, .18), transparent 60%);
opacity: 0;
transition: opacity .25s;
pointer-events: none;
}
.card:hover {
transform: translateY(-3px);
border-color: rgba(141, 240, 176, .4);
}
.card:hover::before { opacity: 1; }
.card-icon {
width: 36px;
height: 36px;
border: 1px solid rgba(141, 240, 176, .3);
border-radius: 9px;
display: grid;
place-items: center;
margin-bottom: 14px;
background: linear-gradient(180deg, rgba(141, 240, 176, .12), rgba(141, 240, 176, .03));
color: var(--mint);
}
.card-icon svg { width: 18px; height: 18px; }
.card-foot {
margin-top: auto;
padding-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--faint);
font-size: 12px;
}
.card-foot .arrow {
color: var(--mint);
opacity: 0;
transform: translateX(-4px);
transition: opacity .2s, transform .2s;
}
.card:hover .card-foot .arrow { opacity: 1; transform: translateX(0); }
.card code, .pill {
color: var(--mint);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.card h3 {
font-size: 21px;
margin-top: 8px;
}
.card p { font-size: 14.5px; line-height: 1.55; }
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid rgba(141, 240, 176, .25);
border-radius: 999px;
padding: 4px 10px;
background: rgba(141, 240, 176, .06);
font-size: 11.5px;
letter-spacing: .02em;
}
/* category-tinted pills (still readable on dark bg) */
.pill.amber { color: var(--amber); border-color: rgba(255, 202, 110, .3); background: rgba(255, 202, 110, .08); }
.pill.blue { color: var(--blue); border-color: rgba(137, 199, 255, .3); background: rgba(137, 199, 255, .08); }
.pill.rose { color: var(--rose); border-color: rgba(255, 143, 159, .3); background: rgba(255, 143, 159, .08); }
/* ───── Setup tabs ───── */
.tabs {
display: inline-flex;
gap: 6px;
padding: 5px;
border: 1px solid rgba(238, 246, 238, .1);
border-radius: 999px;
background: rgba(255, 255, 255, .02);
margin-bottom: 16px;
}
.tab { border: 1px solid transparent; background: transparent; padding: 7px 14px; }
.tab[aria-selected="true"] {
background: rgba(141, 240, 176, .12);
color: var(--ink);
border-color: rgba(141, 240, 176, .3);
}
.tab-panel { display: none; }
.tab-panel.is-active { display: block; }
/* ───── Split / panels ───── */
.split {
display: grid;
grid-template-columns: .9fr 1.1fr;
gap: 18px;
}
.split > *,
.command-layout > * {
min-width: 0;
}
.panel {
border: 1px solid rgba(238, 246, 238, .1);
border-radius: var(--radius);
background: rgba(255, 255, 255, .025);
padding: 22px;
min-width: 0;
}
.steps {
list-style: none;
margin: 18px 0 0;
padding: 0;
display: grid;
gap: 14px;
}
.steps li {
display: grid;
grid-template-columns: 28px 1fr;
gap: 12px;
align-items: start;
}
.steps li b {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(141, 240, 176, .14);
border: 1px solid rgba(141, 240, 176, .3);
color: var(--mint);
font-size: 13px;
font-weight: 700;
}
.steps li p { margin: 0; color: var(--ink); font-size: 14.5px; }
.steps li small { color: var(--muted); display: block; margin-top: 2px; font-size: 13px; }
.codebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 12px;
}
.codebar .pill { padding: 4px 10px; }
.copy {
color: var(--ink);
cursor: pointer;
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
padding: 6px 12px;
}
pre {
margin: 0;
max-width: 100%;
overflow: auto;
color: #dff6e5;
font: 13.5px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.panel pre { padding: 14px 16px; background: rgba(0, 0, 0, .25); border-radius: 8px; }
/* ───── Architecture map ───── */
.map {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.map .card { min-height: 150px; }
.map .card .path {
display: inline-block;
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--amber);
margin-bottom: 10px;
}
/* ───── Command page (sub-pages) ───── */
.command-hero {
padding: 80px 0 28px;
}
.command-hero h1 {
font-size: clamp(46px, 7vw, 88px);
margin-top: 14px;
}
.command-layout {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 36px;
align-items: start;
padding-bottom: 64px;
}
.side {
position: sticky;
top: 92px;
display: grid;
gap: 4px;
padding: 10px;
border: 1px solid rgba(238, 246, 238, .08);
border-radius: var(--radius);
background: rgba(255, 255, 255, .02);
}
.side a {
color: var(--muted);
border-left: 2px solid transparent;
padding: 6px 10px;
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13.5px;
transition: color .15s, background .15s, border-color .15s;
}
.side a:hover { color: var(--ink); border-left-color: var(--mint); background: rgba(141, 240, 176, .05); }
.side a[aria-current="page"] {
color: var(--mint);
border-left-color: var(--mint);
background: rgba(141, 240, 176, .08);
}
.command-layout .section {
padding: 30px 0;
border-top: 0;
}
.command-layout .section + .section { border-top: 1px solid rgba(238, 246, 238, .08); }
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
th, td {
border-bottom: 1px solid rgba(238, 246, 238, .08);
padding: 12px 10px;
text-align: left;
vertical-align: top;
font-size: 14.5px;
}
th { color: var(--ink); width: 40%; }
td { color: var(--muted); }
code {
color: #e9f7ec;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .92em;
}
/* ───── Footer ───── */
.footer {
padding: 44px 0 56px;
color: var(--faint);
border-top: 1px solid rgba(238, 246, 238, .08);
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, .25));
}
.foot-grid {
display: grid;
grid-template-columns: 1.4fr repeat(3, 1fr);
gap: 32px;
align-items: start;
}
.foot-grid h4 {
margin: 0 0 10px;
font-size: 12px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
}
.foot-grid a { display: block; color: var(--muted); padding: 4px 0; font-size: 14px; }
.foot-grid a:hover { color: var(--ink); }
.foot-bar {
margin-top: 36px;
padding-top: 18px;
border-top: 1px solid rgba(238, 246, 238, .06);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
}
/* ───── Responsive ───── */
@media (max-width: 1040px) {
.grid, .map { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.foot-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 920px) {
.hero, .split, .command-layout, .section-head { grid-template-columns: 1fr; }
.hero { padding-top: 48px; gap: 36px; }
.section { padding: 64px 0; }
.side { position: static; grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.shell { width: calc(100% - 28px); max-width: 1200px; }
.nav { align-items: flex-start; flex-wrap: wrap; padding: 10px 0; gap: 10px; }
.links { flex: 1 1 100%; width: 100%; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
.links a { padding: 6px 10px; font-size: 13px; }
h1 { font-size: 52px; }
.lede { font-size: 17.5px; }
.grid, .map { grid-template-columns: 1fr; }
.actions { display: grid; grid-template-columns: 1fr; }
.button { text-align: center; }
.stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.stat .num { font-size: 24px; }
.qi { max-width: 100%; }
.qi .text { font-size: 12.5px; }
.tabs { width: 100%; overflow-x: auto; border-radius: var(--radius); }
.tab { white-space: nowrap; }
.side { grid-template-columns: 1fr; }
.window pre, .panel pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
}
@media (prefers-reduced-motion: reduce) {
body::before { animation: none; }
.cursor { animation: none; }
.card, .button, .links a { transition: none; }
}

50
docs/assets/site.js Normal file
View File

@ -0,0 +1,50 @@
// Copy buttons
document.querySelectorAll("[data-copy]").forEach((button) => {
button.addEventListener("click", async () => {
const text = button.dataset.copyText || document.querySelector(button.dataset.copy)?.innerText.trim();
if (!text) return;
const original = button.textContent;
try {
await navigator.clipboard.writeText(text);
button.textContent = "Copied";
} catch {
button.textContent = "Select";
}
window.setTimeout(() => { button.textContent = original; }, 1300);
});
});
// Tabs
document.querySelectorAll("[data-tabs]").forEach((group) => {
const tabs = group.querySelectorAll("[role='tab']");
const panels = group.querySelectorAll("[role='tabpanel']");
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
tabs.forEach((t) => t.setAttribute("aria-selected", t === tab ? "true" : "false"));
const id = tab.getAttribute("aria-controls");
panels.forEach((p) => p.classList.toggle("is-active", p.id === id));
});
});
});
// Card spotlight follows the cursor
document.querySelectorAll(".card").forEach((card) => {
card.addEventListener("pointermove", (e) => {
const rect = card.getBoundingClientRect();
card.style.setProperty("--mx", `${e.clientX - rect.left}px`);
card.style.setProperty("--my", `${e.clientY - rect.top}px`);
});
});
// Smooth-scroll same-page anchor links
document.querySelectorAll('a[href^="#"]').forEach((a) => {
a.addEventListener("click", (e) => {
const id = a.getAttribute("href").slice(1);
if (!id) return;
const target = document.getElementById(id);
if (!target) return;
e.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", `#${id}`);
});
});

View File

@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>goplaces autocomplete</title>
<meta name="description" content="Autocomplete place and query input with goplaces.">
<link rel="stylesheet" href="../assets/site.css">
</head>
<body>
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &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

@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>goplaces details</title>
<meta name="description" content="Fetch place details by place ID with goplaces.">
<link rel="stylesheet" href="../assets/site.css">
</head>
<body>
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &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

@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>goplaces directions</title>
<meta name="description" content="Get route directions, distance, and duration with goplaces.">
<link rel="stylesheet" href="../assets/site.css">
</head>
<body>
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &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>

43
docs/commands/nearby.html Normal file
View File

@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>goplaces nearby</title>
<meta name="description" content="Search nearby places by location with goplaces.">
<link rel="stylesheet" href="../assets/site.css">
</head>
<body>
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &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>

39
docs/commands/photo.html Normal file
View File

@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>goplaces photo</title>
<meta name="description" content="Fetch a Places photo media URL with goplaces.">
<link rel="stylesheet" href="../assets/site.css">
</head>
<body>
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &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

@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>goplaces resolve</title>
<meta name="description" content="Resolve free-form location text to candidate places with goplaces.">
<link rel="stylesheet" href="../assets/site.css">
</head>
<body>
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &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>

43
docs/commands/route.html Normal file
View File

@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>goplaces route</title>
<meta name="description" content="Search for places along a route with goplaces.">
<link rel="stylesheet" href="../assets/site.css">
</head>
<body>
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &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>

49
docs/commands/search.html Normal file
View File

@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>goplaces search</title>
<meta name="description" content="Search places by text query with goplaces.">
<link rel="stylesheet" href="../assets/site.css">
</head>
<body>
<header class="topbar"><nav class="nav shell" aria-label="Primary"><a class="brand" href="../index.html"><span class="mark"></span><span>goplaces</span></a><div class="links"><a href="../index.html#commands">Commands</a><a href="../index.html#setup">Setup</a><a href="../index.html#library">Go API</a><a href="https://github.com/steipete/goplaces" rel="noopener">GitHub &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>

353
docs/index.html Normal file
View File

@ -0,0 +1,353 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>goplaces &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

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

View File

@ -0,0 +1,120 @@
package places
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestAutocompleteSuccess(t *testing.T) {
var gotRequest map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/v1/places:autocomplete" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("X-Goog-FieldMask") != autocompleteFieldMask {
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if err := json.Unmarshal(body, &gotRequest); err != nil {
t.Fatalf("decode body: %v", err)
}
_, _ = w.Write([]byte(`{
"suggestions": [
{
"placePrediction": {
"placeId": "place-1",
"text": {"text": "Coffee Bar"},
"structuredFormat": {
"mainText": {"text": "Coffee"},
"secondaryText": {"text": "Seattle"}
},
"types": ["cafe"]
}
},
{
"queryPrediction": {
"text": {"text": "coffee beans"},
"structuredFormat": {
"mainText": {"text": "coffee beans"},
"secondaryText": {"text": "query"}
}
}
}
]
}`))
}))
defer server.Close()
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
response, err := client.Autocomplete(context.Background(), AutocompleteRequest{
Input: "cof",
Limit: 5,
SessionToken: "session",
Language: "en",
Region: "US",
LocationBias: &LocationBias{Lat: 1.1, Lng: 2.2, RadiusM: 100},
})
if err != nil {
t.Fatalf("autocomplete error: %v", err)
}
if len(response.Suggestions) != 2 {
t.Fatalf("expected 2 suggestions, got %d", len(response.Suggestions))
}
if response.Suggestions[0].Kind != "place" || response.Suggestions[0].PlaceID != "place-1" {
t.Fatalf("unexpected place suggestion: %#v", response.Suggestions[0])
}
if response.Suggestions[1].Kind != "query" || response.Suggestions[1].Text != "coffee beans" {
t.Fatalf("unexpected query suggestion: %#v", response.Suggestions[1])
}
if gotRequest["input"] != "cof" {
t.Fatalf("unexpected input: %#v", gotRequest["input"])
}
if gotRequest["sessionToken"] != "session" {
t.Fatalf("unexpected session token: %#v", gotRequest["sessionToken"])
}
if gotRequest["languageCode"] != "en" {
t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"])
}
if gotRequest["regionCode"] != "US" {
t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"])
}
locationBias := gotRequest["locationBias"].(map[string]any)
if locationBias["circle"] == nil {
t.Fatalf("missing location bias circle")
}
}
func TestAutocompleteLimitTrims(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{
"suggestions": [
{"queryPrediction": {"text": {"text": "a"}}},
{"queryPrediction": {"text": {"text": "b"}}}
]
}`))
}))
defer server.Close()
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL})
response, err := client.Autocomplete(context.Background(), AutocompleteRequest{
Input: "cof",
Limit: 1,
})
if err != nil {
t.Fatalf("autocomplete error: %v", err)
}
if len(response.Suggestions) != 1 {
t.Fatalf("expected 1 suggestion, got %d", len(response.Suggestions))
}
}

View File

@ -1,5 +1,5 @@
// Package goplaces provides a Go client for the Google Places API (New).
package goplaces
package places
import (
"bytes"

View File

@ -0,0 +1,137 @@
package places
import (
"context"
"errors"
"testing"
)
func TestMissingAPIKey(t *testing.T) {
client := NewClient(Options{})
_, err := client.Search(context.Background(), SearchRequest{Query: "coffee"})
if !errors.Is(err, ErrMissingAPIKey) {
t.Fatalf("expected missing api key error")
}
}
func TestValidationErrors(t *testing.T) {
client := NewClient(Options{APIKey: "test-key", BaseURL: "http://example.com"})
_, err := client.Search(context.Background(), SearchRequest{Query: ""})
if err == nil {
t.Fatalf("expected validation error")
}
minRating := 9.0
_, err = client.Search(context.Background(), SearchRequest{Query: "coffee", Filters: &Filters{MinRating: &minRating}})
if err == nil {
t.Fatalf("expected rating error")
}
_, err = client.Search(context.Background(), SearchRequest{Query: "coffee", Limit: 42})
if err == nil {
t.Fatalf("expected limit error")
}
_, err = client.Search(context.Background(), SearchRequest{Query: "coffee", Filters: &Filters{PriceLevels: []int{9}}})
if err == nil {
t.Fatalf("expected price level error")
}
_, err = client.Search(context.Background(), SearchRequest{Query: "coffee", LocationBias: &LocationBias{Lat: 200, Lng: 0, RadiusM: 1}})
if err == nil {
t.Fatalf("expected location error")
}
_, err = client.Resolve(context.Background(), LocationResolveRequest{LocationText: ""})
if err == nil {
t.Fatalf("expected resolve error")
}
_, err = client.Resolve(context.Background(), LocationResolveRequest{LocationText: "x", Limit: 99})
if err == nil {
t.Fatalf("expected resolve limit error")
}
_, err = client.Autocomplete(context.Background(), AutocompleteRequest{Input: ""})
if err == nil {
t.Fatalf("expected autocomplete input error")
}
_, err = client.Autocomplete(context.Background(), AutocompleteRequest{Input: "x", Limit: 99})
if err == nil {
t.Fatalf("expected autocomplete limit error")
}
_, err = client.NearbySearch(context.Background(), NearbySearchRequest{})
if err == nil {
t.Fatalf("expected nearby location error")
}
_, err = client.NearbySearch(context.Background(), NearbySearchRequest{
LocationRestriction: &LocationBias{Lat: 1, Lng: 2, RadiusM: 3},
Limit: 99,
})
if err == nil {
t.Fatalf("expected nearby limit error")
}
_, err = client.PhotoMedia(context.Background(), PhotoMediaRequest{Name: ""})
if err == nil {
t.Fatalf("expected photo media name error")
}
_, err = client.Details(context.Background(), "")
if err == nil {
t.Fatalf("expected details error")
}
}
func TestNewClientDefaults(t *testing.T) {
client := NewClient(Options{APIKey: "test-key"})
if client.baseURL != DefaultBaseURL {
t.Fatalf("unexpected baseURL: %s", client.baseURL)
}
if client.routesBaseURL != defaultRoutesBaseURL {
t.Fatalf("unexpected routesBaseURL: %s", client.routesBaseURL)
}
if client.directionsBaseURL != defaultDirectionsBaseURL {
t.Fatalf("unexpected directionsBaseURL: %s", client.directionsBaseURL)
}
}
func TestNewClientCustomDirectionsBaseURL(t *testing.T) {
client := NewClient(Options{
APIKey: "test-key",
BaseURL: "https://example.com/v1/",
RoutesBaseURL: "https://routes.example.com/",
DirectionsBaseURL: "https://maps.example.com/directions/",
})
if client.baseURL != "https://example.com/v1" {
t.Fatalf("unexpected baseURL: %s", client.baseURL)
}
if client.routesBaseURL != "https://routes.example.com" {
t.Fatalf("unexpected routesBaseURL: %s", client.routesBaseURL)
}
if client.directionsBaseURL != "https://maps.example.com/directions" {
t.Fatalf("unexpected directionsBaseURL: %s", client.directionsBaseURL)
}
}
func TestMappingHelpers(t *testing.T) {
if mapLatLng(nil) != nil {
t.Fatalf("expected nil location")
}
if displayName(nil) != "" {
t.Fatalf("expected empty display name")
}
if openNow(nil) != nil {
t.Fatalf("expected nil open now")
}
if weekdayDescriptions(nil) != nil {
t.Fatalf("expected nil hours")
}
if mapPriceLevel("UNKNOWN") != nil {
t.Fatalf("expected nil price level")
}
}

View File

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

View File

@ -0,0 +1,173 @@
package places
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestDetailsSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/places/place-123" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("languageCode") != "en" {
t.Fatalf("unexpected languageCode: %s", r.URL.Query().Get("languageCode"))
}
if r.URL.Query().Get("regionCode") != "US" {
t.Fatalf("unexpected regionCode: %s", r.URL.Query().Get("regionCode"))
}
if r.Header.Get("X-Goog-FieldMask") != detailsFieldMaskBase {
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
}
_, _ = w.Write([]byte(`{
"id": "place-123",
"displayName": {"text": "Park"},
"formattedAddress": "Central",
"location": {"latitude": 10, "longitude": 20},
"rating": 4.2,
"userRatingCount": 1234,
"priceLevel": "PRICE_LEVEL_FREE",
"types": ["park"],
"regularOpeningHours": {"weekdayDescriptions": ["Mon: 9-5"]},
"currentOpeningHours": {"openNow": false},
"businessStatus": "CLOSED_TEMPORARILY",
"nationalPhoneNumber": "+1 555",
"websiteUri": "https://example.com"
}`))
}))
defer server.Close()
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
place, err := client.DetailsWithOptions(context.Background(), DetailsRequest{
PlaceID: "place-123",
Language: "en",
Region: "US",
})
if err != nil {
t.Fatalf("details error: %v", err)
}
if place.PlaceID != "place-123" {
t.Fatalf("unexpected id: %s", place.PlaceID)
}
if place.UserRatingCount == nil || *place.UserRatingCount != 1234 {
t.Fatalf("unexpected user rating count: %#v", place.UserRatingCount)
}
if place.OpenNow == nil || *place.OpenNow != false {
t.Fatalf("unexpected openNow")
}
if place.BusinessStatus != "CLOSED_TEMPORARILY" {
t.Fatalf("unexpected business status: %s", place.BusinessStatus)
}
if len(place.Hours) != 1 {
t.Fatalf("unexpected hours")
}
}
func TestDetailsWithReviews(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("X-Goog-FieldMask"), "reviews") {
t.Fatalf("expected reviews in field mask: %s", r.Header.Get("X-Goog-FieldMask"))
}
_, _ = w.Write([]byte(`{
"id": "place-123",
"reviews": [
{
"name": "places/place-123/reviews/1",
"rating": 4.5,
"text": {"text": "Great coffee", "languageCode": "en"},
"authorAttribution": {"displayName": "Alice", "uri": "https://example.com"},
"relativePublishTimeDescription": "2 weeks ago",
"publishTime": "2024-01-01T00:00:00Z",
"visitDate": {"year": 2024, "month": 1, "day": 2}
}
]
}`))
}))
defer server.Close()
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
details, err := client.DetailsWithOptions(context.Background(), DetailsRequest{
PlaceID: "place-123",
IncludeReviews: true,
})
if err != nil {
t.Fatalf("details error: %v", err)
}
if len(details.Reviews) != 1 {
t.Fatalf("expected 1 review")
}
review := details.Reviews[0]
if review.Author == nil || review.Author.DisplayName != "Alice" {
t.Fatalf("unexpected author: %#v", review.Author)
}
if review.Text == nil || review.Text.Text != "Great coffee" {
t.Fatalf("unexpected text: %#v", review.Text)
}
if review.VisitDate == nil || review.VisitDate.Year != 2024 {
t.Fatalf("unexpected visit date: %#v", review.VisitDate)
}
}
func TestDetailsWithPhotos(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("X-Goog-FieldMask"), "photos") {
t.Fatalf("expected photos in field mask: %s", r.Header.Get("X-Goog-FieldMask"))
}
_, _ = w.Write([]byte(`{
"id": "place-123",
"photos": [
{
"name": "places/place-123/photos/photo-1",
"widthPx": 1200,
"heightPx": 800,
"authorAttributions": [{"displayName": "Alice", "uri": "https://example.com"}]
}
]
}`))
}))
defer server.Close()
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
details, err := client.DetailsWithOptions(context.Background(), DetailsRequest{
PlaceID: "place-123",
IncludePhotos: true,
})
if err != nil {
t.Fatalf("details error: %v", err)
}
if len(details.Photos) != 1 {
t.Fatalf("expected 1 photo")
}
photo := details.Photos[0]
if photo.Name == "" || photo.WidthPx != 1200 {
t.Fatalf("unexpected photo: %#v", photo)
}
if len(photo.AuthorAttributions) != 1 {
t.Fatalf("unexpected photo authors: %#v", photo.AuthorAttributions)
}
}
func TestDetailsFieldMaskForRequest(t *testing.T) {
req := DetailsRequest{}
if got := detailsFieldMaskForRequest(req); got != detailsFieldMaskBase {
t.Fatalf("unexpected field mask: %s", got)
}
req.IncludeReviews = true
got := detailsFieldMaskForRequest(req)
if !strings.Contains(got, "reviews") {
t.Fatalf("expected reviews in field mask: %s", got)
}
req = DetailsRequest{IncludePhotos: true}
got = detailsFieldMaskForRequest(req)
if !strings.Contains(got, "photos") {
t.Fatalf("expected photos in field mask: %s", got)
}
req = DetailsRequest{IncludeReviews: true, IncludePhotos: true}
got = detailsFieldMaskForRequest(req)
if !strings.Contains(got, "reviews") || !strings.Contains(got, "photos") {
t.Fatalf("expected reviews and photos in field mask: %s", got)
}
}

View File

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

View File

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

View File

@ -1,7 +1,7 @@
//go:build e2e
// +build e2e
package goplaces
package places
import (
"context"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,84 @@
package places
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestNearbySearchSuccess(t *testing.T) {
var gotRequest map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/v1/places:searchNearby" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("X-Goog-FieldMask") != nearbyFieldMask {
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if err := json.Unmarshal(body, &gotRequest); err != nil {
t.Fatalf("decode body: %v", err)
}
_, _ = w.Write([]byte(`{
"places": [
{
"id": "abc",
"displayName": {"text": "Cafe"},
"formattedAddress": "123 Street",
"location": {"latitude": 1.23, "longitude": 4.56},
"rating": 4.7,
"userRatingCount": 42,
"priceLevel": "PRICE_LEVEL_MODERATE",
"types": ["cafe"],
"currentOpeningHours": {"openNow": true}
}
],
"nextPageToken": "next"
}`))
}))
defer server.Close()
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
response, err := client.NearbySearch(context.Background(), NearbySearchRequest{
LocationRestriction: &LocationBias{Lat: 40.0, Lng: -70.0, RadiusM: 500},
Limit: 5,
IncludedTypes: []string{"cafe"},
ExcludedTypes: []string{"bar"},
Language: "en",
Region: "US",
})
if err != nil {
t.Fatalf("nearby error: %v", err)
}
if len(response.Results) != 1 {
t.Fatalf("expected 1 result, got %d", len(response.Results))
}
if response.Results[0].UserRatingCount == nil || *response.Results[0].UserRatingCount != 42 {
t.Fatalf("unexpected user rating count: %#v", response.Results[0].UserRatingCount)
}
if response.NextPageToken != "next" {
t.Fatalf("unexpected token: %s", response.NextPageToken)
}
if gotRequest["maxResultCount"].(float64) != 5 {
t.Fatalf("unexpected maxResultCount: %#v", gotRequest["maxResultCount"])
}
if gotRequest["languageCode"] != "en" {
t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"])
}
if gotRequest["regionCode"] != "US" {
t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"])
}
if _, ok := gotRequest["locationRestriction"].(map[string]any); !ok {
t.Fatalf("unexpected locationRestriction: %#v", gotRequest["locationRestriction"])
}
}

View File

@ -1,4 +1,4 @@
package goplaces
package places
type searchResponse struct {
Places []placeItem `json:"places"`

View File

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

View File

@ -0,0 +1,41 @@
package places
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestPhotoMediaSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/places/place-1/photos/photo-1/media" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
query := r.URL.Query()
if query.Get("skipHttpRedirect") != "true" {
t.Fatalf("unexpected skipHttpRedirect: %s", query.Get("skipHttpRedirect"))
}
if query.Get("maxWidthPx") != "800" {
t.Fatalf("unexpected maxWidthPx: %s", query.Get("maxWidthPx"))
}
if query.Get("maxHeightPx") != "600" {
t.Fatalf("unexpected maxHeightPx: %s", query.Get("maxHeightPx"))
}
_, _ = w.Write([]byte(`{"name": "places/place-1/photos/photo-1", "photoUri": "https://example.com/photo.jpg"}`))
}))
defer server.Close()
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"})
response, err := client.PhotoMedia(context.Background(), PhotoMediaRequest{
Name: "places/place-1/photos/photo-1",
MaxWidthPx: 800,
MaxHeightPx: 600,
})
if err != nil {
t.Fatalf("photo media error: %v", err)
}
if response.PhotoURI == "" {
t.Fatalf("expected photo uri")
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,57 @@
package places
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestResolveSuccess(t *testing.T) {
var gotRequest map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Goog-FieldMask") != resolveFieldMask {
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if err := json.Unmarshal(body, &gotRequest); err != nil {
t.Fatalf("decode body: %v", err)
}
_, _ = w.Write([]byte(`{
"places": [
{
"id": "loc-1",
"displayName": {"text": "Downtown"},
"formattedAddress": "Main",
"location": {"latitude": 1, "longitude": 2},
"types": ["neighborhood"]
}
]
}`))
}))
defer server.Close()
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL})
response, err := client.Resolve(context.Background(), LocationResolveRequest{
LocationText: "Downtown",
Language: "en",
Region: "US",
})
if err != nil {
t.Fatalf("resolve error: %v", err)
}
if len(response.Results) != 1 {
t.Fatalf("expected 1 result")
}
if gotRequest["languageCode"] != "en" {
t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"])
}
if gotRequest["regionCode"] != "US" {
t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"])
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,170 @@
package places
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestSearchSuccess(t *testing.T) {
var gotRequest map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/v1/places:searchText" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("X-Goog-Api-Key") != "test-key" {
t.Fatalf("missing api key header")
}
if r.Header.Get("X-Goog-FieldMask") != searchFieldMask {
t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask"))
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if err := json.Unmarshal(body, &gotRequest); err != nil {
t.Fatalf("decode body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"places": [
{
"id": "abc",
"displayName": {"text": "Cafe"},
"formattedAddress": "123 Street",
"location": {"latitude": 1.23, "longitude": 4.56},
"rating": 4.7,
"userRatingCount": 532,
"priceLevel": "PRICE_LEVEL_MODERATE",
"types": ["cafe"],
"currentOpeningHours": {"openNow": true},
"businessStatus": "OPERATIONAL"
}
],
"nextPageToken": "next"
}`))
}))
defer server.Close()
client := NewClient(Options{
APIKey: "test-key",
BaseURL: server.URL + "/v1",
Timeout: time.Second,
})
open := true
minRating := 4.0
request := SearchRequest{
Query: "coffee",
Limit: 5,
PageToken: "token",
Language: "en",
Region: "US",
Filters: &Filters{
Keyword: "best",
Types: []string{"cafe"},
OpenNow: &open,
MinRating: &minRating,
PriceLevels: []int{2},
},
LocationBias: &LocationBias{Lat: 40.0, Lng: -70.0, RadiusM: 500},
}
response, err := client.Search(context.Background(), request)
if err != nil {
t.Fatalf("search error: %v", err)
}
if len(response.Results) != 1 {
t.Fatalf("expected 1 result, got %d", len(response.Results))
}
result := response.Results[0]
if result.PlaceID != "abc" {
t.Fatalf("unexpected place id: %s", result.PlaceID)
}
if result.Name != "Cafe" {
t.Fatalf("unexpected name: %s", result.Name)
}
if result.PriceLevel == nil || *result.PriceLevel != 2 {
t.Fatalf("unexpected price level: %#v", result.PriceLevel)
}
if result.UserRatingCount == nil || *result.UserRatingCount != 532 {
t.Fatalf("unexpected user rating count: %#v", result.UserRatingCount)
}
if result.OpenNow == nil || *result.OpenNow != true {
t.Fatalf("unexpected openNow: %#v", result.OpenNow)
}
if result.BusinessStatus != "OPERATIONAL" {
t.Fatalf("unexpected business status: %s", result.BusinessStatus)
}
if response.NextPageToken != "next" {
t.Fatalf("unexpected token: %s", response.NextPageToken)
}
if gotRequest["textQuery"] != "coffee best" {
t.Fatalf("unexpected textQuery: %#v", gotRequest["textQuery"])
}
if gotRequest["pageSize"].(float64) != 5 {
t.Fatalf("unexpected pageSize: %#v", gotRequest["pageSize"])
}
if gotRequest["pageToken"] != "token" {
t.Fatalf("unexpected pageToken: %#v", gotRequest["pageToken"])
}
if gotRequest["languageCode"] != "en" {
t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"])
}
if gotRequest["regionCode"] != "US" {
t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"])
}
}
func TestSearchHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("bad"))
}))
defer server.Close()
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL})
_, err := client.Search(context.Background(), SearchRequest{Query: "coffee"})
var apiErr *APIError
if err == nil || !errors.As(err, &apiErr) {
t.Fatalf("expected api error, got %v", err)
}
if apiErr.StatusCode != http.StatusBadRequest {
t.Fatalf("unexpected status: %d", apiErr.StatusCode)
}
}
func TestSearchInvalidJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("not-json"))
}))
defer server.Close()
client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL})
_, err := client.Search(context.Background(), SearchRequest{Query: "coffee"})
if err == nil {
t.Fatal("expected error")
}
}
func TestBuildSearchBodyOmitsEmptyPriceLevels(t *testing.T) {
request := SearchRequest{Query: "coffee", Filters: &Filters{PriceLevels: []int{9}}}
body := buildSearchBody(request)
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if bytes.Contains(payload, []byte("priceLevels")) {
t.Fatalf("unexpected priceLevels in payload")
}
}

View File

@ -1,4 +1,4 @@
package goplaces
package places
// SearchRequest defines a text search with optional filters.
type SearchRequest struct {

View File

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