Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

thinwrap/location (Go)

Unified, SDK-free, zero-dependency Go wrapper for routing, distance matrix, geocoding, and isochrone across Google, Mapbox, HERE, ESRI, TomTom, and OSRM. Switch vendor by changing the config type; the input and output shapes stay identical. Stateless — bring your own *http.Client, no vendor SDKs.

import location "github.com/thinwrap/location-go"

Requires Go ≥ 1.18. No third-party dependencies (standard library only).

Two-minute route

r := location.NewRouting(location.GoogleConfig{APIKey: os.Getenv("GOOGLE_MAPS_API_KEY")})

res, err := r.Route(ctx, location.RoutingOptions{
    Waypoints: []location.LatLng{
        {Lat: 40.7128, Lng: -74.0060}, // New York
        {Lat: 41.4173, Lng: -73.0001}, // Bridgeport
    },
})
if err != nil {
    var ce *location.ConnectorError
    if errors.As(err, &ce) {
        log.Printf("%s: %s", ce.ProviderCode, ce.ProviderMessage)
    }
    return
}
fmt.Printf("%.1f km, %.0f min\n", res.TotalDistanceMeters/1000, res.TotalDurationSeconds/60)

Operations & providers

The config type selects the provider. An operation a provider does not support is unrepresentable at compile time (e.g. OsrmConfig does not satisfy GeocodingConfig, so NewGeocoding(OsrmConfig{...}) will not compile).

Facade (constructor) Method(s) Providers
NewRouting Route Google, Mapbox, HERE, ESRI, OSRM, TomTom
NewMatrix Matrix Google, Mapbox, HERE, ESRI, OSRM, TomTom
NewGeocoding Geocode, ReverseGeocode, Autocomplete Google, Mapbox, HERE, ESRI, TomTom
NewIsochrone Isochrone Mapbox, HERE, ESRI, TomTom

Configs: GoogleConfig{APIKey}, MapboxConfig{AccessToken}, HereConfig{APIKey}, EsriConfig{APIKey|ArcGISToken} (exactly one), OsrmConfig{BaseURL} (required), TomTomConfig{APIKey}.

Per-provider details (endpoints, auth, error mapping, passthrough) live in docs/providers/ — one page per provider.

Routing options that cost money

Three inputs exist because the cheap thing and the correct thing are not always the same request. All three default (via their zero value) to the lean option.

Option Zero value What it changes
PolylineQuality PolylineSimplified Geometry fidelity. PolylineDetailed returned a 30x larger polyline on Mapbox and 31x on OSRM in measurement, with identical distances and durations. Honoured by Google/Mapbox/OSRM; silently ignored by HERE/TomTom/Esri.
TrafficMode TrafficNone Whether to route against live traffic. TrafficLive selects a Pro-tier SKU on Google, so it is never enabled implicitly — not even by setting DepartureTime.
Include nil Which optional output fields to fetch. Each token maps 1:1 onto one optional result field.
res, err := routing.Route(ctx, location.RoutingOptions{
    Waypoints:       waypoints,
    TrafficMode:     location.TrafficLive,                    // opt into traffic-aware routing
    PolylineQuality: location.PolylineDetailed,               // opt into full geometry
    Include:         []location.RoutingInclude{location.IncludeDurationWithoutTraffic},
})

// Non-nil only when requested AND returned natively — never synthesized, so nil
// tells you this provider did not supply it.
if res.TotalDurationWithoutTrafficSeconds != nil {
    congestion := res.TotalDurationSeconds - *res.TotalDurationWithoutTrafficSeconds
    _ = congestion
}

IncludeDurationWithoutTraffic is native on Google (staticDuration), HERE (baseDuration) and TomTom (noTrafficTravelTimeInSeconds); Mapbox, OSRM and Esri do not return it, so the field stays nil there rather than being faked.

Making OSRM's avoid-flags work

Whether OSRM accepts exclude=toll is a property of your server, not of OSRM. The same request was verified live against two builds with opposite results: the public demo build rejects it with InvalidValue, while a self-hosted instance honoured it and genuinely rerouted (138075 m / 5890 s via the toll road → 130421 m / 6513 s without).

Stock OSRM compiles no exclude classes, so the flags are rejected up front by default. If your profile was built with them, declare it:

routing := location.NewRouting(location.OsrmConfig{
    BaseURL:                 "https://routing.internal",
    SupportedExcludeClasses: []string{"toll", "ferry"},
})

Autocomplete → place details

Autocomplete returns predictions; PlaceDetails resolves one into a full candidate. "Place details" and "geocode by place id" are the same vendor call on all five providers, so this is one operation, not two — and it returns an ordinary GeocodeCandidate.

geocoding := location.NewGeocoding(location.GoogleConfig{APIKey: key})

sug, _ := geocoding.Autocomplete(ctx, location.AutocompleteOptions{Input: "blue bottle"})

// Render the usual two-line suggestion without splitting Description on a comma.
for _, p := range sug.Predictions {
    if p.StructuredFormat != nil {
        fmt.Println(p.StructuredFormat.MainText, "/", p.StructuredFormat.SecondaryText)
    } else {
        fmt.Println(p.Description)
    }
}

det, _ := geocoding.PlaceDetails(ctx, location.PlaceDetailsOptions{
    PlaceID: sug.Predictions[0].PlaceID,
})

PlaceID values are provider-scoped — a Google place id is meaningless to Mapbox.

Two things that cost money here

Google's Place Details SKU is driven by the field mask, so Name (displayName) is a Pro-tier field and only requested behind an opt-in:

geocoding.PlaceDetails(ctx, location.PlaceDetailsOptions{
    PlaceID: id,
    Include: []location.PlaceDetailsInclude{location.IncludePlaceName},
})

Note this is the opposite of Compute Routes, whose SKU is driven by request features — check per API rather than generalizing.

Mapbox Search Box bills per session, not per request. A suggest and the retrieve that follows count as one billable session only when they carry the same token, so pass the same value to both:

token := uuid()  // one per user interaction

mapbox.Autocomplete(ctx, location.AutocompleteOptions{
    Input:       input,
    Passthrough: &location.Passthrough{Query: map[string]string{"session_token": token}},
})
mapbox.PlaceDetails(ctx, location.PlaceDetailsOptions{PlaceID: id, SessionToken: token})

The wrapper cannot generate or remember that token — it holds no state.

StructuredFormat support

Provider MainText / SecondaryText
Google structuredFormat.mainText / .secondaryText — default-on, free
Mapbox name / place_formatted
HERE title / address.labelSecondaryText empty for query-type suggestions, which carry no address
TomTom poi.name / address.freeformAddressnil for street results, which have no poi.name
Esri not supported — returns a single flat text

It is never synthesized: a nil StructuredFormat means the provider gave no distinct main part, and Description remains the thing to render.

Switching providers

// Same Route(ctx, opts) call, same RoutingResult — only the config changes.
google := location.NewRouting(location.GoogleConfig{APIKey: key})
mapbox := location.NewRouting(location.MapboxConfig{AccessToken: token})
here   := location.NewRouting(location.HereConfig{APIKey: key})
osrm   := location.NewRouting(location.OsrmConfig{BaseURL: "http://localhost:5000"})

Normalized surface

Distances are meters, durations are seconds, coordinates are LatLng{Lat, Lng} (lat-first), and route geometry is a Google precision-5 polyline string. Every result carries a Raw any escape hatch holding the decoded vendor body. Isochrone contour geometry is a GeoJSON Polygon.

Bring your own HTTP client

The default client never follows redirects (a 3xx surfaces as an error rather than re-sending auth headers). Inject any HTTPClient (satisfied by *http.Client) for tracing, retries, proxying, or tests:

Contract: a non-2xx must be RETURNED as a *http.Response, not reported as an error — the same rule *http.Client follows. Each connector's status mapping (429 → rate-limited, 401 → auth-failed, …) reads the response, so a client that converts a non-2xx into an error collapses every provider error into provider_unavailable with no status. Return errors only for genuine transport failures: DNS, connection, TLS, timeout.

r := location.NewRouting(cfg, location.WithHTTPClient(myClient))

The wrapper holds no state — no caching, retries, idempotency keys, or telemetry. (The HERE/TomTom async-matrix submit/poll/retrieve cycle is transient, within a single Matrix call.)

_passthrough escape hatch

Forward vendor-specific fields the normalized input doesn't expose. Body is deep-merged into the request body; Headers/Query are shallow-merged. Consumer values win (including over connector-set values).

res, err := r.Route(ctx, location.RoutingOptions{
    Waypoints:   waypoints,
    Passthrough: &location.Passthrough{Query: map[string]string{"alternatives": "true"}},
})

Polyline utilities

Four locked, cross-language-parity helpers (stdlib-only):

location.EncodePolyline(coords)        // []LatLng -> Google precision-5 string
location.DecodePolyline(encoded)       // precision-5 string -> []LatLng
location.DecodeFlexPolyline(encoded)   // HERE flex-polyline -> []LatLng
location.EncodeEsriPaths(paths)        // [][]LatLng -> ESRI-JSON {paths, spatialReference}

Error handling

Every failure is a *ConnectorError (retrieve with errors.As) carrying a typed ProviderCode: the 6 canonical values (invalid_recipient, rate_limited, auth_failed, provider_unavailable, invalid_request, unknown) plus 7 location-extended (unsupported_field, unsupported_option, unsupported_travel_mode, profile_not_configured, matrix_polling_timeout, no_route, timeout) — byte-identical to the TypeScript, PHP and Python siblings.

no_route — "there is no route", normalized

The providers agree on nothing here. Google answers HTTP 200 with the routes key absent; HERE 200 with routes: [] plus a notices[].code; Mapbox code: "NoRoute" on either 200 or 422; OSRM the same codes on a 400; TomTom a 400 with detailedError.code; Esri a 200 whose in-body error.code: 400 names an unlocated stop in details[]. Branching on "no usable route" used to mean reimplementing all six.

In practice it almost always means a waypoint could not be matched to the road network rather than the road network is disconnected: every provider tested happily routes Reykjavik→Oslo via ferry.

timeout

Separated from provider_unavailable because it is the one transport failure a caller acts on differently — back off and retry, versus treat the provider as down.

The built-in http.Client carries a 30-second timeout; inject your own HTTPClient or pass a context with a deadline to change it. Both classify as CodeTimeout.

There is no top-level RetryAfterSeconds field: the raw Retry-After header rides in Cause (key "retryAfter") and its parsed seconds are woven into ProviderMessage.

var ce *location.ConnectorError
if errors.As(err, &ce) {
    switch ce.ProviderCode {
    case location.CodeRateLimited:      // back off
    case location.CodeAuthFailed:       // check credentials
    case location.CodeProviderUnavailable: // transient
    }
}

Security

Report vulnerabilities privately — please do not open a public issue. Preferred: a private security advisory on this repository. Alternatively, email security@thinwrap.dev. Include the affected versions and a minimal reproduction if you have one.

A vulnerability in a provider's own API or service belongs to that vendor rather than to this wrapper — please report those upstream.

MIT © Dmitry Polyanovsky

About

SDK-free, zero-dependency Go wrapper for routing, distance matrix, geocoding, and isochrone across Google, Mapbox, HERE, ESRI, TomTom, and OSRM.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages