thinwrap_location (Erlang)

View Source

Unified, SDK-free, zero-dependency Erlang wrapper for routing, distance matrix, geocoding, and isochrone across Google, Mapbox, HERE, ESRI, TomTom, and OSRM. Switch vendor by changing the config's provider; the input and output shapes stay identical. Synchronous, stateless, OTP-only — bring your own HTTP client if you want hackney, gun or Finch. Works the same from Elixir and Gleam.

%% rebar.config
{deps, [thinwrap_location]}.
# mix.exs
{:thinwrap_location, "~> 1.3"}

Requires Erlang/OTP ≥ 27. No runtime dependencies (OTP inets, ssl, json).

Two-minute route

Config = #{provider => google, api_key => <<"...">>},
case thinwrap_location:route(Config, #{
    waypoints => [#{lat => 40.7128, lng => -74.0060},   % New York
                  #{lat => 41.4173, lng => -73.0001}]   % Bridgeport
}) of
    {ok, #{total_distance_meters := M, total_duration_seconds := S}} ->
        io:format("~.1f km, ~B min~n", [M / 1000, round(S / 60)]);
    {error, #{code := Code, message := Msg}} ->
        io:format("~s: ~s~n", [Code, Msg])
end.
config = %{provider: :google, api_key: System.fetch_env!("GOOGLE_API_KEY")}

{:ok, %{total_distance_meters: m}} =
  :thinwrap_location.route(config, %{
    waypoints: [%{lat: 40.7128, lng: -74.0060}, %{lat: 41.4173, lng: -73.0001}]
  })

Operations & providers

The config's provider selects the connector. Asking a provider for an operation it does not support raises {badarg, _} (e.g. geocode/2 with osrm); see Error handling. thinwrap_location:supports/2 answers the question at runtime.

FunctionProviders
route/2Google, Mapbox, HERE, ESRI, OSRM, TomTom
matrix/2 (flat cells)Google, Mapbox, HERE, ESRI, OSRM, TomTom
geocode/2, reverse_geocode/2, autocomplete/2, place_details/25 (no OSRM)
isochrone/2Mapbox, HERE, ESRI, TomTom

Configs:

providerKeys
google, here, tomtomapi_key
mapboxaccess_token
esriexactly one of api_key or arcgis_token
osrmbase_url (required), optional supported_exclude_classes

Any credential may be a binary or a zero-arity fun returning one (api_key => fun() -> os:getenv(...) end), which keeps the secret out of crash reports that print the config. Every config also accepts transport (see below).

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 to the lean option; you opt up explicitly.

OptionDefaultWhat it changes
polyline_qualitysimplifiedGeometry fidelity. detailed 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.
traffic_modenoneWhether to route against traffic (see Traffic). traffic selects a Pro-tier SKU on Google and HERE's Time-Aware Routing rate, so it is never enabled implicitly — not even by passing departure_time. live is a deprecated alias of traffic.
include[]Which optional output fields to fetch. Each token maps 1:1 onto one optional result field.
{ok, Result} = thinwrap_location:route(Config, #{
    waypoints => Waypoints,
    traffic_mode => traffic,                 % traffic-aware routing
    polyline_quality => detailed,            % full geometry
    include => [durationWithoutTraffic]      % the extra output field
}),
%% Present only when requested AND returned natively — never synthesized, so
%% an absent key tells you this provider did not supply it.
case Result of
    #{total_duration_without_traffic_seconds := Free, total_duration_seconds := T} ->
        {congestion_seconds, T - Free};
    _ ->
        unknown
end.

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

Traffic: none vs traffic

traffic_mode applies to routing and matrix. none (the default) uses the provider's time-independent speeds, and a departure_time never adds traffic. traffic uses the traffic expected at departure_time: live close to now, typical later, and now when no time is given. live is a deprecated alias of traffic and behaves identically.

Providernonetraffic
GoogleTRAFFIC_UNAWARE; the time is not sent (Google rejects it there)TRAFFIC_AWARE + time (Pro SKU)
HEREdepartureTime=any, or the time + traffic[mode]=disabledHERE's default, with the time if given (Time-Aware SKU on routing)
Esrithe time is not sentthe time, or now
Mapbox Directionsdriving, the time is not sentdriving-traffic + time
Mapbox Matrixdriving, the time is not sentdriving-traffic; driving with a time → unsupported_field (depart_at is a beta)
TomTom routingtraffic=false + time. Approximate: TomTom still applies typical traffictraffic=true + time
TomTom matrixhistorical + departAt: "any" (the time is not sent)live + the time, or "now"
OSRM, Mapbox Optimization, walking/cyclingno trafficignored (no traffic available). Mapbox Optimization driving + time → unsupported_field

departure_time is Unix seconds, a UTC calendar:datetime(), or an RFC 3339 binary (<<"2026-11-02T08:00:00Z">>).

Making OSRM's avoid-flags work

Whether OSRM accepts exclude=toll is a property of your server, not of OSRM: the public demo build rejects it with InvalidValue, while a self-hosted instance verified live honoured it and genuinely rerouted. Stock OSRM compiles no exclude classes, so the flags are rejected up front by default. If your profile was built with them, declare it:

Osrm = #{provider => osrm,
         base_url => <<"https://routing.internal">>,
         supported_exclude_classes => [<<"toll">>, <<"ferry">>]}.

Autocomplete → place details

autocomplete/2 returns predictions; place_details/2 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, and it returns an ordinary geocode candidate.

{ok, #{predictions := [First | _] = Preds}} =
    thinwrap_location:autocomplete(Config, #{input => <<"blue bottle">>}),
%% Render the usual two-line suggestion without splitting `description` on a comma.
[case P of
     #{structured_format := #{main_text := Main} = Sf} ->
         {Main, maps:get(secondary_text, Sf, <<>>)};
     #{description := D} ->
         {D, <<>>}
 end || P <- Preds],
{ok, Details} = thinwrap_location:place_details(Config, #{place_id => maps:get(place_id, First)}).

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

Details has no candidate key when the provider does not know the id (HERE and Mapbox answer 404, TomTom and Esri an empty result, Google a 404 NOT_FOUND) or returns the place without a location — ids expire and places close, so match on it. raw still carries the vendor body. An id the vendor rejects as invalid is still an invalid_request error — and Google answers most unknown ids that way (400 INVALID_ARGUMENT), as does Esri for a stale magicKey.

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: include => [name]. This is the opposite of Compute Routes, whose SKU is driven by request features.

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 session_token. The wrapper holds no state, so it cannot remember the token for you; when you omit it, the connector generates a fresh UUIDv4 for that one call, which is valid but billed as its own session.

structured_format support

Providermain_text / secondary_text
GooglestructuredFormat.mainText / .secondaryText — default-on, free
Mapboxname / place_formatted
HEREtitle / address.label — no secondary_text for query-type suggestions
TomTompoi.name / address.freeformAddress — absent for street results
Esrinot supported — a single flat text

It is never synthesized: no structured_format key means the provider gave no distinct main part, and description remains the thing to render.

Normalized surface

Distances are meters, durations seconds (both floats), coordinates #{lat, lng} (latitude first), and route geometry a Google precision-5 polyline binary. Optional output fields are omitted from the result map when the provider gave nothing — read them with maps:get/3 or pattern match. Every result carries raw, the decoded vendor body (objects as binary-keyed maps, JSON null as the atom null). Isochrone contour geometry is a GeoJSON Polygon ([Exterior | Holes], rings closed, exterior counter-clockwise and holes clockwise per RFC 7946), or a MultiPolygon when a contour comes in several disconnected parts — check type before reading coordinates.

Bring your own HTTP client

The default transport is OTP httpc (thinwrap_location_httpc): HTTPS-only, 30-second timeout, never follows redirects, and verifies certificates and host names with OTP's defaults. An http:// URL — e.g. a self-hosted OSRM at http://localhost:5000 — is refused with invalid_request before any network I/O. Options: transport => {thinwrap_location_httpc, #{timeout => 10000, profile => my_profile}}.

To use another client, implement the one-callback thinwrap_location_transport behaviour:

-module(my_hackney_transport).
-behaviour(thinwrap_location_transport).
-export([request/2]).

request(#{method := Method, url := Url, headers := Headers} = Req, _Opts) ->
    Body = maps:get(body, Req, <<>>),
    case hackney:request(Method, Url, maps:to_list(Headers), Body, [with_body]) of
        {ok, Status, RespHeaders, RespBody} ->
            {ok, #{status => Status,
                   headers => maps:from_list([{string:lowercase(K), V} || {K, V} <- RespHeaders]),
                   body => RespBody}};
        {error, Reason} ->
            {error, Reason}
    end.

Contract: a non-2xx must be returned as a response, never as an error — the connector's own status mapping (429 → rate_limited, 401 → auth_failed, …) reads it. Return {error, timeout} on a timeout so callers get the timeout code. Never follow redirects.

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

Passthrough

Forward vendor-specific fields the normalized input doesn't expose with passthrough => #{body => ..., headers => ..., query => ...}. body is deep-merged into the request body; headers/query are shallow-merged. Your values win, including over connector-set ones. Header names match case-insensitively: your x-goog-fieldmask replaces the connector's X-Goog-FieldMask and is sent in your spelling.

thinwrap_location:route(Config, #{waypoints => Wps,
                                  passthrough => #{query => #{alternatives => <<"true">>}}}).

A list-valued vendor parameter the connector already sets — Google's X-Goog-FieldMask, HERE routing's return, a matrix annotations list, Esri's accumulateAttributeNames — is replaced by your value, not merged, so you can also remove a default. To append instead, start from the default the connector itself sends, published in thinwrap_location_defaults:

Mask = lists:join(<<",">>, thinwrap_location_defaults:google_routes_field_mask()
                           ++ [<<"routes.travelAdvisory">>]),
thinwrap_location:route(Config, #{waypoints => Wps, passthrough =>
    #{headers => #{<<"X-Goog-FieldMask">> => iolist_to_binary(Mask)}}}).

Polyline utilities

Four locked, cross-language-parity helpers in thinwrap_location_polyline: encode/1, decode/1, decode_flex/1 (HERE) and encode_esri_paths/1.

Error handling

Every operation failure is {error, Error}, a plain map:

#{code := atom(),               % the classification to branch on
  message := binary(),
  status => integer(),          % the HTTP status, when the provider answered
  provider_message => binary(),
  cause => term()}              % redacted vendor body or transport message

That covers a vendor error, a transport failure, a timeout, request data rejected before sending (too few waypoints, an empty matrix, a malformed country code, an unparseable date, …) and a capability the provider lacks (unsupported_*). Pre-flight rejections have no status.

Misusing the API is a bug in your code, so it raises error({badarg, Description}) before any request and is deliberately not an {error, _}: an unknown or missing config/input key, a value of the wrong type, an atom outside its set (travel_mode => flying), a provider without the operation, a transport module that does not export request/2. The description never includes config values.

HERE's transport_mode is forwarded verbatim, so HERE judges it and a bad value is an {error, _}.

code has 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) — the same strings as the TS, PHP, Go and Python siblings.

no_route — "there is no route", normalized

The providers agree on nothing here. Google answers HTTP 200 with routes 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 practice it almost always means a waypoint could not be matched to the road network: every provider tested 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. There is no retry_after_seconds key: the raw Retry-After header rides in cause as <<"retryAfter">>, with its parsed seconds woven into provider_message.

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