// spatiald — the PostGIS query service for the trip planner. // // Native spatial queries over the osm2pgsql-loaded OSM extracts: // // GET /health { ok, extracts: {name: poi_count} } // GET /near?lat=&lng=&r= ST_DWithin radius search // GET /nearest?lat=&lng= KNN (ORDER BY geom <-> point) // GET /corridor?points=&r= corridor buffering along a route // // Optional filters on all three: // // extract=nh restrict to one loaded extract // kind=amenity=restaurant exact kind match, or one of the bare values // (restaurant, museum, cafe, …) // name=café ILIKE substring on the POI name // limit=20 default 20, max 200 // // /corridor's `points` is "lng,lat;lng,lat;…" (a router polyline, downsampled // is fine — the buffer does the smoothing work). package main import ( "database/sql" "encoding/json" "flag" "fmt" "log" "net/http" "strconv" "strings" "time" _ "github.com/lib/pq" ) var ( dsn *sql.DB addr string ) type poi struct { Extract string `json:"extract"` OsmID int64 `json:"osm_id"` OsmType string `json:"osm_type"` Name string `json:"name"` Kind string `json:"kind"` OpenH *string `json:"opening_hours,omitempty"` Fee *string `json:"fee,omitempty"` Website *string `json:"website,omitempty"` AddrCity *string `json:"addr_city,omitempty"` DistM float64 `json:"dist_m"` } func main() { dsnFlag := flag.String("dsn", "host=localhost port=5432 user=trips password=trips dbname=trips sslmode=disable", "PostgreSQL/PostGIS DSN") addrFlag := flag.String("addr", ":5005", "listen address") flag.Parse() addr = *addrFlag var err error dsn, err = sql.Open("postgres", *dsnFlag) if err != nil { log.Fatalf("sql.Open: %v", err) } dsn.SetMaxOpenConns(8) dsn.SetConnMaxLifetime(30 * time.Minute) if err = dsn.Ping(); err != nil { log.Printf("spatiald: DB not reachable yet (%v) — serving /health as down until it is", err) } mux := http.NewServeMux() mux.HandleFunc("/health", handleHealth) mux.HandleFunc("/near", handleNear) mux.HandleFunc("/nearest", handleNearest) mux.HandleFunc("/corridor", handleCorridor) log.Printf("spatiald: listening on %s (dsn: %s)", addr, *dsnFlag) srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} if err = srv.ListenAndServe(); err != nil { log.Fatal(err) } } func cors(w http.ResponseWriter) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Content-Type", "application/json") } func jerr(w http.ResponseWriter, code int, msg string) { cors(w) w.WriteHeader(code) fmt.Fprintf(w, `{"error":%q}`, strings.ReplaceAll(msg, `"`, `\"`)) } // common filters → (where clause, args). kind accepts "amenity=restaurant" // (exact) or "restaurant" (match the tag value across all kind families). func filters(q map[string][]string) (string, []any, error) { var conds []string var args []any if e := first(q, "extract"); e != "" { conds = append(conds, "poi.extract = $%d") args = append(args, e) } if k := first(q, "kind"); k != "" { if strings.Contains(k, "=") { conds = append(conds, "poi.kind = $%d") args = append(args, k) } else { // bare value: match the part after '=' in any kind family conds = append(conds, "poi.kind LIKE $%d") args = append(args, "%="+k) } } if n := first(q, "name"); n != "" { conds = append(conds, "poi.name ILIKE $%d") args = append(args, "%"+n+"%") } if len(conds) == 0 { return "", args, nil } return " WHERE " + strings.Join(conds, " AND "), args, nil } func first(q map[string][]string, k string) string { if v := q[k]; len(v) > 0 { return strings.TrimSpace(v[0]) } return "" } func limitOf(q map[string][]string) int { l, err := strconv.Atoi(first(q, "limit")) if err != nil || l < 1 { return 20 } if l > 200 { return 200 } return l } // rows → pois; the SELECT must end with a distance column in metres. func scanPois(rs *sql.Rows) ([]poi, error) { var out []poi for rs.Next() { var p poi if err := rs.Scan(&p.Extract, &p.OsmID, &p.OsmType, &p.Name, &p.Kind, &p.OpenH, &p.Fee, &p.Website, &p.AddrCity, &p.DistM); err != nil { return nil, err } out = append(out, p) } return out, rs.Err() } func writePois(w http.ResponseWriter, pois []poi) { if pois == nil { pois = []poi{} } cors(w) fmt.Fprintf(w, `{"count":%d,"results":`, len(pois)) b, _ := json.Marshal(pois) w.Write(b) w.Write([]byte("}")) } func handleHealth(w http.ResponseWriter, r *http.Request) { cors(w) type ext struct { Name string `json:"name"` PoiCount *int64 `json:"poi_count"` Imported *string `json:"imported_at,omitempty"` } healthy := dsn.Ping() == nil var extracts []ext if healthy { rows, err := dsn.Query(`SELECT name, poi_count, to_char(imported_at, 'YYYY-MM-DD"T"HH24:MI:SSOF') FROM spatial_extract ORDER BY name`) if err == nil { for rows.Next() { var e ext rows.Scan(&e.Name, &e.PoiCount, &e.Imported) extracts = append(extracts, e) } rows.Close() } } if extracts == nil { extracts = []ext{} } fmt.Fprintf(w, `{"ok":%v,"postgis":%v,"extracts":`, healthy, healthy) b, _ := json.Marshal(extracts) w.Write(b) w.Write([]byte("}")) } const poiCols = `poi.extract, poi.osm_id, poi.osm_type, poi.name, poi.kind, poi.opening_hours, poi.fee, poi.website, poi.addr_city` func handleNear(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() lat, lng, err := point(q) if err != nil { jerr(w, 400, err.Error()) return } rad, err := radius(q) if err != nil { jerr(w, 400, err.Error()) return } where, args, _ := filters(q) args = append(args, lat, lng, rad, limitOf(q)) // point geometry first, then geography cast for the radius test, then KNN // ordering among hits — ST_DWithin(geography) uses the GIST index. query := `SELECT ` + poiCols + `, ST_Distance_Sphere(poi.geom, ST_SetSRID(ST_MakePoint($2, $1), 4326)) AS dist_m FROM poi` + where + ` AND ST_DWithin(poi.geom::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography, $3) ORDER BY poi.geom <-> ST_SetSRID(ST_MakePoint($2, $1), 4326) LIMIT $4` // renumber: filters consume $1..$n, so shift the point/radius/limit params query, args = renumber(query, args, q) if query == "" { jerr(w, 500, "param renumbering failed") return } rows, err := dsn.Query(query, args...) if err != nil { jerr(w, 502, "query: "+err.Error()) return } pois, err := scanPois(rows) rows.Close() if err != nil { jerr(w, 502, err.Error()) return } writePois(w, pois) } func handleNearest(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() lat, lng, err := point(q) if err != nil { jerr(w, 400, err.Error()) return } where, args, _ := filters(q) args = append(args, lat, lng, limitOf(q)) query := `SELECT ` + poiCols + `, ST_Distance_Sphere(poi.geom, ST_SetSRID(ST_MakePoint($2, $1), 4326)) AS dist_m FROM poi` + where + ` ORDER BY poi.geom <-> ST_SetSRID(ST_MakePoint($2, $1), 4326) LIMIT $3` query, args = renumber(query, args, q) if query == "" { jerr(w, 500, "param renumbering failed") return } rows, err := dsn.Query(query, args...) if err != nil { jerr(w, 502, "query: "+err.Error()) return } pois, err := scanPois(rows) rows.Close() if err != nil { jerr(w, 502, err.Error()) return } writePois(w, pois) } func handleCorridor(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() rad, err := radius(q) if err != nil { jerr(w, 400, err.Error()) return } pts := parsePoints(first(q, "points")) if len(pts) < 2 { jerr(w, 400, "points must be 'lng,lat;lng,lat;…' with at least 2 points") return } where, args, _ := filters(q) var coords []string for _, p := range pts { coords = append(coords, fmt.Sprintf("%s %s", strconv.FormatFloat(p[1], 'f', 6, 64), strconv.FormatFloat(p[0], 'f', 6, 64))) } line := "LINESTRING(" + strings.Join(coords, ",") + ")" args = append(args, line, rad, limitOf(q)) // distance to a line uses ST_Distance(geography) (ST_Distance_Sphere is // point-to-point only); the corridor is a buffered geography band. query := `SELECT ` + poiCols + `, ST_Distance(poi.geom::geography, ST_SetSRID(ST_GeomFromText($1), 4326)::geography) AS dist_m FROM poi` + where + ` AND ST_DWithin(poi.geom::geography, ST_Buffer(ST_SetSRID(ST_GeomFromText($1), 4326)::geography, $2), true) ORDER BY dist_m LIMIT $3` query, args = renumber(query, args, q) if query == "" { jerr(w, 500, "param renumbering failed") return } rows, err := dsn.Query(query, args...) if err != nil { jerr(w, 502, "query: "+err.Error()) return } pois, err := scanPois(rows) rows.Close() if err != nil { jerr(w, 502, err.Error()) return } writePois(w, pois) } // point/radius parse the lat/lng (degrees) and r (metres) query params. func point(q map[string][]string) (lat, lng float64, err error) { lat, err = strconv.ParseFloat(first(q, "lat"), 64) if err != nil { return 0, 0, fmt.Errorf("lat is required (decimal degrees)") } lng, err = strconv.ParseFloat(first(q, "lng"), 64) if err != nil { return 0, 0, fmt.Errorf("lng is required (decimal degrees)") } if lat < -90 || lat > 90 || lng < -180 || lng > 180 { return 0, 0, fmt.Errorf("lat/lng out of range") } return lat, lng, nil } func radius(q map[string][]string) (float64, error) { s := first(q, "r") if s == "" { return 500, nil } v, err := strconv.ParseFloat(s, 64) if err != nil || v < 50 || v > 50000 { return 0, fmt.Errorf("r must be 50..50000 metres") } return v, nil } // parsePoints: "lng,lat;lng,lat" → [[lng,lat],…] in that order. func parsePoints(s string) [][2]float64 { var out [][2]float64 for _, pair := range strings.Split(s, ";") { pair = strings.TrimSpace(pair) if pair == "" { continue } xy := strings.Split(pair, ",") if len(xy) != 2 { continue } lng, e1 := strconv.ParseFloat(strings.TrimSpace(xy[0]), 64) lat, e2 := strconv.ParseFloat(strings.TrimSpace(xy[1]), 64) if e1 == nil && e2 == nil { out = append(out, [2]float64{lng, lat}) } } return out } // renumber: the WHERE clause (from filters) already contains $1..$n in the // order filters() appended its args. The trailing point/radius/limit params // were written as $1,$2,$3,$4 — rewrite them to $n+1, $n+2, … so the arg // list (filter args first, then point args) lines up. func renumber(query string, args []any, q map[string][]string) (string, []any) { n := 0 if e := first(q, "extract"); e != "" { n++ } if k := first(q, "kind"); k != "" { n++ } if nn := first(q, "name"); nn != "" { n++ } var b strings.Builder for i := 0; i < len(query); i++ { c := query[i] if c == '$' && i+1 < len(query) && query[i+1] >= '1' && query[i+1] <= '9' { // count-digit placeholders only — the filter params are < 10 d := int(query[i+1] - '0') if d <= n { b.WriteByte(c) b.WriteByte(query[i+1]) i++ continue } fmt.Fprintf(&b, "$%d", n+d) i++ continue } b.WriteByte(c) } return b.String(), args }