feat: add locale options and lint fmt
This commit is contained in:
parent
56400a603f
commit
2bca928e4e
1
Makefile
1
Makefile
@ -1,6 +1,7 @@
|
||||
.PHONY: lint test coverage
|
||||
|
||||
lint:
|
||||
golangci-lint fmt
|
||||
golangci-lint run ./...
|
||||
|
||||
test:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
70
client.go
70
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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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" {
|
||||
|
||||
@ -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)."`
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
11
types.go
11
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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user