Skip to content

chuprik/coordinates-resolver

Repository files navigation

coordinates-resolver

A reverse-geocoding microservice in Go. Given coordinates (latitude/longitude), it returns the nearest city plus its first-level region (admin1 — province/state), second-level region (admin2 — county/district) and country. Fully offline — no external API calls. Data comes from GeoNames.

  • Single-point lookup: GET /reverse
  • Batch lookup (up to 1000 points): POST /reverse/batch
  • OpenAPI 3.0.3 spec as the single source of truth; Go types and the server interface are generated from it with oapi-codegen.
  • Nearest-neighbour search via a self-contained 3D KD-tree on unit-sphere coordinates; reported distances use the haversine great-circle formula.

Quick start

make download-data     # fetch GeoNames files into ./data
make run               # start the server on :8080
curl "localhost:8080/reverse?lat=34.05&lon=-118.24"
{
  "city": "Los Angeles",
  "admin1": "California",
  "admin1_code": "CA",
  "admin2": "Los Angeles County",
  "admin2_code": "037",
  "country": "United States",
  "country_code": "US",
  "distance_km": 0.42,
  "geoname_id": 5368361
}

admin2 (county/district) is empty for cities that have no second-level code in GeoNames — e.g. Moscow:

curl "localhost:8080/reverse?lat=55.75&lon=37.61"
# {"admin1":"Moscow","admin1_code":"48","admin2":"","admin2_code":"",
#  "city":"Moscow","country":"Russia","country_code":"RU",
#  "distance_km":0.54,"geoname_id":524901}

The OpenAPI 3.0.3 spec (openapi.yaml) is the source of truth and is not served at runtime; render it with any external viewer (e.g. npx @redocly/cli preview-docs openapi.yaml) or make validate-spec.


Downloading data

make download-data downloads three files into ./data:

File Purpose
cities500.txt populated places (population ≥ 500)
admin1CodesASCII.txt first-level region (province/state) names
admin2Codes.txt second-level region (county/district) names
countryInfo.txt country names

To use a smaller/larger cities file, pass CITIES:

make download-data CITIES=cities15000      # ~34k cities, faster startup
# then run with the matching file name:
CITIES_FILE=cities15000.txt make run

Manual download (equivalent): grab cities500.zip (unzip it), admin1CodesASCII.txt, and countryInfo.txt from https://download.geonames.org/export/dump/ into ./data.


Running

Locally

make run                                  # uses ./data and cities500.txt
PORT=9000 CITIES_FILE=cities15000.txt make run

Docker

The image is a static binary on distroless. GeoNames data is not baked in — provide it at runtime in one of two ways:

Option A — mount a volume (recommended):

make docker-build
docker run --rm -p 8080:8080 \
  -v "$(pwd)/data:/data" \
  coordinates-resolver:latest

Option B — bake data in a separate build step. Add a stage to the Dockerfile that downloads the files into /data (or COPY ./data /data) and keep GEONAMES_DIR=/data. This trades a larger image for a self-contained one; prefer it only when you cannot mount a volume.


API examples

Single point

curl "localhost:8080/reverse?lat=35.66&lon=139.70"
{
  "city": "Dōgenzaka",
  "admin1": "Tokyo",
  "admin1_code": "40",
  "admin2": "Shibuya-ku",
  "admin2_code": "1852582",
  "country": "Japan",
  "country_code": "JP",
  "distance_km": 0.32,
  "geoname_id": 10866710
}

Invalid coordinates return 400:

curl -i "localhost:8080/reverse?lat=91&lon=10"
# HTTP/1.1 400 Bad Request
# {"code":"invalid_coordinates","message":"latitude out of range [-90, 90]"}

Batch

curl -X POST localhost:8080/reverse/batch \
  -H 'Content-Type: application/json' \
  -d '{"points":[{"lat":34.05,"lon":-118.24},{"lat":91.0,"lon":-74.0}]}'
[
  {
    "index": 0,
    "lat": 34.05,
    "lon": -118.24,
    "match": {
      "city": "Los Angeles",
      "admin1": "California",
      "admin1_code": "CA",
      "admin2": "Los Angeles County",
      "admin2_code": "037",
      "country": "United States",
      "country_code": "US",
      "distance_km": 0.42,
      "geoname_id": 5368361
    },
    "error": null
  },
  {
    "index": 1,
    "lat": 91.0,
    "lon": -74.0,
    "match": null,
    "error": "latitude out of range [-90, 90]"
  }
]

Partial failures do not fail the whole request: invalid points get match: null + an error, valid points still resolve, and the HTTP status stays 200. Order is preserved (index matches the input position).

100-point batch

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
)

func main() {
	// Build a 100-point request and post it.
	type point struct {
		Lat float64 `json:"lat"`
		Lon float64 `json:"lon"`
	}
	points := make([]point, 100)
	for i := range points {
		points[i] = point{Lat: 55.75 + float64(i)*0.001, Lon: 37.61 + float64(i)*0.001}
	}

	body, _ := json.Marshal(map[string]any{"points": points})
	resp, err := http.Post("http://localhost:8080/reverse/batch",
		"application/json", bytes.NewReader(body))
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	var results []json.RawMessage
	if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
		panic(err)
	}
	fmt.Println(len(results), "results")
	// -> 100 results
}

Service endpoints

Endpoint Description
GET /health {"status":"ok","points_loaded":N}
GET /ready 200 once data is loaded and indexed, else 503

Configuration

Var Default Purpose
PORT 8080 HTTP listen port
GEONAMES_DIR ./data Directory with GeoNames files
CITIES_FILE cities500.txt Cities file name
MAX_BATCH_SIZE 1000 Max points per batch request
MAX_BODY_BYTES 1048576 Max request body size (1 MB)
BATCH_WORKERS GOMAXPROCS Worker-pool size for batch resolution
LOG_LEVEL info info / debug

Development

make generate          # regenerate types/server from the OpenAPI file
make verify-generated  # CI guard: fails if generated code is stale
make validate-spec     # lint the spec (redocly via npx; skipped if Node is absent)
make test              # run all tests
make bench             # run benchmarks

Limitations

  • Returns the nearest city by distance, not the city/region by administrative boundary. There is no point-in-polygon test, so a coordinate just across a border may resolve to a nearby city in the neighbouring region/country.
  • Accuracy and coverage depend on the chosen cities file: cities500.txt is the most complete; cities15000.txt is smaller and faster but coarser.
  • City admin1/admin2/country names are only as good as the GeoNames lookup tables; unknown codes resolve to empty strings.
  • admin2 (county/district) is not assigned to every place in GeoNames — many cities (including capitals like Moscow) have no admin2 code, so admin2 / admin2_code come back empty. GeoNames provides no name lookup beyond admin2 (no admin3/admin4 files), so deeper levels are not exposed.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages