Skip to content

Geocoding endpoints — capability-gated autocomplete & reverse

Granit.Geocoding is a server-side contract. Two of its capabilities, though, are things a browser needs to call directly: an address autocomplete that fires as the user types, and a reverse lookup when someone drops a pin on a map. Granit.Geocoding.Endpoints exposes exactly those two — and nothing else — as HTTP endpoints.

There is deliberately no forward-geocoding endpoint. Resolving a full stored address to a coordinate is back-office enrichment (see Granit.AddressEnrichment), not something a client should drive. Autocomplete and reverse are the only two that belong on the wire, because only they answer an interactive, per-user question.

The host maps the group with one call, which returns a RouteGroupBuilder for further chaining:

app.MapGranitGeocoding();
Method & routeQueryMapped whenResponse
GET /geocoding/autocompleteq (required), limit (default 5)an autocomplete-capable provider is registered200 GeocodingAutocompleteResponse
GET /geocoding/reverselat, lon (required)a reverse-capable provider is registered200 GeocodingReverseResponse, else 400 / 404 problem

The route prefix (geocoding), OpenAPI tag, and authorization policy are configurable via GeocodingEndpointsOptions (section Geocoding:Endpoints).

Each endpoint is mapped only when the registered provider can serve it — the group reads the GeocodingCapabilities aggregate at map time:

if (capabilities.Autocomplete)
group.MapGet("/autocomplete", HandleAutocompleteAsync) /* … */;
if (capabilities.Reverse)
group.MapGet("/reverse", HandleReverseAsync) /* … */;

So a host running Nominatim only (forward + reverse, no autocomplete) exposes /reverse but not /autocomplete — the endpoint simply doesn’t exist, rather than returning a runtime “not supported” error. Add Photon and the /autocomplete route appears. The OpenAPI document reflects exactly what the deployment can do.

public sealed record GeocodingAutocompleteResponse(IReadOnlyList<GeocodingSuggestionResponse> Suggestions);
public sealed record GeocodingSuggestionResponse(
string Label, string? Street, string? PostalCode, string Locality, string Country,
double? Latitude, double? Longitude);
public sealed record GeocodingReverseResponse(
string? Street, string? PostalCode, string Locality, string Country, string Precision);

The DTOs are flattened for the wire — a suggestion carries both a human-readable Label for the typeahead list and the structured components a form fills in when the user picks it. Precision is the match granularity (Rooftop, Street, or Locality).

Geocoding proxies an external, rate-limited, billable provider, so the group is never anonymous. By default MapGranitGeocoding() requires any authenticated user; set GeocodingEndpointsOptions.AuthorizationPolicy to demand a specific policy instead.

The host must rate-limit — denial-of-wallet

Section titled “The host must rate-limit — denial-of-wallet”

The package does not apply a rate-limit policy itself, and this is the one thing a host must add. Autocomplete fires per keystroke, and the upstream provider quota is a shared resource: one principal hammering the endpoint can exhaust the quota — or run up the bill — for every tenant. That’s a denial-of-wallet, not just a denial-of-service.

Pull in the module and map the group:

[DependsOn(typeof(GranitGeocodingEndpointsModule))]
public sealed class StorefrontModule : GranitModule { }
// in the host's endpoint wiring
app.MapGranitGeocoding()
.RequireGranitRateLimiting("geocoding");

GranitGeocodingEndpointsModule depends on GranitGeocodingModule, so the geocoding services and the GeocodingCapabilities aggregate are already registered — register a provider (Nominatim or Photon) and the matching endpoints light up.

  • Geocoding — the contracts, providers, and the capability matrix these endpoints gate on.
  • Rate limitingRequireGranitRateLimiting and per-principal partitioning.
  • Address platform overview — where the endpoints sit in the wider platform.