feat: add locale options and lint fmt

This commit is contained in:
Peter Steinberger 2026-01-02 18:21:38 +01:00
parent 56400a603f
commit 2bca928e4e
8 changed files with 185 additions and 15 deletions

View File

@ -1,6 +1,7 @@
.PHONY: lint test coverage
lint:
golangci-lint fmt
golangci-lint run ./...
test:

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -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" {

View File

@ -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)."`
}

View File

@ -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)

View File

@ -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.