Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions geocoding_forward.go
Original file line number Diff line number Diff line change
@@ -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
}
177 changes: 177 additions & 0 deletions geocoding_forward_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}