feat: add autocomplete support

This commit is contained in:
Peter Steinberger 2026-01-02 21:24:55 +01:00
parent 03d23c05fc
commit 715f4d8aa7
22 changed files with 1158 additions and 500 deletions

View File

@ -5,6 +5,7 @@ Modern Go client + CLI for the Google Places API (New). Fast for humans, tidy fo
## Highlights
- Text search with filters: keyword, type, open now, min rating, price levels.
- Autocomplete suggestions for places + queries (session tokens supported).
- Location bias (lat/lng/radius) and pagination tokens.
- Place details: hours, phone, website, rating, price, types.
- Optional reviews in details (`--reviews` / `IncludeReviews`).
@ -66,6 +67,7 @@ goplaces [--api-key=KEY] [--base-url=URL] [--timeout=10s] [--json] [--no-color]
<command>
Commands:
autocomplete Autocomplete places and queries.
search Search places by text query.
details Fetch place details by place ID.
resolve Resolve a location string to candidate places.
@ -84,6 +86,12 @@ Pagination:
goplaces search "pizza" --page-token "NEXT_PAGE_TOKEN"
```
Autocomplete:
```bash
goplaces autocomplete "cof" --session-token "goplaces-demo" --limit 5 --language en --region US
```
Details (with reviews):
```bash
@ -132,6 +140,14 @@ details, err := client.DetailsWithOptions(ctx, goplaces.DetailsRequest{
Region: "US",
IncludeReviews: true,
})
autocomplete, err := client.Autocomplete(ctx, goplaces.AutocompleteRequest{
Input: "cof",
SessionToken: "goplaces-demo",
Limit: 5,
Language: "en",
Region: "US",
})
```
## Notes
@ -139,7 +155,7 @@ details, err := client.DetailsWithOptions(ctx, goplaces.DetailsRequest{
- `Filters.Types` maps to `includedType` (Google accepts a single value). Only the first type is sent.
- Price levels map to Google enums: `0` (free) → `4` (very expensive).
- Reviews are returned only when `IncludeReviews`/`--reviews` is set.
- Field masks are defined in `client.go`; extend them if you need more fields.
- 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.
## Testing

163
autocomplete.go Normal file
View File

@ -0,0 +1,163 @@
package goplaces
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
const autocompleteFieldMask = "suggestions.placePrediction.placeId,suggestions.placePrediction.place,suggestions.placePrediction.text,suggestions.placePrediction.structuredFormat,suggestions.placePrediction.types,suggestions.placePrediction.distanceMeters,suggestions.queryPrediction.text,suggestions.queryPrediction.structuredFormat"
// Autocomplete returns place and query suggestions for an input string.
func (c *Client) Autocomplete(ctx context.Context, req AutocompleteRequest) (AutocompleteResponse, error) {
req = applyAutocompleteDefaults(req)
if err := validateAutocompleteRequest(req); err != nil {
return AutocompleteResponse{}, err
}
body := map[string]any{
"input": strings.TrimSpace(req.Input),
}
if strings.TrimSpace(req.SessionToken) != "" {
body["sessionToken"] = strings.TrimSpace(req.SessionToken)
}
if strings.TrimSpace(req.Language) != "" {
body["languageCode"] = strings.TrimSpace(req.Language)
}
if strings.TrimSpace(req.Region) != "" {
body["regionCode"] = strings.TrimSpace(req.Region)
}
if req.LocationBias != nil {
body["locationBias"] = circlePayload(req.LocationBias)
}
endpoint, err := c.buildURL("/places:autocomplete", nil)
if err != nil {
return AutocompleteResponse{}, err
}
payload, err := c.doRequest(ctx, http.MethodPost, endpoint, body, autocompleteFieldMask)
if err != nil {
return AutocompleteResponse{}, err
}
var response autocompleteResponsePayload
if err := json.Unmarshal(payload, &response); err != nil {
return AutocompleteResponse{}, fmt.Errorf("goplaces: decode autocomplete response: %w", err)
}
suggestions := make([]AutocompleteSuggestion, 0, len(response.Suggestions))
for _, suggestion := range response.Suggestions {
mapped, ok := mapAutocompleteSuggestion(suggestion)
if !ok {
continue
}
suggestions = append(suggestions, mapped)
}
if req.Limit > 0 && len(suggestions) > req.Limit {
suggestions = suggestions[:req.Limit]
}
return AutocompleteResponse{Suggestions: suggestions}, nil
}
type autocompleteResponsePayload struct {
Suggestions []autocompleteSuggestionPayload `json:"suggestions"`
}
type autocompleteSuggestionPayload struct {
PlacePrediction *placePredictionPayload `json:"placePrediction,omitempty"`
QueryPrediction *queryPredictionPayload `json:"queryPrediction,omitempty"`
}
type placePredictionPayload struct {
PlaceID string `json:"placeId,omitempty"`
Place string `json:"place,omitempty"`
Text *autocompleteTextPayload `json:"text,omitempty"`
StructuredFormat *structuredFormatPayload `json:"structuredFormat,omitempty"`
Types []string `json:"types,omitempty"`
DistanceMeters *int `json:"distanceMeters,omitempty"`
}
type queryPredictionPayload struct {
Text *autocompleteTextPayload `json:"text,omitempty"`
StructuredFormat *structuredFormatPayload `json:"structuredFormat,omitempty"`
}
type structuredFormatPayload struct {
MainText *autocompleteTextPayload `json:"mainText,omitempty"`
SecondaryText *autocompleteTextPayload `json:"secondaryText,omitempty"`
}
type autocompleteTextPayload struct {
Text string `json:"text,omitempty"`
}
func mapAutocompleteSuggestion(payload autocompleteSuggestionPayload) (AutocompleteSuggestion, bool) {
if payload.PlacePrediction != nil {
prediction := payload.PlacePrediction
structured := prediction.StructuredFormat
return AutocompleteSuggestion{
Kind: "place",
PlaceID: prediction.PlaceID,
Place: prediction.Place,
Text: autocompleteText(prediction.Text),
MainText: autocompleteText(structuredText(structured, true)),
SecondaryText: autocompleteText(structuredText(structured, false)),
Types: prediction.Types,
DistanceMeters: prediction.DistanceMeters,
}, true
}
if payload.QueryPrediction != nil {
prediction := payload.QueryPrediction
structured := prediction.StructuredFormat
return AutocompleteSuggestion{
Kind: "query",
Text: autocompleteText(prediction.Text),
MainText: autocompleteText(structuredText(structured, true)),
SecondaryText: autocompleteText(structuredText(structured, false)),
}, true
}
return AutocompleteSuggestion{}, false
}
func structuredText(payload *structuredFormatPayload, main bool) *autocompleteTextPayload {
if payload == nil {
return nil
}
if main {
return payload.MainText
}
return payload.SecondaryText
}
func autocompleteText(payload *autocompleteTextPayload) string {
if payload == nil {
return ""
}
return payload.Text
}
func applyAutocompleteDefaults(req AutocompleteRequest) AutocompleteRequest {
if req.Limit == 0 {
req.Limit = defaultAutocompleteLimit
}
return req
}
func validateAutocompleteRequest(req AutocompleteRequest) error {
if strings.TrimSpace(req.Input) == "" {
return ValidationError{Field: "input", Message: "required"}
}
if req.Limit < 1 || req.Limit > maxAutocompleteLimit {
return ValidationError{Field: "limit", Message: fmt.Sprintf("must be 1-%d", maxAutocompleteLimit)}
}
if req.LocationBias != nil {
if err := validateLocationBias(req.LocationBias); err != nil {
return err
}
}
return nil
}

493
client.go
View File

@ -17,44 +17,6 @@ import (
// DefaultBaseURL is the default endpoint for the Places API (New).
const DefaultBaseURL = "https://places.googleapis.com/v1"
const (
defaultSearchLimit = 10
defaultResolveLimit = 5
maxSearchLimit = 20
maxResolveLimit = 10
)
const (
searchFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.priceLevel,places.types,places.currentOpeningHours,nextPageToken"
detailsFieldMaskBase = "id,displayName,formattedAddress,location,rating,priceLevel,types,regularOpeningHours,currentOpeningHours,nationalPhoneNumber,websiteUri"
detailsFieldMaskReview = "reviews"
resolveFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.types"
)
const (
priceLevelFree = "PRICE_LEVEL_FREE"
priceLevelInexpensive = "PRICE_LEVEL_INEXPENSIVE"
priceLevelModerate = "PRICE_LEVEL_MODERATE"
priceLevelExpensive = "PRICE_LEVEL_EXPENSIVE"
priceLevelVeryExp = "PRICE_LEVEL_VERY_EXPENSIVE"
)
var priceLevelToEnum = map[int]string{
0: priceLevelFree,
1: priceLevelInexpensive,
2: priceLevelModerate,
3: priceLevelExpensive,
4: priceLevelVeryExp,
}
var enumToPriceLevel = map[string]int{
priceLevelFree: 0,
priceLevelInexpensive: 1,
priceLevelModerate: 2,
priceLevelExpensive: 3,
priceLevelVeryExp: 4,
}
// Client wraps access to the Google Places API.
type Client struct {
apiKey string
@ -93,112 +55,6 @@ func NewClient(opts Options) *Client {
}
}
// Search performs a text search with optional filters.
func (c *Client) Search(ctx context.Context, req SearchRequest) (SearchResponse, error) {
req = applySearchDefaults(req)
if err := validateSearchRequest(req); err != nil {
return SearchResponse{}, err
}
body := buildSearchBody(req)
endpoint, err := c.buildURL("/places:searchText", nil)
if err != nil {
return SearchResponse{}, err
}
payload, err := c.doRequest(ctx, http.MethodPost, endpoint, body, searchFieldMask)
if err != nil {
return SearchResponse{}, err
}
var response searchResponse
if err := json.Unmarshal(payload, &response); err != nil {
return SearchResponse{}, fmt.Errorf("goplaces: decode search response: %w", err)
}
results := make([]PlaceSummary, 0, len(response.Places))
for _, place := range response.Places {
results = append(results, mapPlaceSummary(place))
}
return SearchResponse{
Results: results,
NextPageToken: response.NextPageToken,
}, nil
}
// Details fetches details for a specific place ID.
func (c *Client) Details(ctx context.Context, placeID string) (PlaceDetails, error) {
return c.DetailsWithOptions(ctx, DetailsRequest{PlaceID: placeID})
}
// DetailsWithOptions fetches place details with locale hints.
func (c *Client) DetailsWithOptions(ctx context.Context, req DetailsRequest) (PlaceDetails, error) {
placeID := strings.TrimSpace(req.PlaceID)
if placeID == "" {
return PlaceDetails{}, ValidationError{Field: "place_id", Message: "required"}
}
endpoint, err := c.buildURL("/places/"+placeID, map[string]string{
"languageCode": strings.TrimSpace(req.Language),
"regionCode": strings.TrimSpace(req.Region),
})
if err != nil {
return PlaceDetails{}, err
}
payload, err := c.doRequest(ctx, http.MethodGet, endpoint, nil, detailsFieldMaskForRequest(req))
if err != nil {
return PlaceDetails{}, err
}
var place placeItem
if err := json.Unmarshal(payload, &place); err != nil {
return PlaceDetails{}, fmt.Errorf("goplaces: decode place details: %w", err)
}
return mapPlaceDetails(place), nil
}
// Resolve converts a free-form location string into candidate places.
func (c *Client) Resolve(ctx context.Context, req LocationResolveRequest) (LocationResolveResponse, error) {
req = applyResolveDefaults(req)
if err := validateResolveRequest(req); err != nil {
return LocationResolveResponse{}, err
}
body := map[string]any{
"textQuery": req.LocationText,
"pageSize": req.Limit,
}
if strings.TrimSpace(req.Language) != "" {
body["languageCode"] = strings.TrimSpace(req.Language)
}
if strings.TrimSpace(req.Region) != "" {
body["regionCode"] = strings.TrimSpace(req.Region)
}
endpoint, err := c.buildURL("/places:searchText", nil)
if err != nil {
return LocationResolveResponse{}, err
}
payload, err := c.doRequest(ctx, http.MethodPost, endpoint, body, resolveFieldMask)
if err != nil {
return LocationResolveResponse{}, err
}
var response searchResponse
if err := json.Unmarshal(payload, &response); err != nil {
return LocationResolveResponse{}, fmt.Errorf("goplaces: decode resolve response: %w", err)
}
results := make([]ResolvedLocation, 0, len(response.Places))
for _, place := range response.Places {
results = append(results, mapResolvedLocation(place))
}
return LocationResolveResponse{Results: results}, nil
}
func (c *Client) doRequest(
ctx context.Context,
method string,
@ -276,352 +132,3 @@ func (c *Client) buildURL(path string, query map[string]string) (string, error)
parsed.RawQuery = values.Encode()
return parsed.String(), nil
}
func buildSearchBody(req SearchRequest) map[string]any {
textQuery := req.Query
if req.Filters != nil && strings.TrimSpace(req.Filters.Keyword) != "" {
// Google expects a single text query; append keywords here.
textQuery = strings.TrimSpace(textQuery + " " + req.Filters.Keyword)
}
body := map[string]any{
"textQuery": textQuery,
"pageSize": req.Limit,
}
if strings.TrimSpace(req.Language) != "" {
body["languageCode"] = strings.TrimSpace(req.Language)
}
if strings.TrimSpace(req.Region) != "" {
body["regionCode"] = strings.TrimSpace(req.Region)
}
if req.PageToken != "" {
body["pageToken"] = req.PageToken
}
if req.LocationBias != nil {
// Places API expects a circular bias object.
body["locationBias"] = map[string]any{
"circle": map[string]any{
"center": map[string]any{
"latitude": req.LocationBias.Lat,
"longitude": req.LocationBias.Lng,
},
"radius": req.LocationBias.RadiusM,
},
}
}
if req.Filters != nil {
filters := req.Filters
if len(filters.Types) > 0 {
// API accepts a single includedType; use the first value.
body["includedType"] = filters.Types[0]
}
if filters.OpenNow != nil {
body["openNow"] = *filters.OpenNow
}
if filters.MinRating != nil {
body["minRating"] = *filters.MinRating
}
if len(filters.PriceLevels) > 0 {
levels := make([]string, 0, len(filters.PriceLevels))
for _, level := range filters.PriceLevels {
if mapped, ok := priceLevelToEnum[level]; ok {
levels = append(levels, mapped)
}
}
if len(levels) > 0 {
body["priceLevels"] = levels
}
}
}
return body
}
func detailsFieldMaskForRequest(req DetailsRequest) string {
if req.IncludeReviews {
// Reviews are heavy; opt-in to include them.
return detailsFieldMaskBase + "," + detailsFieldMaskReview
}
return detailsFieldMaskBase
}
func mapPlaceSummary(place placeItem) PlaceSummary {
return PlaceSummary{
PlaceID: place.ID,
Name: displayName(place.DisplayName),
Address: place.FormattedAddress,
Location: mapLatLng(place.Location),
Rating: place.Rating,
PriceLevel: mapPriceLevel(place.PriceLevel),
Types: place.Types,
OpenNow: openNow(place.CurrentOpeningHours),
}
}
func mapPlaceDetails(place placeItem) PlaceDetails {
return PlaceDetails{
PlaceID: place.ID,
Name: displayName(place.DisplayName),
Address: place.FormattedAddress,
Location: mapLatLng(place.Location),
Rating: place.Rating,
PriceLevel: mapPriceLevel(place.PriceLevel),
Types: place.Types,
Phone: place.NationalPhoneNumber,
Website: place.WebsiteURI,
Hours: weekdayDescriptions(place.RegularOpeningHours),
OpenNow: openNow(place.CurrentOpeningHours),
Reviews: mapReviews(place.Reviews),
}
}
func mapResolvedLocation(place placeItem) ResolvedLocation {
return ResolvedLocation{
PlaceID: place.ID,
Name: displayName(place.DisplayName),
Address: place.FormattedAddress,
Location: mapLatLng(place.Location),
Types: place.Types,
}
}
func mapReviews(reviews []reviewPayload) []Review {
if len(reviews) == 0 {
return nil
}
mapped := make([]Review, 0, len(reviews))
for _, review := range reviews {
mapped = append(mapped, Review{
Name: review.Name,
RelativePublishTimeDescription: review.RelativePublishTimeDescription,
Text: mapLocalizedText(review.Text),
OriginalText: mapLocalizedText(review.OriginalText),
Rating: review.Rating,
Author: mapAuthorAttribution(review.AuthorAttribution),
PublishTime: review.PublishTime,
FlagContentURI: review.FlagContentURI,
GoogleMapsURI: review.GoogleMapsURI,
VisitDate: mapVisitDate(review.VisitDate),
})
}
return mapped
}
func mapLocalizedText(text *localizedTextPayload) *LocalizedText {
if text == nil {
return nil
}
// Avoid emitting empty text structs downstream.
if strings.TrimSpace(text.Text) == "" && strings.TrimSpace(text.LanguageCode) == "" {
return nil
}
return &LocalizedText{
Text: text.Text,
LanguageCode: text.LanguageCode,
}
}
func mapAuthorAttribution(author *authorAttributionPayload) *AuthorAttribution {
if author == nil {
return nil
}
// Drop empty attribution blocks to keep JSON clean.
if strings.TrimSpace(author.DisplayName) == "" && strings.TrimSpace(author.URI) == "" && strings.TrimSpace(author.PhotoURI) == "" {
return nil
}
return &AuthorAttribution{
DisplayName: author.DisplayName,
URI: author.URI,
PhotoURI: author.PhotoURI,
}
}
func mapVisitDate(date *visitDatePayload) *ReviewVisitDate {
if date == nil {
return nil
}
// Treat zeroed dates as missing.
if date.Year == 0 && date.Month == 0 && date.Day == 0 {
return nil
}
return &ReviewVisitDate{
Year: date.Year,
Month: date.Month,
Day: date.Day,
}
}
func mapLatLng(loc *location) *LatLng {
if loc == nil {
return nil
}
return &LatLng{Lat: loc.Latitude, Lng: loc.Longitude}
}
func displayName(name *displayNamePayload) string {
if name == nil {
return ""
}
return name.Text
}
func openNow(hours *openingHours) *bool {
if hours == nil {
return nil
}
return hours.OpenNow
}
func weekdayDescriptions(hours *openingHours) []string {
if hours == nil {
return nil
}
return hours.WeekdayDescriptions
}
func mapPriceLevel(value string) *int {
if value == "" {
return nil
}
if mapped, ok := enumToPriceLevel[value]; ok {
return &mapped
}
return nil
}
func applySearchDefaults(req SearchRequest) SearchRequest {
if req.Limit == 0 {
req.Limit = defaultSearchLimit
}
return req
}
func applyResolveDefaults(req LocationResolveRequest) LocationResolveRequest {
if req.Limit == 0 {
req.Limit = defaultResolveLimit
}
return req
}
func validateSearchRequest(req SearchRequest) error {
if strings.TrimSpace(req.Query) == "" {
return ValidationError{Field: "query", Message: "required"}
}
if req.Limit < 1 || req.Limit > maxSearchLimit {
return ValidationError{Field: "limit", Message: fmt.Sprintf("must be 1-%d", maxSearchLimit)}
}
if req.Filters != nil {
if req.Filters.MinRating != nil {
if *req.Filters.MinRating < 0 || *req.Filters.MinRating > 5 {
return ValidationError{Field: "filters.min_rating", Message: "must be 0-5"}
}
}
for _, level := range req.Filters.PriceLevels {
if level < 0 || level > 4 {
return ValidationError{Field: "filters.price_levels", Message: "must be 0-4"}
}
}
}
if req.LocationBias != nil {
if err := validateLocationBias(req.LocationBias); err != nil {
return err
}
}
return nil
}
func validateResolveRequest(req LocationResolveRequest) error {
if strings.TrimSpace(req.LocationText) == "" {
return ValidationError{Field: "location_text", Message: "required"}
}
if req.Limit < 1 || req.Limit > maxResolveLimit {
return ValidationError{Field: "limit", Message: fmt.Sprintf("must be 1-%d", maxResolveLimit)}
}
return nil
}
func validateLocationBias(bias *LocationBias) error {
if bias == nil {
return nil
}
if bias.RadiusM <= 0 {
return ValidationError{Field: "location_bias.radius_m", Message: "must be > 0"}
}
if bias.Lat < -90 || bias.Lat > 90 {
return ValidationError{Field: "location_bias.lat", Message: "must be -90..90"}
}
if bias.Lng < -180 || bias.Lng > 180 {
return ValidationError{Field: "location_bias.lng", Message: "must be -180..180"}
}
return nil
}
type searchResponse struct {
Places []placeItem `json:"places"`
NextPageToken string `json:"nextPageToken"`
}
type placeItem struct {
ID string `json:"id"`
DisplayName *displayNamePayload `json:"displayName,omitempty"`
FormattedAddress string `json:"formattedAddress,omitempty"`
Location *location `json:"location,omitempty"`
Rating *float64 `json:"rating,omitempty"`
PriceLevel string `json:"priceLevel,omitempty"`
Types []string `json:"types,omitempty"`
CurrentOpeningHours *openingHours `json:"currentOpeningHours,omitempty"`
RegularOpeningHours *openingHours `json:"regularOpeningHours,omitempty"`
NationalPhoneNumber string `json:"nationalPhoneNumber,omitempty"`
WebsiteURI string `json:"websiteUri,omitempty"`
Reviews []reviewPayload `json:"reviews,omitempty"`
}
type displayNamePayload struct {
Text string `json:"text"`
}
type location struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
type openingHours struct {
OpenNow *bool `json:"openNow,omitempty"`
WeekdayDescriptions []string `json:"weekdayDescriptions,omitempty"`
}
type reviewPayload struct {
Name string `json:"name,omitempty"`
RelativePublishTimeDescription string `json:"relativePublishTimeDescription,omitempty"`
Text *localizedTextPayload `json:"text,omitempty"`
OriginalText *localizedTextPayload `json:"originalText,omitempty"`
Rating *float64 `json:"rating,omitempty"`
AuthorAttribution *authorAttributionPayload `json:"authorAttribution,omitempty"`
PublishTime string `json:"publishTime,omitempty"`
FlagContentURI string `json:"flagContentUri,omitempty"`
GoogleMapsURI string `json:"googleMapsUri,omitempty"`
VisitDate *visitDatePayload `json:"visitDate,omitempty"`
}
type localizedTextPayload struct {
Text string `json:"text,omitempty"`
LanguageCode string `json:"languageCode,omitempty"`
}
type authorAttributionPayload struct {
DisplayName string `json:"displayName,omitempty"`
URI string `json:"uri,omitempty"`
PhotoURI string `json:"photoUri,omitempty"`
}
type visitDatePayload struct {
Year int `json:"year,omitempty"`
Month int `json:"month,omitempty"`
Day int `json:"day,omitempty"`
}

View File

@ -150,6 +150,116 @@ func TestSearchInvalidJSON(t *testing.T) {
}
}
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 TestDetailsSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/places/place-123" {
@ -351,6 +461,16 @@ func TestValidationErrors(t *testing.T) {
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.Details(context.Background(), "")
if err == nil {
t.Fatalf("expected details error")

72
details.go Normal file
View File

@ -0,0 +1,72 @@
package goplaces
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
const (
detailsFieldMaskBase = "id,displayName,formattedAddress,location,rating,priceLevel,types,regularOpeningHours,currentOpeningHours,nationalPhoneNumber,websiteUri"
detailsFieldMaskReview = "reviews"
)
// Details fetches details for a specific place ID.
func (c *Client) Details(ctx context.Context, placeID string) (PlaceDetails, error) {
return c.DetailsWithOptions(ctx, DetailsRequest{PlaceID: placeID})
}
// DetailsWithOptions fetches place details with locale hints.
func (c *Client) DetailsWithOptions(ctx context.Context, req DetailsRequest) (PlaceDetails, error) {
placeID := strings.TrimSpace(req.PlaceID)
if placeID == "" {
return PlaceDetails{}, ValidationError{Field: "place_id", Message: "required"}
}
endpoint, err := c.buildURL("/places/"+placeID, map[string]string{
"languageCode": strings.TrimSpace(req.Language),
"regionCode": strings.TrimSpace(req.Region),
})
if err != nil {
return PlaceDetails{}, err
}
payload, err := c.doRequest(ctx, http.MethodGet, endpoint, nil, detailsFieldMaskForRequest(req))
if err != nil {
return PlaceDetails{}, err
}
var place placeItem
if err := json.Unmarshal(payload, &place); err != nil {
return PlaceDetails{}, fmt.Errorf("goplaces: decode place details: %w", err)
}
return mapPlaceDetails(place), nil
}
func detailsFieldMaskForRequest(req DetailsRequest) string {
if req.IncludeReviews {
// Reviews are heavy; opt-in to include them.
return detailsFieldMaskBase + "," + detailsFieldMaskReview
}
return detailsFieldMaskBase
}
func mapPlaceDetails(place placeItem) PlaceDetails {
return PlaceDetails{
PlaceID: place.ID,
Name: displayName(place.DisplayName),
Address: place.FormattedAddress,
Location: mapLatLng(place.Location),
Rating: place.Rating,
PriceLevel: mapPriceLevel(place.PriceLevel),
Types: place.Types,
Phone: place.NationalPhoneNumber,
Website: place.WebsiteURI,
Hours: weekdayDescriptions(place.RegularOpeningHours),
OpenNow: openNow(place.CurrentOpeningHours),
Reviews: mapReviews(place.Reviews),
}
}

36
docs/autocomplete.md Normal file
View File

@ -0,0 +1,36 @@
# Autocomplete
Autocomplete returns place + query suggestions for partial text.
## CLI
```bash
goplaces autocomplete "cof" \
--session-token "goplaces-demo" \
--limit 5 \
--language en \
--region US
```
Optional location bias:
```bash
goplaces autocomplete "pizza" --lat 40.7411 --lng -73.9897 --radius-m 1500
```
## Library
```go
response, err := client.Autocomplete(ctx, goplaces.AutocompleteRequest{
Input: "cof",
SessionToken: "goplaces-demo",
Limit: 5,
Language: "en",
Region: "US",
})
```
## Notes
- Use a session token for billing consistency across autocomplete + details.
- Limit is applied client-side after the API response.

25
docs/plan/features.md Normal file
View File

@ -0,0 +1,25 @@
# Features Plan
## Autocomplete
- [x] Client: request/response types, validation, field mask.
- [x] CLI: `goplaces autocomplete` flags + renderer.
- [x] Tests: client + CLI coverage.
- [x] E2E: autocomplete via API key.
- [x] Docs: `docs/autocomplete.md` + README update.
- [x] Lint + coverage gate.
## Nearby Search
- [ ] Client: request/response types, validation, field mask.
- [ ] CLI: `goplaces nearby` flags + renderer.
- [ ] Tests: client + CLI coverage.
- [ ] E2E: nearby search via API key.
- [ ] Docs: `docs/nearby-search.md` + README update.
- [ ] Lint + coverage gate.
## Photos
- [ ] Client: details photos + media URL endpoint.
- [ ] CLI: `goplaces photo` flags + renderer.
- [ ] Tests: client + CLI coverage.
- [ ] E2E: details photos + photo media URL.
- [ ] Docs: `docs/photos.md` + README update.
- [ ] Lint + coverage gate.

View File

@ -69,3 +69,46 @@ func TestE2ESearchAndDetails(t *testing.T) {
t.Fatalf("expected details place id")
}
}
func TestE2EAutocomplete(t *testing.T) {
apiKey := os.Getenv("GOOGLE_PLACES_API_KEY")
if apiKey == "" {
t.Skip("GOOGLE_PLACES_API_KEY not set")
}
query := os.Getenv("GOOGLE_PLACES_E2E_QUERY")
if query == "" {
query = "coffee"
}
language := os.Getenv("GOOGLE_PLACES_E2E_LANGUAGE")
if language == "" {
language = "en"
}
region := os.Getenv("GOOGLE_PLACES_E2E_REGION")
if region == "" {
region = "US"
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := NewClient(Options{
APIKey: apiKey,
BaseURL: os.Getenv("GOOGLE_PLACES_E2E_BASE_URL"),
Timeout: 10 * time.Second,
})
response, err := client.Autocomplete(ctx, AutocompleteRequest{
Input: query,
Limit: 3,
Language: language,
Region: region,
SessionToken: "goplaces-e2e",
})
if err != nil {
t.Fatalf("autocomplete error: %v", err)
}
if len(response.Suggestions) == 0 {
t.Fatalf("expected autocomplete suggestions")
}
}

View File

@ -118,6 +118,61 @@ func TestRunSearchWithFilters(t *testing.T) {
}
}
func TestRunAutocompleteJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/places:autocomplete" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"suggestions": [{"placePrediction": {"placeId": "abc"}}]}`))
}))
defer server.Close()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{
"autocomplete",
"coffee",
"--api-key", "test-key",
"--base-url", server.URL,
"--json",
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d (stdout=%s stderr=%s)", exitCode, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), "\"suggestions\"") {
t.Fatalf("unexpected stdout: %s", stdout.String())
}
}
func TestRunAutocompleteHuman(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"suggestions": [{"placePrediction": {"placeId": "abc", "text": {"text": "Cafe"}}}]}`))
}))
defer server.Close()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{
"autocomplete",
"coffee",
"--api-key", "test-key",
"--base-url", server.URL,
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if !strings.Contains(stdout.String(), "Cafe") {
t.Fatalf("unexpected stdout: %s", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("unexpected stderr: %s", stderr.String())
}
}
func TestRunDetailsJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/places/place-1" {

View File

@ -13,7 +13,7 @@ func renderSearch(color Color, response goplaces.SearchResponse) string {
var out bytes.Buffer
count := len(response.Results)
if count == 0 {
return "No results."
return emptyResultsMessage
}
out.WriteString(color.Bold(fmt.Sprintf("Results (%d)", count)))
out.WriteString("\n")
@ -36,6 +36,26 @@ func renderSearch(color Color, response goplaces.SearchResponse) string {
return out.String()
}
func renderAutocomplete(color Color, response goplaces.AutocompleteResponse) string {
var out bytes.Buffer
count := len(response.Suggestions)
if count == 0 {
return emptyResultsMessage
}
out.WriteString(color.Bold(fmt.Sprintf("Suggestions (%d)", count)))
out.WriteString("\n")
for i, suggestion := range response.Suggestions {
title := formatTitle(color, autocompleteTitle(suggestion), autocompleteSubtitle(suggestion))
out.WriteString(fmt.Sprintf("%d. %s\n", i+1, title))
writeAutocompleteSuggestion(&out, color, suggestion)
if i < count-1 {
out.WriteString("\n")
}
}
return out.String()
}
func renderDetails(color Color, place goplaces.PlaceDetails) string {
var out bytes.Buffer
out.WriteString(color.Bold(formatTitle(color, place.Name, place.Address)))
@ -48,7 +68,7 @@ func renderResolve(color Color, response goplaces.LocationResolveResponse) strin
var out bytes.Buffer
count := len(response.Results)
if count == 0 {
return "No results."
return emptyResultsMessage
}
out.WriteString(color.Bold(fmt.Sprintf("Resolved (%d)", count)))
out.WriteString("\n")
@ -74,6 +94,25 @@ func formatTitle(color Color, name string, address string) string {
return color.Cyan(display) + " — " + address
}
const emptyResultsMessage = "No results."
func autocompleteTitle(suggestion goplaces.AutocompleteSuggestion) string {
if strings.TrimSpace(suggestion.MainText) != "" {
return suggestion.MainText
}
return suggestion.Text
}
func autocompleteSubtitle(suggestion goplaces.AutocompleteSuggestion) string {
if strings.TrimSpace(suggestion.SecondaryText) != "" {
return suggestion.SecondaryText
}
if strings.TrimSpace(suggestion.Text) == "" || strings.TrimSpace(suggestion.MainText) == "" {
return ""
}
return suggestion.Text
}
func writePlaceSummary(out *bytes.Buffer, color Color, place goplaces.PlaceSummary) {
writeLine(out, color, "ID", place.PlaceID)
writeLocation(out, color, place.Location)
@ -82,6 +121,16 @@ func writePlaceSummary(out *bytes.Buffer, color Color, place goplaces.PlaceSumma
writeOpenNow(out, color, place.OpenNow)
}
func writeAutocompleteSuggestion(out *bytes.Buffer, color Color, suggestion goplaces.AutocompleteSuggestion) {
writeLine(out, color, "Kind", suggestion.Kind)
writeLine(out, color, "ID", suggestion.PlaceID)
writeLine(out, color, "Place", suggestion.Place)
writeTypes(out, color, suggestion.Types)
if suggestion.DistanceMeters != nil {
writeLine(out, color, "Distance", fmt.Sprintf("%dm", *suggestion.DistanceMeters))
}
}
func writePlaceDetails(out *bytes.Buffer, color Color, place goplaces.PlaceDetails) {
writeLine(out, color, "ID", place.PlaceID)
writeLocation(out, color, place.Location)

View File

@ -50,6 +50,40 @@ func TestRenderSearchEmpty(t *testing.T) {
}
}
func TestRenderAutocomplete(t *testing.T) {
response := goplaces.AutocompleteResponse{
Suggestions: []goplaces.AutocompleteSuggestion{
{
Kind: "place",
PlaceID: "abc",
MainText: "Cafe",
SecondaryText: "Seattle",
Types: []string{"cafe"},
},
},
}
output := renderAutocomplete(NewColor(false), response)
if !strings.Contains(output, "Suggestions") {
t.Fatalf("missing suggestions header")
}
if !strings.Contains(output, "Cafe") {
t.Fatalf("missing suggestion text")
}
if !strings.Contains(output, "Kind") {
t.Fatalf("missing kind label")
}
if !strings.Contains(output, "cafe") {
t.Fatalf("missing types")
}
}
func TestRenderAutocompleteEmpty(t *testing.T) {
output := renderAutocomplete(NewColor(false), goplaces.AutocompleteResponse{})
if !strings.Contains(output, "No results") {
t.Fatalf("unexpected output: %s", output)
}
}
func TestFormatTitleFallback(t *testing.T) {
title := formatTitle(NewColor(false), "", "")
if !strings.Contains(title, "(no name)") {

View File

@ -6,10 +6,11 @@ import (
// Root defines the CLI command tree.
type Root struct {
Global GlobalOptions `embed:""`
Search SearchCmd `cmd:"" help:"Search places by text query."`
Details DetailsCmd `cmd:"" help:"Fetch place details by place ID."`
Resolve ResolveCmd `cmd:"" help:"Resolve a location string to candidate places."`
Global GlobalOptions `embed:""`
Autocomplete AutocompleteCmd `cmd:"" help:"Autocomplete places and queries."`
Search SearchCmd `cmd:"" help:"Search places by text query."`
Details DetailsCmd `cmd:"" help:"Fetch place details by place ID."`
Resolve ResolveCmd `cmd:"" help:"Resolve a location string to candidate places."`
}
// GlobalOptions are flags shared by all commands.
@ -40,6 +41,18 @@ type SearchCmd struct {
RadiusM *float64 `help:"Radius in meters for location bias."`
}
// AutocompleteCmd runs autocomplete queries.
type AutocompleteCmd struct {
Input string `arg:"" name:"input" help:"Autocomplete input text."`
Limit int `help:"Max suggestions (1-20)." default:"5"`
SessionToken string `help:"Session token for billing consistency."`
Language string `help:"BCP-47 language code (e.g. en, en-US)."`
Region string `help:"CLDR region code (e.g. US, DE)."`
Lat *float64 `help:"Latitude for location bias."`
Lng *float64 `help:"Longitude for location bias."`
RadiusM *float64 `help:"Radius in meters for location bias."`
}
// DetailsCmd fetches place details.
type DetailsCmd struct {
PlaceID string `arg:"" name:"place_id" help:"Place ID."`

View File

@ -174,6 +174,40 @@ func (c *SearchCmd) Run(app *App) error {
return err
}
// Run executes the autocomplete command.
func (c *AutocompleteCmd) Run(app *App) error {
request := goplaces.AutocompleteRequest{
Input: c.Input,
Limit: c.Limit,
SessionToken: c.SessionToken,
Language: c.Language,
Region: c.Region,
}
if c.Lat != nil || c.Lng != nil || c.RadiusM != nil {
if c.Lat == nil || c.Lng == nil || c.RadiusM == nil {
return goplaces.ValidationError{Field: "location_bias", Message: "lat, lng, radius required"}
}
request.LocationBias = &goplaces.LocationBias{
Lat: *c.Lat,
Lng: *c.Lng,
RadiusM: *c.RadiusM,
}
}
response, err := app.client.Autocomplete(context.Background(), request)
if err != nil {
return err
}
if app.json {
return writeJSON(app.out, response)
}
_, err = fmt.Fprintln(app.out, renderAutocomplete(app.color, response))
return err
}
// Run executes the details command.
func (c *DetailsCmd) Run(app *App) error {
response, err := app.client.DetailsWithOptions(context.Background(), goplaces.DetailsRequest{

10
limits.go Normal file
View File

@ -0,0 +1,10 @@
package goplaces
const (
defaultSearchLimit = 10
defaultResolveLimit = 5
maxSearchLimit = 20
maxResolveLimit = 10
defaultAutocompleteLimit = 5
maxAutocompleteLimit = 20
)

107
mapping.go Normal file
View File

@ -0,0 +1,107 @@
package goplaces
import "strings"
func mapReviews(reviews []reviewPayload) []Review {
if len(reviews) == 0 {
return nil
}
mapped := make([]Review, 0, len(reviews))
for _, review := range reviews {
mapped = append(mapped, Review{
Name: review.Name,
RelativePublishTimeDescription: review.RelativePublishTimeDescription,
Text: mapLocalizedText(review.Text),
OriginalText: mapLocalizedText(review.OriginalText),
Rating: review.Rating,
Author: mapAuthorAttribution(review.AuthorAttribution),
PublishTime: review.PublishTime,
FlagContentURI: review.FlagContentURI,
GoogleMapsURI: review.GoogleMapsURI,
VisitDate: mapVisitDate(review.VisitDate),
})
}
return mapped
}
func mapLocalizedText(text *localizedTextPayload) *LocalizedText {
if text == nil {
return nil
}
// Avoid emitting empty text structs downstream.
if strings.TrimSpace(text.Text) == "" && strings.TrimSpace(text.LanguageCode) == "" {
return nil
}
return &LocalizedText{
Text: text.Text,
LanguageCode: text.LanguageCode,
}
}
func mapAuthorAttribution(author *authorAttributionPayload) *AuthorAttribution {
if author == nil {
return nil
}
// Drop empty attribution blocks to keep JSON clean.
if strings.TrimSpace(author.DisplayName) == "" && strings.TrimSpace(author.URI) == "" && strings.TrimSpace(author.PhotoURI) == "" {
return nil
}
return &AuthorAttribution{
DisplayName: author.DisplayName,
URI: author.URI,
PhotoURI: author.PhotoURI,
}
}
func mapVisitDate(date *visitDatePayload) *ReviewVisitDate {
if date == nil {
return nil
}
// Treat zeroed dates as missing.
if date.Year == 0 && date.Month == 0 && date.Day == 0 {
return nil
}
return &ReviewVisitDate{
Year: date.Year,
Month: date.Month,
Day: date.Day,
}
}
func mapLatLng(loc *location) *LatLng {
if loc == nil {
return nil
}
return &LatLng{Lat: loc.Latitude, Lng: loc.Longitude}
}
func displayName(name *displayNamePayload) string {
if name == nil {
return ""
}
return name.Text
}
func openNow(hours *openingHours) *bool {
if hours == nil {
return nil
}
return hours.OpenNow
}
func weekdayDescriptions(hours *openingHours) []string {
if hours == nil {
return nil
}
return hours.WeekdayDescriptions
}
func mapPriceLevel(value string) *int {
if value == "" {
return nil
}
if mapped, ok := enumToPriceLevel[value]; ok {
return &mapped
}
return nil
}

65
payloads.go Normal file
View File

@ -0,0 +1,65 @@
package goplaces
type searchResponse struct {
Places []placeItem `json:"places"`
NextPageToken string `json:"nextPageToken"`
}
type placeItem struct {
ID string `json:"id"`
DisplayName *displayNamePayload `json:"displayName,omitempty"`
FormattedAddress string `json:"formattedAddress,omitempty"`
Location *location `json:"location,omitempty"`
Rating *float64 `json:"rating,omitempty"`
PriceLevel string `json:"priceLevel,omitempty"`
Types []string `json:"types,omitempty"`
CurrentOpeningHours *openingHours `json:"currentOpeningHours,omitempty"`
RegularOpeningHours *openingHours `json:"regularOpeningHours,omitempty"`
NationalPhoneNumber string `json:"nationalPhoneNumber,omitempty"`
WebsiteURI string `json:"websiteUri,omitempty"`
Reviews []reviewPayload `json:"reviews,omitempty"`
}
type displayNamePayload struct {
Text string `json:"text"`
}
type location struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
type openingHours struct {
OpenNow *bool `json:"openNow,omitempty"`
WeekdayDescriptions []string `json:"weekdayDescriptions,omitempty"`
}
type reviewPayload struct {
Name string `json:"name,omitempty"`
RelativePublishTimeDescription string `json:"relativePublishTimeDescription,omitempty"`
Text *localizedTextPayload `json:"text,omitempty"`
OriginalText *localizedTextPayload `json:"originalText,omitempty"`
Rating *float64 `json:"rating,omitempty"`
AuthorAttribution *authorAttributionPayload `json:"authorAttribution,omitempty"`
PublishTime string `json:"publishTime,omitempty"`
FlagContentURI string `json:"flagContentUri,omitempty"`
GoogleMapsURI string `json:"googleMapsUri,omitempty"`
VisitDate *visitDatePayload `json:"visitDate,omitempty"`
}
type localizedTextPayload struct {
Text string `json:"text,omitempty"`
LanguageCode string `json:"languageCode,omitempty"`
}
type authorAttributionPayload struct {
DisplayName string `json:"displayName,omitempty"`
URI string `json:"uri,omitempty"`
PhotoURI string `json:"photoUri,omitempty"`
}
type visitDatePayload struct {
Year int `json:"year,omitempty"`
Month int `json:"month,omitempty"`
Day int `json:"day,omitempty"`
}

25
price_levels.go Normal file
View File

@ -0,0 +1,25 @@
package goplaces
const (
priceLevelFree = "PRICE_LEVEL_FREE"
priceLevelInexpensive = "PRICE_LEVEL_INEXPENSIVE"
priceLevelModerate = "PRICE_LEVEL_MODERATE"
priceLevelExpensive = "PRICE_LEVEL_EXPENSIVE"
priceLevelVeryExp = "PRICE_LEVEL_VERY_EXPENSIVE"
)
var priceLevelToEnum = map[int]string{
0: priceLevelFree,
1: priceLevelInexpensive,
2: priceLevelModerate,
3: priceLevelExpensive,
4: priceLevelVeryExp,
}
var enumToPriceLevel = map[string]int{
priceLevelFree: 0,
priceLevelInexpensive: 1,
priceLevelModerate: 2,
priceLevelExpensive: 3,
priceLevelVeryExp: 4,
}

13
request_helpers.go Normal file
View File

@ -0,0 +1,13 @@
package goplaces
func circlePayload(bias *LocationBias) map[string]any {
return map[string]any{
"circle": map[string]any{
"center": map[string]any{
"latitude": bias.Lat,
"longitude": bias.Lng,
},
"radius": bias.RadiusM,
},
}
}

78
resolve.go Normal file
View File

@ -0,0 +1,78 @@
package goplaces
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
const resolveFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.types"
// Resolve converts a free-form location string into candidate places.
func (c *Client) Resolve(ctx context.Context, req LocationResolveRequest) (LocationResolveResponse, error) {
req = applyResolveDefaults(req)
if err := validateResolveRequest(req); err != nil {
return LocationResolveResponse{}, err
}
body := map[string]any{
"textQuery": req.LocationText,
"pageSize": req.Limit,
}
if strings.TrimSpace(req.Language) != "" {
body["languageCode"] = strings.TrimSpace(req.Language)
}
if strings.TrimSpace(req.Region) != "" {
body["regionCode"] = strings.TrimSpace(req.Region)
}
endpoint, err := c.buildURL("/places:searchText", nil)
if err != nil {
return LocationResolveResponse{}, err
}
payload, err := c.doRequest(ctx, http.MethodPost, endpoint, body, resolveFieldMask)
if err != nil {
return LocationResolveResponse{}, err
}
var response searchResponse
if err := json.Unmarshal(payload, &response); err != nil {
return LocationResolveResponse{}, fmt.Errorf("goplaces: decode resolve response: %w", err)
}
results := make([]ResolvedLocation, 0, len(response.Places))
for _, place := range response.Places {
results = append(results, mapResolvedLocation(place))
}
return LocationResolveResponse{Results: results}, nil
}
func mapResolvedLocation(place placeItem) ResolvedLocation {
return ResolvedLocation{
PlaceID: place.ID,
Name: displayName(place.DisplayName),
Address: place.FormattedAddress,
Location: mapLatLng(place.Location),
Types: place.Types,
}
}
func applyResolveDefaults(req LocationResolveRequest) LocationResolveRequest {
if req.Limit == 0 {
req.Limit = defaultResolveLimit
}
return req
}
func validateResolveRequest(req LocationResolveRequest) error {
if strings.TrimSpace(req.LocationText) == "" {
return ValidationError{Field: "location_text", Message: "required"}
}
if req.Limit < 1 || req.Limit > maxResolveLimit {
return ValidationError{Field: "limit", Message: fmt.Sprintf("must be 1-%d", maxResolveLimit)}
}
return nil
}

149
search.go Normal file
View File

@ -0,0 +1,149 @@
package goplaces
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
const searchFieldMask = "places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.priceLevel,places.types,places.currentOpeningHours,nextPageToken"
// Search performs a text search with optional filters.
func (c *Client) Search(ctx context.Context, req SearchRequest) (SearchResponse, error) {
req = applySearchDefaults(req)
if err := validateSearchRequest(req); err != nil {
return SearchResponse{}, err
}
body := buildSearchBody(req)
endpoint, err := c.buildURL("/places:searchText", nil)
if err != nil {
return SearchResponse{}, err
}
payload, err := c.doRequest(ctx, http.MethodPost, endpoint, body, searchFieldMask)
if err != nil {
return SearchResponse{}, err
}
var response searchResponse
if err := json.Unmarshal(payload, &response); err != nil {
return SearchResponse{}, fmt.Errorf("goplaces: decode search response: %w", err)
}
results := make([]PlaceSummary, 0, len(response.Places))
for _, place := range response.Places {
results = append(results, mapPlaceSummary(place))
}
return SearchResponse{
Results: results,
NextPageToken: response.NextPageToken,
}, nil
}
func buildSearchBody(req SearchRequest) map[string]any {
textQuery := req.Query
if req.Filters != nil && strings.TrimSpace(req.Filters.Keyword) != "" {
// Google expects a single text query; append keywords here.
textQuery = strings.TrimSpace(textQuery + " " + req.Filters.Keyword)
}
body := map[string]any{
"textQuery": textQuery,
"pageSize": req.Limit,
}
if strings.TrimSpace(req.Language) != "" {
body["languageCode"] = strings.TrimSpace(req.Language)
}
if strings.TrimSpace(req.Region) != "" {
body["regionCode"] = strings.TrimSpace(req.Region)
}
if req.PageToken != "" {
body["pageToken"] = req.PageToken
}
if req.LocationBias != nil {
// Places API expects a circular bias object.
body["locationBias"] = circlePayload(req.LocationBias)
}
if req.Filters != nil {
filters := req.Filters
if len(filters.Types) > 0 {
// API accepts a single includedType; use the first value.
body["includedType"] = filters.Types[0]
}
if filters.OpenNow != nil {
body["openNow"] = *filters.OpenNow
}
if filters.MinRating != nil {
body["minRating"] = *filters.MinRating
}
if len(filters.PriceLevels) > 0 {
levels := make([]string, 0, len(filters.PriceLevels))
for _, level := range filters.PriceLevels {
if mapped, ok := priceLevelToEnum[level]; ok {
levels = append(levels, mapped)
}
}
if len(levels) > 0 {
body["priceLevels"] = levels
}
}
}
return body
}
func mapPlaceSummary(place placeItem) PlaceSummary {
return PlaceSummary{
PlaceID: place.ID,
Name: displayName(place.DisplayName),
Address: place.FormattedAddress,
Location: mapLatLng(place.Location),
Rating: place.Rating,
PriceLevel: mapPriceLevel(place.PriceLevel),
Types: place.Types,
OpenNow: openNow(place.CurrentOpeningHours),
}
}
func applySearchDefaults(req SearchRequest) SearchRequest {
if req.Limit == 0 {
req.Limit = defaultSearchLimit
}
return req
}
func validateSearchRequest(req SearchRequest) error {
if strings.TrimSpace(req.Query) == "" {
return ValidationError{Field: "query", Message: "required"}
}
if req.Limit < 1 || req.Limit > maxSearchLimit {
return ValidationError{Field: "limit", Message: fmt.Sprintf("must be 1-%d", maxSearchLimit)}
}
if req.Filters != nil {
if req.Filters.MinRating != nil {
if *req.Filters.MinRating < 0 || *req.Filters.MinRating > 5 {
return ValidationError{Field: "filters.min_rating", Message: "must be 0-5"}
}
}
for _, level := range req.Filters.PriceLevels {
if level < 0 || level > 4 {
return ValidationError{Field: "filters.price_levels", Message: "must be 0-4"}
}
}
}
if req.LocationBias != nil {
if err := validateLocationBias(req.LocationBias); err != nil {
return err
}
}
return nil
}

View File

@ -39,6 +39,33 @@ type SearchResponse struct {
NextPageToken string `json:"next_page_token,omitempty"`
}
// AutocompleteRequest defines input for autocomplete suggestions.
type AutocompleteRequest struct {
Input string `json:"input"`
SessionToken string `json:"session_token,omitempty"`
Limit int `json:"limit,omitempty"`
Language string `json:"language,omitempty"`
Region string `json:"region,omitempty"`
LocationBias *LocationBias `json:"location_bias,omitempty"`
}
// AutocompleteResponse contains suggestions from autocomplete.
type AutocompleteResponse struct {
Suggestions []AutocompleteSuggestion `json:"suggestions"`
}
// AutocompleteSuggestion is a place or query prediction.
type AutocompleteSuggestion struct {
Kind string `json:"kind"`
PlaceID string `json:"place_id,omitempty"`
Place string `json:"place,omitempty"`
Text string `json:"text,omitempty"`
MainText string `json:"main_text,omitempty"`
SecondaryText string `json:"secondary_text,omitempty"`
Types []string `json:"types,omitempty"`
DistanceMeters *int `json:"distance_meters,omitempty"`
}
// PlaceSummary is a compact view of a place.
type PlaceSummary struct {
PlaceID string `json:"place_id"`

17
validation.go Normal file
View File

@ -0,0 +1,17 @@
package goplaces
func validateLocationBias(bias *LocationBias) error {
if bias == nil {
return nil
}
if bias.RadiusM <= 0 {
return ValidationError{Field: "location_bias.radius_m", Message: "must be > 0"}
}
if bias.Lat < -90 || bias.Lat > 90 {
return ValidationError{Field: "location_bias.lat", Message: "must be -90..90"}
}
if bias.Lng < -180 || bias.Lng > 180 {
return ValidationError{Field: "location_bias.lng", Message: "must be -180..180"}
}
return nil
}