feat: add autocomplete support
This commit is contained in:
parent
03d23c05fc
commit
715f4d8aa7
18
README.md
18
README.md
@ -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
163
autocomplete.go
Normal 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
493
client.go
@ -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"`
|
||||
}
|
||||
|
||||
120
client_test.go
120
client_test.go
@ -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
72
details.go
Normal 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
36
docs/autocomplete.md
Normal 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
25
docs/plan/features.md
Normal 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.
|
||||
43
e2e_test.go
43
e2e_test.go
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)") {
|
||||
|
||||
@ -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."`
|
||||
|
||||
@ -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
10
limits.go
Normal 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
107
mapping.go
Normal 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
65
payloads.go
Normal 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
25
price_levels.go
Normal 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
13
request_helpers.go
Normal 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
78
resolve.go
Normal 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
149
search.go
Normal 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
|
||||
}
|
||||
27
types.go
27
types.go
@ -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
17
validation.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user