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).
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)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.
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.
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 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.
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.
| Provider | MainText / SecondaryText |
|---|---|
structuredFormat.mainText / .secondaryText — default-on, free |
|
| Mapbox | name / place_formatted |
| HERE | title / address.label — SecondaryText empty for query-type suggestions, which carry no address |
| TomTom | poi.name / address.freeformAddress — nil 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.
// 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"})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.
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.)
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"}},
})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}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.
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.
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
}
}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