272 lines
9.3 KiB
Go
272 lines
9.3 KiB
Go
package utils
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Turning a typed address into coordinates.
|
|
//
|
|
// `tenants` and `tenantlocations` have carried `latitude` and `longitude` since
|
|
// the schema was written, and nothing has ever filled them: the console sends
|
|
// the strings it was given, which are empty, and they are stored empty. Every
|
|
// feature that needs to know where a shop is — delivery radius, "shops near
|
|
// me", a map of the estate — is waiting on this.
|
|
//
|
|
// ── Two providers, one switch ────────────────────────────────────────────────
|
|
//
|
|
// With GEOCODER_API_KEY set, Google's Geocoding API. Without it, OpenStreetMap's
|
|
// Nominatim, which needs no key and no account. Nominatim is good at city and
|
|
// postcode level in India and patchier for one shop's street address, so the
|
|
// key is worth setting eventually — but the absence of one must not be the
|
|
// reason this ships never. Set the key and the next tenant onboarded is
|
|
// geocoded by Google; nothing else changes.
|
|
//
|
|
// ── Never fatal, ever ────────────────────────────────────────────────────────
|
|
//
|
|
// Every path here returns empty strings rather than an error the caller has to
|
|
// decide about. Onboarding a merchant must not fail because a third-party
|
|
// geocoder was slow, rate-limited or down — an outlet with no coordinates is a
|
|
// row someone can fix later, and an outlet that could not be created at all is
|
|
// a person on the phone. The caller writes whatever comes back and carries on.
|
|
|
|
const (
|
|
// Nominatim's usage policy asks for at most one request a second and a
|
|
// User-Agent that identifies the application. Both are honoured below.
|
|
// Onboarding is a handful of merchants a day, so the ceiling is irrelevant
|
|
// in practice — it is respected because it is the condition of use.
|
|
nominatimMinInterval = 1100 * time.Millisecond
|
|
|
|
// Short. This sits inside the request that creates a merchant, and a person
|
|
// is watching a spinner: better to save without coordinates than to make
|
|
// them wait on a geocoder having a bad minute.
|
|
geocodeTimeout = 6 * time.Second
|
|
)
|
|
|
|
var nominatimGate struct {
|
|
mu sync.Mutex
|
|
last time.Time
|
|
}
|
|
|
|
// GeocodeAddress resolves a postal address to latitude and longitude.
|
|
//
|
|
// Returns two empty strings when the address is too thin to be worth sending,
|
|
// when the provider finds nothing, or when anything at all goes wrong. Callers
|
|
// are expected to store the result unconditionally; empty means "not known",
|
|
// which is exactly what the column already holds.
|
|
//
|
|
// Strings, not floats, because that is what the columns are — see
|
|
// `models.Tenants`. Converting here would only mean converting back.
|
|
func GeocodeAddress(address, suburb, city, state, postcode string) (lat, lng string) {
|
|
query := buildQuery(address, suburb, city, state, postcode)
|
|
if query == "" {
|
|
return "", ""
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), geocodeTimeout)
|
|
defer cancel()
|
|
|
|
if key := strings.TrimSpace(os.Getenv("GEOCODER_API_KEY")); key != "" {
|
|
return geocodeGoogle(ctx, query, key)
|
|
}
|
|
return geocodeNominatim(ctx, query)
|
|
}
|
|
|
|
// buildQuery assembles the one-line address the providers want.
|
|
//
|
|
// Empty parts are dropped rather than left as stray commas, and a query with
|
|
// nothing but a country in it is refused: "India" geocodes successfully to the
|
|
// middle of the country, and a shop silently pinned to a field in Madhya
|
|
// Pradesh is worse than a shop with no pin at all, because only one of the two
|
|
// looks wrong.
|
|
func buildQuery(address, suburb, city, state, postcode string) string {
|
|
parts := make([]string, 0, 6)
|
|
for _, part := range []string{address, suburb, city, state, postcode} {
|
|
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
|
parts = append(parts, trimmed)
|
|
}
|
|
}
|
|
// A postcode alone is enough; a city alone is enough. A single fragment
|
|
// that is neither — a house number, say — is not.
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
if len(parts) == 1 && strings.TrimSpace(city) == "" && strings.TrimSpace(postcode) == "" {
|
|
return ""
|
|
}
|
|
|
|
parts = append(parts, "India")
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
/* ── Google ───────────────────────────────────────────────────────────────── */
|
|
|
|
type googleGeocodeResponse struct {
|
|
Status string `json:"status"`
|
|
Results []struct {
|
|
Geometry struct {
|
|
Location struct {
|
|
Lat float64 `json:"lat"`
|
|
Lng float64 `json:"lng"`
|
|
} `json:"location"`
|
|
} `json:"geometry"`
|
|
} `json:"results"`
|
|
ErrorMessage string `json:"error_message"`
|
|
}
|
|
|
|
func geocodeGoogle(ctx context.Context, query, key string) (string, string) {
|
|
endpoint := "https://maps.googleapis.com/maps/api/geocode/json?" + url.Values{
|
|
"address": {query},
|
|
// Biases results towards India without excluding anything, which
|
|
// matters for a name like "Gandhipuram" that also exists elsewhere.
|
|
"region": {"in"},
|
|
"key": {key},
|
|
}.Encode()
|
|
|
|
body, err := fetch(ctx, endpoint, false)
|
|
if err != nil {
|
|
log.Printf("geocode: google request failed: %v", err)
|
|
return "", ""
|
|
}
|
|
|
|
var payload googleGeocodeResponse
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
log.Printf("geocode: google returned unreadable JSON: %v", err)
|
|
return "", ""
|
|
}
|
|
|
|
// ZERO_RESULTS is an answer, not a fault — do not log it as one. The others
|
|
// are configuration problems a person has to fix, so they get named.
|
|
switch payload.Status {
|
|
case "OK":
|
|
case "ZERO_RESULTS":
|
|
return "", ""
|
|
default:
|
|
log.Printf("geocode: google says %s — %s", payload.Status, payload.ErrorMessage)
|
|
return "", ""
|
|
}
|
|
|
|
if len(payload.Results) == 0 {
|
|
return "", ""
|
|
}
|
|
loc := payload.Results[0].Geometry.Location
|
|
return format(loc.Lat), format(loc.Lng)
|
|
}
|
|
|
|
/* ── Nominatim ────────────────────────────────────────────────────────────── */
|
|
|
|
type nominatimResult struct {
|
|
Lat string `json:"lat"`
|
|
Lon string `json:"lon"`
|
|
}
|
|
|
|
func geocodeNominatim(ctx context.Context, query string) (string, string) {
|
|
endpoint := "https://nominatim.openstreetmap.org/search?" + url.Values{
|
|
"q": {query},
|
|
"format": {"json"},
|
|
"limit": {"1"},
|
|
"countrycodes": {"in"},
|
|
"addressdetails": {"0"},
|
|
}.Encode()
|
|
|
|
// One request a second, across every goroutine. Held for the duration of
|
|
// the call rather than just the gap, because two concurrent requests would
|
|
// otherwise both pass a gate that only measures the time since the last
|
|
// one STARTED.
|
|
nominatimGate.mu.Lock()
|
|
if wait := nominatimMinInterval - time.Since(nominatimGate.last); wait > 0 {
|
|
select {
|
|
case <-time.After(wait):
|
|
case <-ctx.Done():
|
|
nominatimGate.mu.Unlock()
|
|
return "", ""
|
|
}
|
|
}
|
|
body, err := fetch(ctx, endpoint, true)
|
|
nominatimGate.last = time.Now()
|
|
nominatimGate.mu.Unlock()
|
|
|
|
if err != nil {
|
|
log.Printf("geocode: nominatim request failed: %v", err)
|
|
return "", ""
|
|
}
|
|
|
|
var results []nominatimResult
|
|
if err := json.Unmarshal(body, &results); err != nil {
|
|
log.Printf("geocode: nominatim returned unreadable JSON: %v", err)
|
|
return "", ""
|
|
}
|
|
if len(results) == 0 {
|
|
return "", ""
|
|
}
|
|
|
|
// Returned as strings already, but parsed and reformatted so both providers
|
|
// store the same shape and precision rather than whichever the upstream
|
|
// happened to send.
|
|
lat, errLat := strconv.ParseFloat(results[0].Lat, 64)
|
|
lng, errLng := strconv.ParseFloat(results[0].Lon, 64)
|
|
if errLat != nil || errLng != nil {
|
|
return "", ""
|
|
}
|
|
return format(lat), format(lng)
|
|
}
|
|
|
|
/* ── Shared ───────────────────────────────────────────────────────────────── */
|
|
|
|
func fetch(ctx context.Context, endpoint string, identify bool) ([]byte, error) {
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if identify {
|
|
// Nominatim's policy requires an identifying User-Agent and rejects
|
|
// requests without one. Fair: it is a free service run on donations.
|
|
request.Header.Set("User-Agent", "NearleFiesta/1.0 (care@nearle.in)")
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
|
|
response, err := http.DefaultClient.Do(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP %d", response.StatusCode)
|
|
}
|
|
|
|
buffer := make([]byte, 0, 4096)
|
|
chunk := make([]byte, 4096)
|
|
for {
|
|
n, err := response.Body.Read(chunk)
|
|
if n > 0 {
|
|
buffer = append(buffer, chunk[:n]...)
|
|
}
|
|
// A geocoder answering with megabytes is a geocoder that is not
|
|
// answering the question asked.
|
|
if len(buffer) > 1<<20 {
|
|
return nil, fmt.Errorf("response too large")
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
return buffer, nil
|
|
}
|
|
|
|
// format renders a coordinate at six decimal places — about 11cm, which is
|
|
// finer than any address-level geocoder is actually accurate to, and stops the
|
|
// column holding `12.99999999999998`.
|
|
func format(value float64) string {
|
|
return strconv.FormatFloat(value, 'f', 6, 64)
|
|
}
|