goplaces/search.go
2026-01-02 21:24:55 +01:00

150 lines
3.9 KiB
Go

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
}