Add proxied autocomplete for Stadia

This allows me to make the format consistent and to cache the
intermediate results, which is useful for speed and testing
This commit is contained in:
Eli Ribble 2026-04-05 21:57:30 +00:00
parent b6cfbee102
commit 2d5dca3fb5
No known key found for this signature in database
11 changed files with 275 additions and 11 deletions

1
.gitignore vendored
View file

@ -10,6 +10,7 @@ node_modules/
postgrid/cmd/send-pdf/send-pdf
result
stadia/cmd/bulk-geocode/bulk-geocode
stadia/cmd/geocode-autocomplete/geocode-autocomplete
stadia/cmd/reverse-geocode/reverse-geocode
stadia/cmd/structured-geocode/structured-geocode
static/gen/

View file

@ -73,6 +73,9 @@ func AddRoutes(r *mux.Router) {
// Unauthenticated endpoints
district := resource.District(router)
r.Handle("/district", handlerJSONSlice(district.List)).Methods("GET")
geocode := resource.Geocode(router)
r.Handle("/geocode/suggestion", handlerJSONSlice(geocode.SuggestionList)).Methods("GET")
//r.HandleFunc("/district", apiGetDistrict).Methods("GET")
r.HandleFunc("/district/{slug}/logo", apiGetDistrictLogo).Methods("GET").Name("district.logo.BySlug")
r.HandleFunc("/compliance-request/image/pool/{public_id}", getComplianceRequestImagePool).Methods("GET")

View file

@ -0,0 +1,38 @@
package geocode
import (
"context"
"fmt"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/stadia"
"github.com/rs/zerolog/log"
)
type AutocompleteResult struct {
Detail string
Locality string
}
func Autocomplete(ctx context.Context, org *models.Organization, address string) ([]*AutocompleteResult, error) {
req := stadia.RequestGeocodeAutocomplete{
Text: address,
}
maybeAddServiceArea(&req, org)
resp, err := client.GeocodeAutocomplete(ctx, req)
if err != nil {
return nil, fmt.Errorf("client raw geocode failure on %s: %w", address, err)
}
result := make([]*AutocompleteResult, len(resp.Features))
for i, r := range resp.Features {
if r.Type != "Feature" {
log.Error().Str("type", r.Type).Msg("should be handled from Stadia")
continue
}
result[i] = &AutocompleteResult{
Detail: r.Properties.Name,
Locality: r.Properties.Locality,
}
}
return result, nil
}

View file

@ -267,6 +267,9 @@ func allFeaturesIdenticalEnough(features []stadia.GeocodeFeature) bool {
return true
}
func maybeAddServiceArea(req stadia.RequestGeocode, org *models.Organization) {
if org == nil {
return
}
if org.ServiceAreaXmax.IsNull() ||
org.ServiceAreaYmax.IsNull() ||
org.ServiceAreaXmin.IsNull() ||

42
resource/geocode.go Normal file
View file

@ -0,0 +1,42 @@
package resource
import (
"context"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform/geocode"
"net/http"
//"github.com/rs/zerolog/log"
)
type geocodeR struct {
router *router
}
type geocodeSuggestion struct {
Detail string `json:"detail"`
Locality string `json:"locality"`
}
func Geocode(r *router) *geocodeR {
return &geocodeR{
router: r,
}
}
func (res *geocodeR) SuggestionList(ctx context.Context, r *http.Request, query QueryParams) ([]*geocodeSuggestion, *nhttp.ErrorWithStatus) {
if query.Query == nil {
return nil, nhttp.NewBadRequest("you must include a query")
}
completions, err := geocode.Autocomplete(ctx, nil, *query.Query)
if err != nil {
return nil, nhttp.NewError("geocode: %w", err)
}
result := make([]*geocodeSuggestion, len(completions))
for i, c := range completions {
result[i] = &geocodeSuggestion{
Detail: c.Detail,
Locality: c.Locality,
}
}
return result, nil
}

View file

@ -1,14 +1,15 @@
package resource
import (
//"github.com/gorilla/schema"
// "github.com/gorilla/schema"
)
type QueryParams struct {
Limit *int `schema:"limit"`
Query *string `schema:"query"`
Sort *string `schema:"sort"`
Type *string `schema:"type"`
Limit *int `schema:"limit"`
OrganizationID *int `schema:"org"`
Query *string `schema:"query"`
Sort *string `schema:"sort"`
Type *string `schema:"type"`
}
func (qp QueryParams) SortOrDefault(default_name string, ascending bool) (string, bool) {

View file

@ -0,0 +1,99 @@
package main
import (
"context"
"flag"
"log"
"os"
"github.com/Gleipnir-Technology/nidus-sync/stadia"
)
func main() {
// Define command-line flags
query := flag.String("query", "", "Street address query to autocomplete")
boundaryRectMaxLat := flag.Float64("boundary-rect-max-lat", 0, "The max lat of the boundary")
boundaryRectMinLat := flag.Float64("boundary-rect-min-lat", 0, "The min lat of the boundary")
boundaryRectMaxLon := flag.Float64("boundary-rect-max-lng", 0, "The max lon of the boundary")
boundaryRectMinLon := flag.Float64("boundary-rect-min-lng", 0, "The min lon of the boundary")
focusLat := flag.Float64("focus-lat", 0, "The latitude of the focus point")
focusLng := flag.Float64("focus-lng", 0, "The longitude of the focus point")
// Parse the flags
flag.Parse()
// Validate required arguments
if *query == "" {
log.Println("Error: -query is required")
flag.Usage()
os.Exit(1)
}
if *focusLat != 0 && *focusLng == 0 {
log.Println("Error: you must specify both focus-lat and focus-lng together, not just focus-lat")
flag.Usage()
os.Exit(1)
}
if *focusLat == 0 && *focusLng != 0 {
log.Println("Error: you must specify both focus-lat and focus-lng together, not just focus-lng")
flag.Usage()
os.Exit(1)
}
if (*boundaryRectMaxLat != 0 ||
*boundaryRectMinLat != 0 ||
*boundaryRectMaxLon != 0 ||
*boundaryRectMinLon != 0) && (*boundaryRectMaxLat == 0 ||
*boundaryRectMinLat == 0 ||
*boundaryRectMaxLon == 0 ||
*boundaryRectMinLon == 0) {
log.Println("If you specify one of boundary-rect you need to specify them all")
os.Exit(1)
}
key := os.Getenv("STADIA_MAPS_API_KEY")
if key == "" {
log.Println("STADIA_MAPS_API_KEY is empty")
os.Exit(1)
}
client := stadia.NewStadiaMaps(key)
ctx := context.Background()
req := stadia.RequestGeocodeAutocomplete{
Text: *query,
}
if *focusLat != 0 && *focusLng != 0 {
req.FocusPointLat = focusLat
req.FocusPointLng = focusLng
}
if *boundaryRectMaxLat != 0 {
req.BoundaryRectMaxLat = boundaryRectMaxLat
req.BoundaryRectMinLat = boundaryRectMinLat
req.BoundaryRectMaxLon = boundaryRectMaxLon
req.BoundaryRectMinLon = boundaryRectMinLon
}
resp, err := client.GeocodeAutocomplete(ctx, req)
if err != nil {
log.Printf("err: %v\n", err)
os.Exit(2)
}
log.Printf("type: %s, features: %d\n", resp.Type, len(resp.Features))
for i, feature := range resp.Features {
log.Printf("feature %d: type %s\n", i, feature.Type)
if feature.Geometry == nil {
log.Printf("\tno geometry")
} else {
log.Printf("\tgeometry %s\n", feature.Geometry.Type) //, feature.Geometry.Coordinates[0], feature.Geometry.Coordinates[1])
}
log.Printf("\tproperties %s\n", feature.Properties.Layer)
switch feature.Properties.Layer {
case "address":
log.Printf("\t\t%s", feature.Properties.Name)
if feature.Properties.CoarseLocation != nil {
log.Printf("\t\t%s", *feature.Properties.CoarseLocation)
}
log.Printf("\t\t%s", feature.Properties.Precision)
log.Printf("\t\t%s", feature.Properties.Layer)
log.Printf("\t\t%s", feature.Properties.GID)
}
}
}

View file

@ -0,0 +1,72 @@
package stadia
import (
"context"
"fmt"
"github.com/google/go-querystring/query"
)
type RequestGeocodeAutocomplete struct {
Text string `url:"text" json:"text"`
// Boundary circle parameters
BoundaryCircleLat *float64 `url:"boundary.circle.lat,omitempty"`
BoundaryCircleLon *float64 `url:"boundary.circle.lon,omitempty"`
BoundaryCircleRadius *float64 `url:"boundary.circle.radius,omitempty"`
BoundaryCountry *string `url:"boundary.country,omitempty"` //comma-delimited ISO 2 or 3 character code
BoundaryGID *string `url:"boundary.gid,omitempty"` // The GID of a region to limit the search to
// Boundary parameters
BoundaryRectMaxLat *float64 `url:"boundary.rect.max_lat,omitempty"`
BoundaryRectMinLat *float64 `url:"boundary.rect.min_lat,omitempty"`
BoundaryRectMaxLon *float64 `url:"boundary.rect.max_lon,omitempty"`
BoundaryRectMinLon *float64 `url:"boundary.rect.min_lon,omitempty"`
// Focus point
FocusPointLat *float64 `url:"focus.point.lat,omitempty" json:",omitempty"`
FocusPointLng *float64 `url:"focus.point.lon,omitempty" json:",omitempty"`
// Other parameters
Lang *string `url:"lang,omitempty" json:"lang,omitempty"`
Layers []string `url:"layers,omitempty,comma" json:"layers,omitempty"`
Size *int `url:"size,omitempty" json:"size,omitempty"`
Sources []string `url:"sources,omitempty,comma" json:"sources,omitempty"`
}
func (r *RequestGeocodeAutocomplete) SetBoundaryRect(xmin, ymin, xmax, ymax float64) {
r.BoundaryRectMaxLat = &ymax
r.BoundaryRectMinLat = &ymin
r.BoundaryRectMaxLon = &xmax
r.BoundaryRectMinLon = &xmin
}
func (r *RequestGeocodeAutocomplete) SetFocusPoint(x, y float64) {
r.FocusPointLat = &y
r.FocusPointLng = &x
}
func (s *StadiaMaps) GeocodeAutocomplete(ctx context.Context, req RequestGeocodeAutocomplete) (*GeocodeResponse, error) {
// https://docs.stadiamaps.com/geocoding-search-autocomplete/search/
var result GeocodeResponse
query, err := query.Values(req)
if err != nil {
return nil, fmt.Errorf("structured geocode query: %w", err)
}
//var api_error Error
resp, err := s.client.R().
SetQueryParamsFromValues(query).
SetContext(ctx).
SetResult(&result).
SetPathParam("urlBase", s.urlBase).
SetQueryParam("api_key", s.APIKey).
Get("https://{urlBase}/geocoding/v2/autocomplete")
if err != nil {
return nil, fmt.Errorf("autocomplete get: %w", err)
}
if !resp.IsSuccess() {
return nil, parseError(resp)
}
return &result, nil
}

View file

@ -98,7 +98,7 @@ type GeocodeMeta struct {
// GeocodeFeature represents a GeoJSON feature in the response
type GeocodeFeature struct {
Type string `json:"type"` // Should be "Feature"
Geometry GeocodeGeometry `json:"geometry"`
Geometry *GeocodeGeometry `json:"geometry"`
Properties GeocodeProperties `json:"properties"`
}
@ -113,6 +113,7 @@ type GeocodeProperties struct {
Addendum map[string]interface{} `json:"addendum,omitempty"`
AddressComponents AddressComponents `json:"address_components,omitempty"`
Accuracy string `json:"accuracy"` // 'point'
CoarseLocation *string `json:"coarse_location"` // 'Riverton, UT, USA'
Confidence float64 `json:"confidence"` // 1
Context Context `json:"context,omitempty"` // bunch of stuff
Country string `json:"country"` // 'United States'
@ -132,6 +133,7 @@ type GeocodeProperties struct {
MatchType string `json:"match_type"` // 'exact'
Name string `json:"name"` // '1234 Main St'
PostalCode string `json:"postalcode"` // '93618'
Precision string `json:"precision"` // 'centroid'
Region string `json:"region"` // 'California'
RegionA string `json:"region_a"` // 'CA'
RegionGID string `json:"region_gid"` // 'whosonfirst:region:85688637'

View file

@ -138,9 +138,11 @@ function handleInput() {
async function fetchAddressSuggestions(text: string) {
try {
const url = `https://api.stadiamaps.com/geocoding/v2/autocomplete?text=${encodeURIComponent(
text,
)}&focus.point.lat=35&focus.point.lon=-115`;
const q = encodeURIComponent(text);
//const url = `https://api.stadiamaps.com/geocoding/v2/autocomplete?text=${encodeURIComponent(
//text,
//)}&focus.point.lat=35&focus.point.lon=-115`;
const url = `/api/geocode/suggestion?query=${q}`;
const response = await fetch(url);
const data = await response.json();
@ -183,8 +185,10 @@ function formatAddressDisplay(address: Address): string {
const street = props.address_components.street ?? "";
const location = props.coarse_location ?? "";
return `${num} ${street}, ${location}`.trim();
} else {
} else if (props.name != "") {
return `${props.name ?? ""}, ${props.coarse_location ?? ""}`.trim();
} else {
return "???";
}
}
</script>

View file

@ -35,7 +35,6 @@ interface Properties {
precision?: string; // "centroid"
name: string;
}
export interface Geometry {
type: string;
coordinates: [number, number];