diff --git a/Makefile b/Makefile index 28b21a6..050d3f0 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: lint test coverage lint: + golangci-lint fmt golangci-lint run ./... test: diff --git a/README.md b/README.md index e2b198a..c3c204e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Search: ```bash goplaces search "coffee" --min-rating 4 --open-now --limit 5 \ - --lat 40.8065 --lng -73.9719 --radius-m 3000 + --lat 40.8065 --lng -73.9719 --radius-m 3000 --language en --region US ``` Details: @@ -88,6 +88,8 @@ resp, err := client.Search(ctx, goplaces.SearchRequest{ - `Filters.Types` maps to `includedType` (Google supports a single value). Only the first type is sent. - Price levels map to Google enums: `0` (free) → `4` (very expensive). - Use `GOOGLE_PLACES_BASE_URL` to override the endpoint (useful for tests). +- Field masks are defined in `client.go` constants; extend them if you need more fields. +- Google Places API usage is billed and quota-limited; keep an eye on your Cloud Console quotas. ## Testing diff --git a/client.go b/client.go index a883abf..9fb1246 100644 --- a/client.go +++ b/client.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" ) @@ -99,7 +100,11 @@ func (c *Client) Search(ctx context.Context, req SearchRequest) (SearchResponse, } body := buildSearchBody(req) - payload, err := c.doRequest(ctx, http.MethodPost, "/places:searchText", body, searchFieldMask) + 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 } @@ -122,13 +127,25 @@ func (c *Client) Search(ctx context.Context, req SearchRequest) (SearchResponse, // Details fetches details for a specific place ID. func (c *Client) Details(ctx context.Context, placeID string) (PlaceDetails, error) { - placeID = strings.TrimSpace(placeID) + 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"} } - path := "/places/" + placeID - payload, err := c.doRequest(ctx, http.MethodGet, path, nil, detailsFieldMask) + 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, detailsFieldMask) if err != nil { return PlaceDetails{}, err } @@ -152,8 +169,18 @@ func (c *Client) Resolve(ctx context.Context, req LocationResolveRequest) (Locat "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) + } - payload, err := c.doRequest(ctx, http.MethodPost, "/places:searchText", body, resolveFieldMask) + 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 } @@ -174,7 +201,7 @@ func (c *Client) Resolve(ctx context.Context, req LocationResolveRequest) (Locat func (c *Client) doRequest( ctx context.Context, method string, - path string, + endpoint string, body any, fieldMask string, ) ([]byte, error) { @@ -182,7 +209,6 @@ func (c *Client) doRequest( return nil, ErrMissingAPIKey } - url := c.baseURL + path var reader io.Reader if body != nil { payload, err := json.Marshal(body) @@ -192,7 +218,7 @@ func (c *Client) doRequest( reader = bytes.NewReader(payload) } - request, err := http.NewRequestWithContext(ctx, method, url, reader) + request, err := http.NewRequestWithContext(ctx, method, endpoint, reader) if err != nil { return nil, fmt.Errorf("goplaces: build request: %w", err) } @@ -226,6 +252,28 @@ func (c *Client) doRequest( return payload, nil } +func (c *Client) buildURL(path string, query map[string]string) (string, error) { + endpoint := c.baseURL + path + if len(query) == 0 { + return endpoint, nil + } + + parsed, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("goplaces: invalid url: %w", err) + } + + values := parsed.Query() + for key, value := range query { + if strings.TrimSpace(value) == "" { + continue + } + values.Set(key, value) + } + 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) != "" { @@ -236,6 +284,12 @@ func buildSearchBody(req SearchRequest) 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 diff --git a/client_test.go b/client_test.go index 408b291..1ba4e52 100644 --- a/client_test.go +++ b/client_test.go @@ -65,6 +65,8 @@ func TestSearchSuccess(t *testing.T) { Query: "coffee", Limit: 5, PageToken: "token", + Language: "en", + Region: "US", Filters: &Filters{ Keyword: "best", Types: []string{"cafe"}, @@ -108,6 +110,12 @@ func TestSearchSuccess(t *testing.T) { if gotRequest["pageToken"] != "token" { t.Fatalf("unexpected pageToken: %#v", gotRequest["pageToken"]) } + if gotRequest["languageCode"] != "en" { + t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"]) + } + if gotRequest["regionCode"] != "US" { + t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"]) + } } func TestSearchHTTPError(t *testing.T) { @@ -146,6 +154,12 @@ func TestDetailsSuccess(t *testing.T) { if r.URL.Path != "/v1/places/place-123" { t.Fatalf("unexpected path: %s", r.URL.Path) } + if r.URL.Query().Get("languageCode") != "en" { + t.Fatalf("unexpected languageCode: %s", r.URL.Query().Get("languageCode")) + } + if r.URL.Query().Get("regionCode") != "US" { + t.Fatalf("unexpected regionCode: %s", r.URL.Query().Get("regionCode")) + } if r.Header.Get("X-Goog-FieldMask") != detailsFieldMask { t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask")) } @@ -166,7 +180,11 @@ func TestDetailsSuccess(t *testing.T) { defer server.Close() client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL + "/v1"}) - place, err := client.Details(context.Background(), "place-123") + place, err := client.DetailsWithOptions(context.Background(), DetailsRequest{ + PlaceID: "place-123", + Language: "en", + Region: "US", + }) if err != nil { t.Fatalf("details error: %v", err) } @@ -182,10 +200,18 @@ func TestDetailsSuccess(t *testing.T) { } func TestResolveSuccess(t *testing.T) { + var gotRequest map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Goog-FieldMask") != resolveFieldMask { t.Fatalf("unexpected field mask: %s", r.Header.Get("X-Goog-FieldMask")) } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if err := json.Unmarshal(body, &gotRequest); err != nil { + t.Fatalf("decode body: %v", err) + } _, _ = w.Write([]byte(`{ "places": [ { @@ -201,13 +227,23 @@ func TestResolveSuccess(t *testing.T) { defer server.Close() client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL}) - response, err := client.Resolve(context.Background(), LocationResolveRequest{LocationText: "Downtown"}) + response, err := client.Resolve(context.Background(), LocationResolveRequest{ + LocationText: "Downtown", + Language: "en", + Region: "US", + }) if err != nil { t.Fatalf("resolve error: %v", err) } if len(response.Results) != 1 { t.Fatalf("expected 1 result") } + if gotRequest["languageCode"] != "en" { + t.Fatalf("unexpected languageCode: %#v", gotRequest["languageCode"]) + } + if gotRequest["regionCode"] != "US" { + t.Fatalf("unexpected regionCode: %#v", gotRequest["regionCode"]) + } } func TestMissingAPIKey(t *testing.T) { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index df22795..d020af2 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -32,7 +33,7 @@ func TestRunSearchJSON(t *testing.T) { }, &stdout, &stderr) if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d", exitCode) + t.Fatalf("expected exit code 0, got %d (stdout=%s stderr=%s)", exitCode, stdout.String(), stderr.String()) } if stderr.Len() != 0 { t.Fatalf("unexpected stderr: %s", stderr.String()) @@ -59,13 +60,64 @@ func TestRunSearchHuman(t *testing.T) { }, &stdout, &stderr) if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d", exitCode) + t.Fatalf("expected exit code 0, got %d (stdout=%s stderr=%s)", exitCode, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "Cafe") { t.Fatalf("unexpected stdout: %s", stdout.String()) } } +func TestRunSearchWithFilters(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if payload["includedType"] != "cafe" { + t.Fatalf("unexpected includedType: %#v", payload["includedType"]) + } + if payload["languageCode"] != "en" { + t.Fatalf("unexpected languageCode: %#v", payload["languageCode"]) + } + if payload["regionCode"] != "US" { + t.Fatalf("unexpected regionCode: %#v", payload["regionCode"]) + } + _, _ = w.Write([]byte(`{"places": [{"id": "abc"}]}`)) + })) + defer server.Close() + + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{ + "search", + "coffee", + "--api-key", "test-key", + "--base-url", server.URL, + "--json", + "--keyword", "best", + "--type", "cafe", + "--open-now=true", + "--min-rating", "4.2", + "--price-level", "1", + "--lat", "40.0", + "--lng=-70.0", + "--radius-m", "500", + "--language", "en", + "--region", "US", + }, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d (stdout=%s stderr=%s)", exitCode, stdout.String(), stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("unexpected stderr: %s", stderr.String()) + } + if !strings.Contains(stdout.String(), "\"results\"") { + t.Fatalf("unexpected stdout: %s", stdout.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" { diff --git a/internal/cli/root.go b/internal/cli/root.go index 7b0f43c..e51d0ea 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -28,6 +28,8 @@ type SearchCmd struct { Query string `arg:"" name:"query" help:"Search text."` Limit int `help:"Max results (1-20)." default:"10"` PageToken string `help:"Page token for pagination."` + Language string `help:"BCP-47 language code (e.g. en, en-US)."` + Region string `help:"CLDR region code (e.g. US, DE)."` Keyword string `help:"Keyword to append to the query."` Type []string `help:"Place type filter (includedType). Repeatable."` OpenNow *bool `help:"Return only currently open places."` @@ -40,11 +42,15 @@ type SearchCmd struct { // DetailsCmd fetches place details. type DetailsCmd struct { - PlaceID string `arg:"" name:"place_id" help:"Place ID."` + PlaceID string `arg:"" name:"place_id" help:"Place ID."` + Language string `help:"BCP-47 language code (e.g. en, en-US)."` + Region string `help:"CLDR region code (e.g. US, DE)."` } // ResolveCmd resolves a location string into candidates. type ResolveCmd struct { LocationText string `arg:"" name:"location" help:"Location text to resolve."` Limit int `help:"Max results (1-10)." default:"5"` + Language string `help:"BCP-47 language code (e.g. en, en-US)."` + Region string `help:"CLDR region code (e.g. US, DE)."` } diff --git a/internal/cli/run.go b/internal/cli/run.go index 52fdb28..5e0a2a5 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -118,6 +118,8 @@ func (c *SearchCmd) Run(app *App) error { Query: c.Query, Limit: c.Limit, PageToken: c.PageToken, + Language: c.Language, + Region: c.Region, } filters := goplaces.Filters{} @@ -172,7 +174,11 @@ func (c *SearchCmd) Run(app *App) error { // Run executes the details command. func (c *DetailsCmd) Run(app *App) error { - response, err := app.client.Details(context.Background(), c.PlaceID) + response, err := app.client.DetailsWithOptions(context.Background(), goplaces.DetailsRequest{ + PlaceID: c.PlaceID, + Language: c.Language, + Region: c.Region, + }) if err != nil { return err } @@ -190,6 +196,8 @@ func (c *ResolveCmd) Run(app *App) error { request := goplaces.LocationResolveRequest{ LocationText: c.LocationText, Limit: c.Limit, + Language: c.Language, + Region: c.Region, } response, err := app.client.Resolve(context.Background(), request) diff --git a/types.go b/types.go index 1531138..e84124f 100644 --- a/types.go +++ b/types.go @@ -7,6 +7,8 @@ type SearchRequest struct { LocationBias *LocationBias `json:"location_bias,omitempty"` Limit int `json:"limit,omitempty"` PageToken string `json:"page_token,omitempty"` + Language string `json:"language,omitempty"` + Region string `json:"region,omitempty"` } // Filters are optional search refinements. @@ -68,6 +70,15 @@ type PlaceDetails struct { type LocationResolveRequest struct { LocationText string `json:"location_text"` Limit int `json:"limit,omitempty"` + Language string `json:"language,omitempty"` + Region string `json:"region,omitempty"` +} + +// DetailsRequest fetches place details with optional locale hints. +type DetailsRequest struct { + PlaceID string `json:"place_id"` + Language string `json:"language,omitempty"` + Region string `json:"region,omitempty"` } // LocationResolveResponse contains resolved locations.