diff --git a/geocoding_forward.go b/geocoding_forward.go new file mode 100644 index 0000000..ad38656 --- /dev/null +++ b/geocoding_forward.go @@ -0,0 +1,102 @@ +package mapbox + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" +) + +// GeocodeRequest is the request for Geocoding v6 Forward. +type GeocodeRequest struct { + // Query is the search text (address, city name, etc.). Required, max 256 chars. + Query string + // Permanent requests Permanent tier geocoding, which permits storing results + // indefinitely. Defaults to Temporary tier when false. + Permanent bool + // Countries restricts results to one or more ISO 3166-1 alpha-2 country codes. + Countries []string + // Types filters results to specific feature types (e.g. "address", "place"). + Types []string + // Limit caps the number of results returned (max 10, default 5). + Limit int + // Language is an IETF language tag for result text (e.g. "fi", "en"). + Language string + // BBox limits results to a bounding box (minLon,minLat,maxLon,maxLat). + BBox *BoundingBox + // Proximity biases results toward a coordinate. + Proximity *Coordinate + // Autocomplete enables partial-match results. Default true when omitted. + // Set to a non-nil false to disable. + Autocomplete *bool +} + +// Geocode performs a forward geocode lookup using Geocoding v6. +func (c *Client) Geocode(ctx context.Context, req *GeocodeRequest) (_ *FeatureCollection, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("mapbox: geocode: %w", err) + } + }() + + if req.Query == "" { + return nil, fmt.Errorf("query is required") + } + + params := url.Values{} + params.Set("q", req.Query) + if req.Permanent { + params.Set("permanent", "true") + } + if len(req.Countries) > 0 { + params.Set("country", strings.Join(req.Countries, ",")) + } + if len(req.Types) > 0 { + params.Set("types", strings.Join(req.Types, ",")) + } + if req.Limit > 0 { + params.Set("limit", strconv.Itoa(req.Limit)) + } + if req.Language != "" { + params.Set("language", req.Language) + } + if req.BBox != nil { + params.Set("bbox", formatBBoxParam(req.BBox)) + } + if req.Proximity != nil { + params.Set("proximity", formatCoordParam(req.Proximity.Longitude, req.Proximity.Latitude)) + } + if req.Autocomplete != nil && !*req.Autocomplete { + params.Set("autocomplete", "false") + } + + endpoint := c.baseURL + "/search/geocode/v6/forward?" + params.Encode() + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + httpResp, err := c.do(httpReq) + if err != nil { + return nil, err + } + defer func() { + if cerr := httpResp.Body.Close(); cerr != nil { + slog.WarnContext(ctx, "mapbox: failed to close geocode response body", "error", cerr) + } + }() + + if httpResp.StatusCode != http.StatusOK { + return nil, newResponseError(httpResp) + } + + var result FeatureCollection + if err := json.NewDecoder(httpResp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &result, nil +} diff --git a/geocoding_forward_test.go b/geocoding_forward_test.go new file mode 100644 index 0000000..1395cd5 --- /dev/null +++ b/geocoding_forward_test.go @@ -0,0 +1,177 @@ +package mapbox_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + mapbox "github.com/way-platform/mapbox-go" +) + +func TestGeocode_QueryParams(t *testing.T) { + var gotReq *http.Request + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotReq = r + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(mapbox.FeatureCollection{Type: "FeatureCollection"}); err != nil { + t.Errorf("encode response: %v", err) + } + })) + defer srv.Close() + + client := mapbox.NewClient(mapbox.WithAccessToken("my-token"), mapbox.WithBaseURL(srv.URL)) + _, err := client.Geocode(context.Background(), &mapbox.GeocodeRequest{ + Query: "Aallonmerkki 2, 02320 Espoo, Finland", + Permanent: true, + Countries: []string{"fi"}, + Types: []string{"address"}, + Limit: 1, + Language: "fi", + }) + if err != nil { + t.Fatalf("Geocode error: %v", err) + } + + q := gotReq.URL.Query() + if got := q.Get("q"); got != "Aallonmerkki 2, 02320 Espoo, Finland" { + t.Errorf("q = %q, want address string", got) + } + if got := q.Get("permanent"); got != "true" { + t.Errorf("permanent = %q, want %q", got, "true") + } + if got := q.Get("country"); got != "fi" { + t.Errorf("country = %q, want %q", got, "fi") + } + if got := q.Get("types"); got != "address" { + t.Errorf("types = %q, want %q", got, "address") + } + if got := q.Get("limit"); got != "1" { + t.Errorf("limit = %q, want %q", got, "1") + } + if got := q.Get("language"); got != "fi" { + t.Errorf("language = %q, want %q", got, "fi") + } + if got := q.Get("access_token"); got != "my-token" { + t.Errorf("access_token = %q, want %q", got, "my-token") + } + if gotReq.Method != http.MethodGet { + t.Errorf("Method = %s, want GET", gotReq.Method) + } +} + +func TestGeocode_ResponseParsing(t *testing.T) { + const body = `{ + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [24.7914, 60.1756]}, + "properties": { + "mapbox_id": "addr.456", + "feature_type": "address", + "name": "Aallonmerkki 2", + "full_address": "Aallonmerkki 2, 02320 Espoo, Finland", + "context": { + "address": { + "mapbox_id": "addr.456", + "address_number": "2", + "street_name": "Aallonmerkki" + }, + "postcode": {"mapbox_id": "post.02320", "name": "02320"}, + "place": {"mapbox_id": "place.espoo", "name": "Espoo"}, + "country": {"mapbox_id": "country.fi", "name": "Finland", "country_code": "FI", "country_code_alpha_3": "FIN"} + } + } + }] + }` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte(body)); err != nil { + t.Errorf("write response: %v", err) + } + })) + defer srv.Close() + + client := mapbox.NewClient(mapbox.WithAccessToken("t"), mapbox.WithBaseURL(srv.URL)) + result, err := client.Geocode(context.Background(), &mapbox.GeocodeRequest{ + Query: "Aallonmerkki 2, Espoo", + }) + if err != nil { + t.Fatalf("Geocode error: %v", err) + } + if len(result.Features) != 1 { + t.Fatalf("len(Features) = %d, want 1", len(result.Features)) + } + f := result.Features[0] + if f.Geometry.Type != "Point" { + t.Errorf("Geometry.Type = %q, want Point", f.Geometry.Type) + } + if len(f.Geometry.Coordinates) != 2 { + t.Fatalf("Geometry.Coordinates len = %d, want 2", len(f.Geometry.Coordinates)) + } + if f.Geometry.Coordinates[0] != 24.7914 { + t.Errorf("longitude = %f, want 24.7914", f.Geometry.Coordinates[0]) + } + if f.Geometry.Coordinates[1] != 60.1756 { + t.Errorf("latitude = %f, want 60.1756", f.Geometry.Coordinates[1]) + } + if f.Properties.FullAddress != "Aallonmerkki 2, 02320 Espoo, Finland" { + t.Errorf("FullAddress = %q", f.Properties.FullAddress) + } + if f.Properties.Context.Country == nil { + t.Fatal("expected Country context to be set") + } + if f.Properties.Context.Country.CountryCode != "FI" { + t.Errorf("CountryCode = %q, want FI", f.Properties.Context.Country.CountryCode) + } +} + +func TestGeocode_EmptyQuery(t *testing.T) { + client := mapbox.NewClient(mapbox.WithAccessToken("t")) + _, err := client.Geocode(context.Background(), &mapbox.GeocodeRequest{Query: ""}) + if err == nil { + t.Fatal("expected error for empty query") + } +} + +func TestGeocode_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"Not Authorized - Invalid Token"}`, http.StatusUnauthorized) + })) + defer srv.Close() + + client := mapbox.NewClient(mapbox.WithAccessToken("bad"), mapbox.WithBaseURL(srv.URL)) + _, err := client.Geocode(context.Background(), &mapbox.GeocodeRequest{Query: "Helsinki"}) + if err == nil { + t.Fatal("expected error for 401 response") + } + if !mapbox.IsUnauthorized(err) { + t.Errorf("IsUnauthorized(err) = false, want true; err = %v", err) + } +} + +func TestGeocode_AutocompleteFalse(t *testing.T) { + var gotParam string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotParam = r.URL.Query().Get("autocomplete") + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(mapbox.FeatureCollection{Type: "FeatureCollection"}); err != nil { + t.Errorf("encode response: %v", err) + } + })) + defer srv.Close() + + ac := false + client := mapbox.NewClient(mapbox.WithAccessToken("t"), mapbox.WithBaseURL(srv.URL)) + _, err := client.Geocode(context.Background(), &mapbox.GeocodeRequest{ + Query: "Helsinki", + Autocomplete: &ac, + }) + if err != nil { + t.Fatalf("Geocode error: %v", err) + } + if gotParam != "false" { + t.Errorf("autocomplete = %q, want %q", gotParam, "false") + } +}