standort

Gopher CircleCI codecov Go Report Card Go Reference Stability: Active

📍 Standort

Standort is a Go service that provides location-based information (country + continent) from:

It exposes two API versions (v1 and v2) over gRPC and HTTP. It is useful when an application needs a small service boundary for country/continent enrichment without calling an external provider at request time.

[!NOTE] Standort ships its lookup data as embedded assets, so runtime responses are determined by the GeoIP2 database and GeoJSON files committed under assets/.


🧭 What “location” means

Standort returns:

Lookups are performed using embedded assets:

[!CAUTION] Freshness is only one accuracy limit. Standort provides coarse country/continent enrichment:

Update the assets and rebuild the service when lookup freshness matters. See assets/SOURCES.md for dataset provenance and terms.

The repository’s MIT license covers repository-owned code. The embedded datasets retain their source terms and attribution requirements, recorded in assets/SOURCES.md.

When replacing embedded lookup data, keep the filenames and formats unchanged:

Record the dataset source/provenance in the asset update change, then rebuild and run the feature harness so changed lookup results are visible in review.

To refresh both committed lookup assets:

MAXMIND_ACCOUNT_ID=... MAXMIND_LICENSE_KEY=... make update-lookup-assets

The refresh requires curl, jq, shasum, and tar in addition to common POSIX tools. MAXMIND_URL and EARTH_GEOJSON_URL may point to alternate locations of the same GeoLite2 Country and Natural Earth Admin-0 1:110m datasets; the generated provenance and terms assume those dataset identities. The size guards can be tuned with LOOKUP_ASSET_MIN_SIZE_PERCENT (default 80, range 1–100) and LOOKUP_ASSET_MAX_SIZE_PERCENT (default 400, minimum 100).

The command checks the downloaded files before replacing assets/geoip2.mmdb and assets/earth.geojson: each replacement must be non-empty, match the expected dataset shape, and stay within size bounds relative to the current committed asset. It also writes the resolved source, checksum, and size metadata to assets/SOURCES.md. The Natural Earth GeoJSON is minified, filtered to features the service can index, and trimmed to the lookup properties expected by the service. If the downloaded assets match the current committed files, the command exits without rewriting the provenance timestamp.


🔌 API versions

1️⃣ v1

v1 has separate RPCs for IP-based lookup and lat/lng-based lookup.

Both methods map invalid or unresolved lookups to gRPC NotFound; the HTTP mapping returns 404.

2️⃣ v2

v2 combines both inputs into a single lookup RPC and also supports batch lookups:

v2 supports passing inputs either directly in the request or via request metadata:

If a lookup fails, v2 keeps trying any other available input, and only returns “not found” when neither IP nor GEO yields a location.

v2 response fields are independent:

Terminal lookup failures return gRPC NotFound; the HTTP RPC router exposes the same lookup miss as HTTP 404. Code-only diagnostics are delivered in gRPC trailing metadata or HTTP response headers for terminal GetLocation failures, and in google.rpc.ErrorInfo.metadata for failed batch entries.

Diagnostic key Meaning Current codes
location-ip-error IP parsing or lookup failed invalid_ip, not_found
location-lat-lng-error Point validation or lookup failed invalid_point, not_found
location-point-error Geolocation metadata could not be parsed invalid_geo_uri

LookupLocations accepts up to 100 lookup entries and preserves request order in the response. Each entry returns one outcome: successful entries populate locations.ip, locations.geo, or both, while entries that do not resolve any location populate a per-entry google.rpc.Status with code NOT_FOUND (5) instead of failing the whole batch. Requests with more than 100 lookup entries fail the whole call with gRPC InvalidArgument; the HTTP RPC mapping returns 400.

For a classified batch failure, status.details contains one google.rpc.ErrorInfo with reason LOCATION_LOOKUP_FAILED and domain standort.v2. Its metadata preserves the safe, code-only diagnostic keys used by terminal lookup failures (such as location-ip-error: not_found). Clients must ignore unknown diagnostic keys and codes; no raw request input or provider error is included.

For the HTTP mapping of LookupLocations, set the Content-Type header to application/pbjson to receive protobuf JSON where each lookup entry has either locations or status.

GetLookupAssets returns read-only metadata for the embedded lookup assets, including asset name, size in bytes, checksum algorithm, and checksum. It is informational only; it does not enforce asset freshness.

[!WARNING] Treat forwarded IP metadata as trusted only after your proxy or gateway has normalized it. Standort reads the metadata supplied by the transport layer; it does not decide whether a forwarded client IP is trustworthy.


🚀 Quickstart (development)

✅ Prerequisites

Optional tools used by specific targets include air, buf, gotestsum, govulncheck, trivy, and hadolint.

🧰 Bootstrap

Initialize submodules and vendor dependencies:

git submodule sync
git submodule update --init
make dep

[!IMPORTANT] Most workflow commands include make fragments from the bin/ submodule, and many Go targets run with -mod vendor. If bin/ is missing or Go reports “inconsistent vendoring,” run the bootstrap commands above again. The bin submodule intentionally uses an SSH URL (git@github.com:alexfalkowski/bin.git), so fresh environments need GitHub SSH credentials before git submodule update --init can succeed.

🏗️ Build

Build the server binary:

make build

🖥️ Run locally (dev config)

The repository includes a dev config at test/.config/server.yaml with default addresses:

♻️ Option A: dev mode (requires air)

make dev

This runs air to rebuild and restart using the dev config.

▶️ Option B: run the binary directly

If you’ve built the binary (via make build), run:

./standort server -config file:test/.config/server.yaml

🔧 Configuration

standort server reads a go-service configuration file from -config (or -c). The dev sample at test/.config/server.yaml is the authoritative local example.

Standort-owned configuration currently adds the required health section:

health:
  duration: 1s
  timeout: 1s

duration is the interval between health check executions, while timeout is the maximum time allowed for one check. Both values must be positive durations. The rest of the file is the embedded go-service configuration, inlined at the top level, including transport addresses/timeouts, limiter settings, logger, metrics, tracer, and ID settings.


❤️ Health endpoints

When running with the dev config, the health HTTP observer endpoints are:

[!NOTE] go-service prefixes operational HTTP routes with the service name. The dev and test harness service name is standort.

The healthz endpoint uses go-health’s online checker. The livez and readyz endpoints are local noop observers. The metrics endpoint is available with the dev config because it sets telemetry.metrics.kind: prometheus.

Use livez or readyz for local process probes that must not depend on public egress. The healthz endpoint is an online check and is expected to report unhealthy when the process cannot reach the public internet.

The gRPC health service registers these service names:


📡 API usage examples

The service is gRPC-first. Its HTTP transport uses go-service RPC routing to map generated gRPC full method names to transport-specific HTTP handlers; the HTTP and gRPC handlers share the same response-building locators.

[!TIP] Use -plaintext with grpcurl for the local dev config unless you have configured TLS.

🧪 gRPC examples

You can use grpcurl against the dev gRPC address (localhost:12000).

🌐 v1: lookup by IP

grpcurl -plaintext \
  -d '{"ip":"8.8.8.8"}' \
  localhost:12000 \
  standort.v1.Service/GetLocationByIP

🗺️ v1: lookup by lat/lng

grpcurl -plaintext \
  -d '{"lat":52.5200,"lng":13.4050}' \
  localhost:12000 \
  standort.v1.Service/GetLocationByLatLng

🌐 v2: lookup with explicit IP

grpcurl -plaintext \
  -d '{"ip":"8.8.8.8"}' \
  localhost:12000 \
  standort.v2.Service/GetLocation

🗺️ v2: lookup with explicit point

grpcurl -plaintext \
  -d '{"point":{"lat":52.5200,"lng":13.4050}}' \
  localhost:12000 \
  standort.v2.Service/GetLocation

🧾 v2: lookup using metadata (headers)

v2 can fall back to metadata when request fields are omitted:

Example with a geo URI:

grpcurl -plaintext \
  -H 'Geolocation: geo:52.5200,13.4050' \
  -d '{}' \
  localhost:12000 \
  standort.v2.Service/GetLocation

Example with a forwarded IP:

grpcurl -plaintext \
  -H 'X-Forwarded-For: 8.8.8.8' \
  -d '{}' \
  localhost:12000 \
  standort.v2.Service/GetLocation

📦 v2: batch lookup

grpcurl -plaintext \
  -d '{"lookups":[{"ip":"8.8.8.8"},{"point":{"lat":52.5200,"lng":13.4050}},{"ip":"192.0.2.1"}]}' \
  localhost:12000 \
  standort.v2.Service/LookupLocations

🧾 v2: embedded lookup assets

grpcurl -plaintext \
  -d '{}' \
  localhost:12000 \
  standort.v2.Service/GetLookupAssets

🌍 HTTP examples

HTTP runs on localhost:11000 with the dev config. The RPC router exposes POST routes using the generated gRPC full method names.

🌐 v1: lookup by IP

curl -sS \
  -X POST \
  -H 'Content-Type: application/json' \
  -d '{"ip":"8.8.8.8"}' \
  http://localhost:11000/standort.v1.Service/GetLocationByIP

🗺️ v1: lookup by lat/lng

curl -sS \
  -X POST \
  -H 'Content-Type: application/json' \
  -d '{"lat":52.5200,"lng":13.4050}' \
  http://localhost:11000/standort.v1.Service/GetLocationByLatLng

🌐 v2: lookup with explicit IP

curl -sS \
  -X POST \
  -H 'Content-Type: application/json' \
  -d '{"ip":"8.8.8.8"}' \
  http://localhost:11000/standort.v2.Service/GetLocation

🗺️ v2: lookup with explicit point

curl -sS \
  -X POST \
  -H 'Content-Type: application/json' \
  -d '{"point":{"lat":52.5200,"lng":13.4050}}' \
  http://localhost:11000/standort.v2.Service/GetLocation

🧾 v2: lookup using metadata headers

curl -sS \
  -X POST \
  -H 'Content-Type: application/json' \
  -H 'Geolocation: geo:52.5200,13.4050' \
  -d '{}' \
  http://localhost:11000/standort.v2.Service/GetLocation

Example with a forwarded IP:

curl -sS \
  -X POST \
  -H 'Content-Type: application/json' \
  -H 'X-Forwarded-For: 8.8.8.8' \
  -d '{}' \
  http://localhost:11000/standort.v2.Service/GetLocation

📦 v2: batch lookup

curl -sS \
  -X POST \
  -H 'Content-Type: application/pbjson' \
  -d '{"lookups":[{"ip":"8.8.8.8"},{"point":{"lat":52.5200,"lng":13.4050}},{"ip":"192.0.2.1"}]}' \
  http://localhost:11000/standort.v2.Service/LookupLocations

🧾 v2: embedded lookup assets

curl -sS \
  -X POST \
  -H 'Content-Type: application/json' \
  -d '{}' \
  http://localhost:11000/standort.v2.Service/GetLookupAssets

🛠️ Development workflow

🎨 Format

make format

🔎 Lint

make lint

To apply automatic fixes where available:

make fix-lint

🧪 Unit/spec tests (Go)

make specs

make specs uses gotestsum directly. Install it if you don’t already have it.

🧩 Feature tests (Ruby harness)

make features

The Ruby harness is configured by test/nonnative.yml and writes logs, coverage, and report artifacts under test/reports/.

The harness starts and stops ../standort server itself with -config file:.config/server.yaml relative to test/, so leave ports 11000 and 12000 free before running it. To rerun a focused slice, pass paths relative to test/:

make features feature=features/v2/transport/grpc/api.feature tags=@grpc

⏱️ Benchmarks (Ruby harness)

make benchmarks

Benchmark runs use the same harness configuration and report directory as feature tests.

Focused benchmark runs also use paths relative to test/:

make benchmarks feature=features/v2/transport/http/benchmark.feature

🧯 Security checks

make sec
make trivy-repo

make sec is the full local security wrapper: it runs govulncheck -show verbose -test ./... and the Trivy repository scan. Use make trivy-repo only when you want the narrower Trivy-only scan.

✅ CI parity

CircleCI initializes submodules, vendors dependencies, then runs linting, protobuf breaking and generated-output stale checks, security checks, feature tests, benchmarks, analysis, coverage, and Codecov upload. To mirror the main validation sequence locally, use:

make dep
make lint
make proto-breaking
make proto-stale
make sec
make features
make benchmarks
make analyse
make coverage

Use make specs as a focused Go test check; it is not a separate CircleCI step in the main build job.


🧬 Protobuf / API generation

Protos live under api/standort/v1 and api/standort/v2.

To lint and generate code:

make proto-lint
make proto-format
make proto-generate

buf generate writes Go protobuf/gRPC files into this repository and Ruby protobuf/gRPC files under test/lib. It uses Buf remote plugins, so make proto-generate and make proto-stale may need network access on first run or when the plugin cache is empty. When a .proto file is renamed or deleted, remove any orphaned generated Go and Ruby outputs in the same change.

Breaking-change check:

make proto-breaking

make proto-breaking compares api/ with the master branch on GitHub and requires network access to that remote baseline.

Generated-output freshness check:

make proto-stale

[!CAUTION] Do not hand-edit generated protobuf or gRPC files. Change the .proto files and run make proto-generate.


🗂️ Repository layout (high level)


🆘 Support and maintenance

Use the GitHub issue tracker for bug reports, documentation fixes, and feature requests. Maintainers should keep README command examples aligned with Makefile, .circleci/config.yml, and the generated API definitions under api/.