feat: add business status and route modifiers
Some checks failed
ci / test (push) Has been cancelled
build / goreleaser (push) Has been cancelled

Fixes #7
Fixes #8
This commit is contained in:
Peter Steinberger 2026-05-04 03:02:58 +01:00
parent 7145bf2b1c
commit 522731953d
No known key found for this signature in database
17 changed files with 259 additions and 49 deletions

View File

@ -1,6 +1,9 @@
# Changelog
## 0.3.1 - Unreleased
## 0.4.0 - 2026-05-04
- Add `business_status` to search, nearby, details, JSON output, and human CLI output. (#8) - thanks @doomsday-rgb
- Add drive-only `--avoid-tolls`, `--avoid-highways`, and `--avoid-ferries` direction flags backed by Routes API `routeModifiers`. (#7) - thanks @gabob23
## 0.3.0 - 2026-02-14

View File

@ -9,7 +9,7 @@ Modern Go client + CLI for the Google Places API (New). Fast for humans, tidy fo
- 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, and steps (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`).
@ -20,7 +20,7 @@ Modern Go client + CLI for the Google Places API (New). Fast for humans, tidy fo
## Install / Run
Latest release: v0.3.0 (2026-02-14).
Latest release: v0.4.0 (2026-05-04).
- Homebrew: `brew install steipete/tap/goplaces`
- Go: `go install github.com/steipete/goplaces/cmd/goplaces@latest`
@ -129,6 +129,13 @@ goplaces directions --from "Pike Place Market" --to "Space Needle"
goplaces directions --from-place-id <fromId> --to-place-id <toId> --compare drive --steps
```
Driving route modifiers:
```bash
goplaces directions --from "Paris" --to "Brest" --mode drive --avoid-tolls
goplaces directions --from "Paris" --to "Brest" --mode drive --avoid-highways --avoid-ferries
```
Units (default metric):
```bash
@ -230,6 +237,8 @@ route, err := client.Route(ctx, goplaces.RouteRequest{
- Reviews are returned only when `IncludeReviews`/`--reviews` is set.
- Photos are returned only when `IncludePhotos`/`--photos` is set.
- Route search requires the Google Routes API to be enabled.
- `business_status` is returned for search, nearby, and details when Google includes it.
- Direction route modifiers (`--avoid-tolls`, `--avoid-highways`, `--avoid-ferries`) require `--mode drive`.
- Field masks are defined alongside each request (e.g. `search.go`, `details.go`, `autocomplete.go`).
- The Places API is billed and quota-limited; keep an eye on your Cloud Console quotas.
@ -252,3 +261,4 @@ Optional env overrides:
- Override the search text used in E2E: `GOOGLE_PLACES_E2E_QUERY`
- Override language code for E2E: `GOOGLE_PLACES_E2E_LANGUAGE`
- Override region code for E2E: `GOOGLE_PLACES_E2E_REGION`
- Override directions endpoints/locations: `GOOGLE_DIRECTIONS_E2E_BASE_URL`, `GOOGLE_PLACES_E2E_DIRECTIONS_FROM`, `GOOGLE_PLACES_E2E_DIRECTIONS_TO`

View File

@ -47,7 +47,8 @@ func TestSearchSuccess(t *testing.T) {
"userRatingCount": 532,
"priceLevel": "PRICE_LEVEL_MODERATE",
"types": ["cafe"],
"currentOpeningHours": {"openNow": true}
"currentOpeningHours": {"openNow": true},
"businessStatus": "OPERATIONAL"
}
],
"nextPageToken": "next"
@ -102,6 +103,9 @@ func TestSearchSuccess(t *testing.T) {
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)
}
@ -396,6 +400,7 @@ func TestDetailsSuccess(t *testing.T) {
"types": ["park"],
"regularOpeningHours": {"weekdayDescriptions": ["Mon: 9-5"]},
"currentOpeningHours": {"openNow": false},
"businessStatus": "CLOSED_TEMPORARILY",
"nationalPhoneNumber": "+1 555",
"websiteUri": "https://example.com"
}`))
@ -420,6 +425,9 @@ func TestDetailsSuccess(t *testing.T) {
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")
}

View File

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

View File

@ -40,16 +40,19 @@ var directionsUnits = map[string]struct{}{
// DirectionsRequest describes a directions query between two locations.
type DirectionsRequest struct {
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
FromPlaceID string `json:"from_place_id,omitempty"`
ToPlaceID string `json:"to_place_id,omitempty"`
FromLocation *LatLng `json:"from_location,omitempty"`
ToLocation *LatLng `json:"to_location,omitempty"`
Mode string `json:"mode,omitempty"`
Language string `json:"language,omitempty"`
Region string `json:"region,omitempty"`
Units string `json:"units,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
FromPlaceID string `json:"from_place_id,omitempty"`
ToPlaceID string `json:"to_place_id,omitempty"`
FromLocation *LatLng `json:"from_location,omitempty"`
ToLocation *LatLng `json:"to_location,omitempty"`
Mode string `json:"mode,omitempty"`
Language string `json:"language,omitempty"`
Region string `json:"region,omitempty"`
Units string `json:"units,omitempty"`
AvoidTolls bool `json:"avoid_tolls,omitempty"`
AvoidHighways bool `json:"avoid_highways,omitempty"`
AvoidFerries bool `json:"avoid_ferries,omitempty"`
}
// DirectionsResponse contains a single route summary and steps.
@ -164,6 +167,9 @@ func validateDirectionsRequest(req DirectionsRequest) error {
return ValidationError{Field: "units", Message: "must be metric or imperial"}
}
}
if (req.AvoidTolls || req.AvoidHighways || req.AvoidFerries) && req.Mode != directionsModeDrive {
return ValidationError{Field: "route_modifiers", Message: "avoid tolls/highways/ferries require drive mode"}
}
return nil
}
@ -290,6 +296,13 @@ func buildDirectionsBody(req DirectionsRequest) map[string]any {
if strings.TrimSpace(req.Region) != "" {
body["regionCode"] = strings.TrimSpace(req.Region)
}
if req.AvoidTolls || req.AvoidHighways || req.AvoidFerries {
body["routeModifiers"] = map[string]any{
"avoidTolls": req.AvoidTolls,
"avoidHighways": req.AvoidHighways,
"avoidFerries": req.AvoidFerries,
}
}
return body
}

View File

@ -124,6 +124,14 @@ func TestDirectionsUnitsValidation(t *testing.T) {
}
}
func TestDirectionsRouteModifiersRequireDrive(t *testing.T) {
req := DirectionsRequest{From: "A", To: "B", Mode: "walk", AvoidTolls: true}
err := validateDirectionsRequest(applyDirectionsDefaults(req))
if err == nil || !strings.Contains(err.Error(), "route_modifiers") {
t.Fatalf("expected route modifier validation error, got %v", err)
}
}
func TestDirectionsLocationValidation(t *testing.T) {
req := DirectionsRequest{FromPlaceID: "a", From: "b", To: "c"}
if err := validateDirectionsRequest(applyDirectionsDefaults(req)); err == nil {
@ -283,6 +291,49 @@ func TestDirectionsRequestLocaleAndImperialUnits(t *testing.T) {
}
}
func TestDirectionsRequestRouteModifiers(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != routesPath {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode body: %v", err)
}
modifiers, ok := payload["routeModifiers"].(map[string]any)
if !ok {
t.Fatalf("missing routeModifiers: %#v", payload)
}
if modifiers["avoidTolls"] != true || modifiers["avoidHighways"] != true || modifiers["avoidFerries"] != true {
t.Fatalf("unexpected routeModifiers: %#v", modifiers)
}
_, _ = w.Write([]byte(`{
"routes":[{
"legs":[{
"distanceMeters":1609,
"duration":"300s",
"localizedValues":{"distance":{"text":"1 mi"},"duration":{"text":"5 mins"}},
"steps":[]
}]
}]
}`))
}))
defer server.Close()
client := NewClient(Options{APIKey: "test-key", DirectionsBaseURL: server.URL})
_, err := client.Directions(context.Background(), DirectionsRequest{
From: "Seattle",
To: "Portland",
Mode: "drive",
AvoidTolls: true,
AvoidHighways: true,
AvoidFerries: true,
})
if err != nil {
t.Fatalf("Directions error: %v", err)
}
}
func TestDirectionsLocationBoundsValidation(t *testing.T) {
req := DirectionsRequest{
FromLocation: &LatLng{Lat: 91, Lng: 0},

View File

@ -33,9 +33,17 @@ Imperial units:
goplaces directions --from-place-id <fromId> --to-place-id <toId> --units imperial
```
Driving route modifiers:
```bash
goplaces directions --from "Paris" --to "Brest" --mode drive --avoid-tolls
goplaces directions --from "Paris" --to "Brest" --mode drive --avoid-highways --avoid-ferries
```
## Notes
- Default mode is walking.
- Default units are metric (use `--units imperial` for miles/feet).
- Use `--steps` for turn-by-turn instructions.
- Use `--compare drive` to add a driving ETA.
- Use `--avoid-tolls`, `--avoid-highways`, and `--avoid-ferries` with `--mode drive` to request drive routes that avoid those features when reasonable.

View File

@ -34,3 +34,4 @@ response, err := client.NearbySearch(ctx, goplaces.NearbySearchRequest{
- Location restriction (lat/lng/radius) is required.
- Use `IncludedTypes`/`--type` to filter result types.
- Results include `business_status` when Google returns it.

View File

@ -148,6 +148,46 @@ func TestE2ENearbySearch(t *testing.T) {
}
}
func TestE2EDirectionsAvoidTolls(t *testing.T) {
apiKey := os.Getenv("GOOGLE_PLACES_API_KEY")
if apiKey == "" {
t.Skip("GOOGLE_PLACES_API_KEY not set")
}
from := os.Getenv("GOOGLE_PLACES_E2E_DIRECTIONS_FROM")
if from == "" {
from = "Paris, France"
}
to := os.Getenv("GOOGLE_PLACES_E2E_DIRECTIONS_TO")
if to == "" {
to = "Brest, France"
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
client := NewClient(Options{
APIKey: apiKey,
DirectionsBaseURL: os.Getenv("GOOGLE_DIRECTIONS_E2E_BASE_URL"),
Timeout: 15 * time.Second,
})
response, err := client.Directions(ctx, DirectionsRequest{
From: from,
To: to,
Mode: "drive",
AvoidTolls: true,
Language: "en",
Region: "FR",
})
if err != nil {
t.Fatalf("directions error: %v", err)
}
if response.DistanceMeters == 0 || response.DurationSeconds == 0 {
t.Fatalf("expected distance and duration, got %#v", response)
}
}
func TestE2EPhotoMedia(t *testing.T) {
apiKey := os.Getenv("GOOGLE_PLACES_API_KEY")
if apiKey == "" {

View File

@ -162,3 +162,55 @@ func TestRunDirectionsWithEqualsFlags(t *testing.T) {
t.Fatalf("unexpected stdout: %s", stdout.String())
}
}
func TestRunDirectionsWithAvoidFlags(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != directionsPath {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload["travelMode"] != directionsModeDriveAPI {
t.Fatalf("unexpected mode: %#v", payload["travelMode"])
}
modifiers, ok := payload["routeModifiers"].(map[string]any)
if !ok {
t.Fatalf("missing routeModifiers: %#v", payload)
}
if modifiers["avoidTolls"] != true || modifiers["avoidHighways"] != true || modifiers["avoidFerries"] != true {
t.Fatalf("unexpected routeModifiers: %#v", modifiers)
}
_, _ = w.Write([]byte(`{
"routes":[{"legs":[{"distanceMeters":1000,"duration":"600s","localizedValues":{"distance":{"text":"1 km"},"duration":{"text":"10 mins"}},"steps":[]}]}]
}`))
}))
defer server.Close()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{
"directions",
"--from=A",
"--to=B",
"--api-key=test-key",
"--directions-base-url=" + server.URL,
"--mode=drive",
"--avoid-tolls",
"--avoid-highways",
"--avoid-ferries",
"--json",
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d (stdout=%s stderr=%s)", exitCode, stdout.String(), stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("unexpected stderr: %s", stderr.String())
}
if !strings.Contains(stdout.String(), "\"mode\": \"DRIVING\"") {
t.Fatalf("unexpected stdout: %s", stdout.String())
}
}

View File

@ -9,20 +9,23 @@ import (
// DirectionsCmd fetches directions between two points.
type DirectionsCmd struct {
From string `help:"Origin address or place name."`
To string `help:"Destination address or place name."`
FromPlaceID string `help:"Origin place ID." name:"from-place-id"`
ToPlaceID string `help:"Destination place ID." name:"to-place-id"`
FromLat *float64 `help:"Origin latitude." name:"from-lat"`
FromLng *float64 `help:"Origin longitude." name:"from-lng"`
ToLat *float64 `help:"Destination latitude." name:"to-lat"`
ToLng *float64 `help:"Destination longitude." name:"to-lng"`
Mode string `help:"Travel mode: walk, drive, bicycle, transit." default:"walk"`
Compare string `help:"Compare with another mode: walk, drive, bicycle, transit."`
Steps bool `help:"Include step-by-step instructions."`
Units string `help:"Units: metric or imperial." default:"metric"`
Language string `help:"BCP-47 language code (e.g. en, en-US)."`
Region string `help:"CLDR region code (e.g. US, DE)."`
From string `help:"Origin address or place name."`
To string `help:"Destination address or place name."`
FromPlaceID string `help:"Origin place ID." name:"from-place-id"`
ToPlaceID string `help:"Destination place ID." name:"to-place-id"`
FromLat *float64 `help:"Origin latitude." name:"from-lat"`
FromLng *float64 `help:"Origin longitude." name:"from-lng"`
ToLat *float64 `help:"Destination latitude." name:"to-lat"`
ToLng *float64 `help:"Destination longitude." name:"to-lng"`
Mode string `help:"Travel mode: walk, drive, bicycle, transit." default:"walk"`
Compare string `help:"Compare with another mode: walk, drive, bicycle, transit."`
Steps bool `help:"Include step-by-step instructions."`
Units string `help:"Units: metric or imperial." default:"metric"`
AvoidTolls bool `help:"Avoid toll roads when driving."`
AvoidHighways bool `help:"Avoid highways when driving."`
AvoidFerries bool `help:"Avoid ferries when driving."`
Language string `help:"BCP-47 language code (e.g. en, en-US)."`
Region string `help:"CLDR region code (e.g. US, DE)."`
}
// Run executes the directions command.
@ -43,14 +46,17 @@ func (c *DirectionsCmd) Run(app *App) error {
}
request := goplaces.DirectionsRequest{
From: c.From,
To: c.To,
FromPlaceID: c.FromPlaceID,
ToPlaceID: c.ToPlaceID,
Mode: primaryMode,
Units: c.Units,
Language: c.Language,
Region: c.Region,
From: c.From,
To: c.To,
FromPlaceID: c.FromPlaceID,
ToPlaceID: c.ToPlaceID,
Mode: primaryMode,
Units: c.Units,
AvoidTolls: c.AvoidTolls,
AvoidHighways: c.AvoidHighways,
AvoidFerries: c.AvoidFerries,
Language: c.Language,
Region: c.Region,
}
if c.FromLat != nil || c.FromLng != nil {
if c.FromLat == nil || c.FromLng == nil {
@ -74,6 +80,9 @@ func (c *DirectionsCmd) Run(app *App) error {
if compareMode != "" {
compareRequest := request
compareRequest.Mode = compareMode
compareRequest.AvoidTolls = false
compareRequest.AvoidHighways = false
compareRequest.AvoidFerries = false
second, err := app.client.Directions(context.Background(), compareRequest)
if err != nil {
return err

View File

@ -237,6 +237,7 @@ func writePlaceSummary(out *bytes.Buffer, color Color, place goplaces.PlaceSumma
writeRating(out, color, place.Rating, place.UserRatingCount, place.PriceLevel)
writeTypes(out, color, place.Types)
writeOpenNow(out, color, place.OpenNow)
writeLine(out, color, "Status", place.BusinessStatus)
}
func writeAutocompleteSuggestion(out *bytes.Buffer, color Color, suggestion goplaces.AutocompleteSuggestion) {
@ -255,6 +256,7 @@ func writePlaceDetails(out *bytes.Buffer, color Color, place goplaces.PlaceDetai
writeRating(out, color, place.Rating, place.UserRatingCount, place.PriceLevel)
writeTypes(out, color, place.Types)
writeOpenNow(out, color, place.OpenNow)
writeLine(out, color, "Status", place.BusinessStatus)
writeLine(out, color, "Phone", place.Phone)
writeLine(out, color, "Website", place.Website)
writePhotos(out, color, place.Photos)

View File

@ -25,6 +25,7 @@ func TestRenderSearch(t *testing.T) {
PriceLevel: &level,
Types: []string{"cafe", "coffee_shop"},
OpenNow: &open,
BusinessStatus: "OPERATIONAL",
},
},
NextPageToken: "next",
@ -43,6 +44,9 @@ func TestRenderSearch(t *testing.T) {
if !strings.Contains(output, "Open now") {
t.Fatalf("missing open now")
}
if !strings.Contains(output, "Status: OPERATIONAL") {
t.Fatalf("missing status")
}
if !strings.Contains(output, "next") {
t.Fatalf("missing next page token")
}
@ -226,16 +230,17 @@ func TestRenderDetailsAndResolve(t *testing.T) {
open := false
level := 0
details := goplaces.PlaceDetails{
PlaceID: "place-1",
Name: "Park",
Address: "Central",
Rating: floatPtr(4.2),
PriceLevel: &level,
Types: []string{"park"},
Phone: "+1 555",
Website: "https://example.com",
Hours: []string{"Mon: 9-5"},
OpenNow: &open,
PlaceID: "place-1",
Name: "Park",
Address: "Central",
Rating: floatPtr(4.2),
PriceLevel: &level,
Types: []string{"park"},
Phone: "+1 555",
Website: "https://example.com",
Hours: []string{"Mon: 9-5"},
OpenNow: &open,
BusinessStatus: "CLOSED_TEMPORARILY",
Photos: []goplaces.Photo{
{Name: "places/place-1/photos/photo-1", WidthPx: 1200, HeightPx: 800},
},
@ -258,6 +263,9 @@ func TestRenderDetailsAndResolve(t *testing.T) {
if !strings.Contains(output, "Reviews:") || !strings.Contains(output, "Alice") {
t.Fatalf("missing reviews output: %s", output)
}
if !strings.Contains(output, "Status: CLOSED_TEMPORARILY") {
t.Fatalf("missing status output: %s", output)
}
resolve := goplaces.LocationResolveResponse{
Results: []goplaces.ResolvedLocation{{PlaceID: "loc-1", Name: "Downtown"}},

View File

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

View File

@ -16,6 +16,7 @@ type placeItem struct {
Types []string `json:"types,omitempty"`
CurrentOpeningHours *openingHours `json:"currentOpeningHours,omitempty"`
RegularOpeningHours *openingHours `json:"regularOpeningHours,omitempty"`
BusinessStatus string `json:"businessStatus,omitempty"`
NationalPhoneNumber string `json:"nationalPhoneNumber,omitempty"`
WebsiteURI string `json:"websiteUri,omitempty"`
Reviews []reviewPayload `json:"reviews,omitempty"`

View File

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

View File

@ -93,6 +93,7 @@ type PlaceSummary struct {
PriceLevel *int `json:"price_level,omitempty"`
Types []string `json:"types,omitempty"`
OpenNow *bool `json:"open_now,omitempty"`
BusinessStatus string `json:"business_status,omitempty"`
}
// PlaceDetails is a detailed view of a place.
@ -109,6 +110,7 @@ type PlaceDetails struct {
Website string `json:"website,omitempty"`
Hours []string `json:"hours,omitempty"`
OpenNow *bool `json:"open_now,omitempty"`
BusinessStatus string `json:"business_status,omitempty"`
Reviews []Review `json:"reviews,omitempty"`
Photos []Photo `json:"photos,omitempty"`
}