Add APIs for geocoding and reverse-geocoding

This commit is contained in:
Eli Ribble 2026-04-06 16:53:26 +00:00
parent 437f87013a
commit 43dce16fbd
No known key found for this signature in database
7 changed files with 175 additions and 40 deletions

View file

@ -24,11 +24,12 @@ type ErrorAPI struct {
var decoder = schema.NewDecoder() var decoder = schema.NewDecoder()
type handlerFunctionDelete func(context.Context, *http.Request, platform.User) *nhttp.ErrorWithStatus type handlerFunctionDelete func(context.Context, *http.Request, platform.User) *nhttp.ErrorWithStatus
type handlerFunctionGet[T any] func(context.Context, *http.Request, platform.User, resource.QueryParams) (*T, *nhttp.ErrorWithStatus) type handlerFunctionGet[T any] func(context.Context, *http.Request, resource.QueryParams) (*T, *nhttp.ErrorWithStatus)
type handlerFunctionGetAuthenticated[T any] func(context.Context, *http.Request, platform.User, resource.QueryParams) (*T, *nhttp.ErrorWithStatus)
type handlerFunctionGetImage func(context.Context, *http.Request, platform.User) (file.Collection, uuid.UUID, *nhttp.ErrorWithStatus) type handlerFunctionGetImage func(context.Context, *http.Request, platform.User) (file.Collection, uuid.UUID, *nhttp.ErrorWithStatus)
type handlerFunctionGetSlice[T any] func(context.Context, *http.Request, resource.QueryParams) ([]*T, *nhttp.ErrorWithStatus) type handlerFunctionGetSlice[T any] func(context.Context, *http.Request, resource.QueryParams) ([]*T, *nhttp.ErrorWithStatus)
type handlerFunctionGetSliceAuthenticated[T any] func(context.Context, *http.Request, platform.User, resource.QueryParams) ([]*T, *nhttp.ErrorWithStatus) type handlerFunctionGetSliceAuthenticated[T any] func(context.Context, *http.Request, platform.User, resource.QueryParams) ([]*T, *nhttp.ErrorWithStatus)
type handlerFunctionPost[RequestType any] func(context.Context, *http.Request, RequestType) (string, *nhttp.ErrorWithStatus) type handlerFunctionPost[RequestType any, ResponseType any] func(context.Context, *http.Request, RequestType) (ResponseType, *nhttp.ErrorWithStatus)
type handlerFunctionPostAuthenticated[RequestType any, ResponseType any] func(context.Context, *http.Request, platform.User, RequestType) (ResponseType, *nhttp.ErrorWithStatus) type handlerFunctionPostAuthenticated[RequestType any, ResponseType any] func(context.Context, *http.Request, platform.User, RequestType) (ResponseType, *nhttp.ErrorWithStatus)
type handlerFunctionPostFormMultipart[RequestType any, ResponseType any] func(context.Context, *http.Request, RequestType) (*ResponseType, *nhttp.ErrorWithStatus) type handlerFunctionPostFormMultipart[RequestType any, ResponseType any] func(context.Context, *http.Request, RequestType) (*ResponseType, *nhttp.ErrorWithStatus)
type handlerFunctionPutAuthenticated[RequestType any] func(context.Context, *http.Request, platform.User, RequestType) (string, *nhttp.ErrorWithStatus) type handlerFunctionPutAuthenticated[RequestType any] func(context.Context, *http.Request, platform.User, RequestType) (string, *nhttp.ErrorWithStatus)
@ -58,7 +59,7 @@ func authenticatedHandlerGetImage(f handlerFunctionGetImage) http.Handler {
}) })
} }
func authenticatedHandlerJSON[T any](f handlerFunctionGet[T]) http.Handler { func authenticatedHandlerJSON[T any](f handlerFunctionGetAuthenticated[T]) http.Handler {
return auth.NewEnsureAuth(func(w http.ResponseWriter, r *http.Request, u platform.User) { return auth.NewEnsureAuth(func(w http.ResponseWriter, r *http.Request, u platform.User) {
ctx := r.Context() ctx := r.Context()
var body []byte var body []byte
@ -189,6 +190,31 @@ func authenticatedHandlerPostMultipart[ResponseType any](f handlerFunctionPostAu
w.Write(body) w.Write(body)
}) })
} }
func handlerJSON[T any](f handlerFunctionGet[T]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var body []byte
var params resource.QueryParams
err := decoder.Decode(&params, r.URL.Query())
if err != nil {
respondErrorStatus(w, nhttp.NewBadRequest("failed to decode query: %w", err))
return
}
resp, e := f(ctx, r, params)
w.Header().Set("Content-Type", "application/json")
//log.Info().Str("template", template).Err(e).Msg("handler done")
if e != nil {
respondErrorStatus(w, e)
return
}
body, err = json.Marshal(resp)
if err != nil {
respondErrorStatus(w, nhttp.NewError("failed to marshal json: %w", err))
return
}
w.Write(body)
}
}
func handlerJSONSlice[T any](f handlerFunctionGetSlice[T]) http.HandlerFunc { func handlerJSONSlice[T any](f handlerFunctionGetSlice[T]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@ -215,7 +241,7 @@ func handlerJSONSlice[T any](f handlerFunctionGetSlice[T]) http.HandlerFunc {
} }
} }
func handlerJSONPost[RequestType any](f handlerFunctionPost[RequestType]) http.HandlerFunc { func handlerJSONPost[RequestType any, ResponseType any](f handlerFunctionPost[RequestType, ResponseType]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
req, e := parseRequest[RequestType](r) req, e := parseRequest[RequestType](r)
@ -224,11 +250,16 @@ func handlerJSONPost[RequestType any](f handlerFunctionPost[RequestType]) http.H
return return
} }
ctx := r.Context() ctx := r.Context()
path, e := f(ctx, r, *req) resp, e := f(ctx, r, *req)
if e != nil { if e != nil {
return return
} }
http.Redirect(w, r, path, http.StatusFound) body, err := json.Marshal(resp)
if err != nil {
respondErrorStatus(w, nhttp.NewError("failed to marshal json: %w", err))
return
}
w.Write(body)
} }
} }
func handlerFormPost[RequestType any, ResponseType any](f handlerFunctionPostFormMultipart[RequestType, ResponseType]) http.HandlerFunc { func handlerFormPost[RequestType any, ResponseType any](f handlerFunctionPostFormMultipart[RequestType, ResponseType]) http.HandlerFunc {

View file

@ -74,6 +74,8 @@ func AddRoutes(r *mux.Router) {
district := resource.District(router) district := resource.District(router)
r.Handle("/district", handlerJSONSlice(district.List)).Methods("GET") r.Handle("/district", handlerJSONSlice(district.List)).Methods("GET")
geocode := resource.Geocode(router) geocode := resource.Geocode(router)
r.Handle("/geocode/by-gid/{id:.*}", handlerJSON(geocode.ByGID)).Methods("GET")
r.Handle("/geocode/reverse", handlerJSONPost(geocode.Reverse)).Methods("POST")
r.Handle("/geocode/suggestion", handlerJSONSlice(geocode.SuggestionList)).Methods("GET") r.Handle("/geocode/suggestion", handlerJSONSlice(geocode.SuggestionList)).Methods("GET")
//r.HandleFunc("/district", apiGetDistrict).Methods("GET") //r.HandleFunc("/district", apiGetDistrict).Methods("GET")

View file

@ -11,6 +11,8 @@ import (
type AutocompleteResult struct { type AutocompleteResult struct {
Detail string Detail string
GID string
Layer string // 'poi', 'postalcode', 'street',
Locality string Locality string
} }
@ -29,9 +31,17 @@ func Autocomplete(ctx context.Context, org *models.Organization, address string)
log.Error().Str("type", r.Type).Msg("should be handled from Stadia") log.Error().Str("type", r.Type).Msg("should be handled from Stadia")
continue continue
} }
var locality string
if r.Properties.CoarseLocation != nil {
locality = *r.Properties.CoarseLocation
} else {
locality = "???"
}
result[i] = &AutocompleteResult{ result[i] = &AutocompleteResult{
Detail: r.Properties.Name, Detail: r.Properties.Name,
Locality: r.Properties.Locality, GID: r.Properties.GID,
Layer: r.Properties.Layer,
Locality: locality,
} }
} }
return result, nil return result, nil

View file

@ -0,0 +1,47 @@
package geocode
import (
"context"
"fmt"
"github.com/Gleipnir-Technology/nidus-sync/h3utils"
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
"github.com/Gleipnir-Technology/nidus-sync/stadia"
)
func ByGID(ctx context.Context, gid string) (*GeocodeResult, error) {
req := stadia.RequestGeocodeByGID{
GIDs: []string{gid},
}
resp, err := client.GeocodeByGID(ctx, req)
if err != nil {
return nil, fmt.Errorf("geocodebygid: %w", err)
}
if len(resp.Features) < 1 {
return nil, fmt.Errorf("no features in result")
}
feature := resp.Features[0]
location := types.Location{
Latitude: feature.Geometry.Coordinates[1],
Longitude: feature.Geometry.Coordinates[0],
}
cell, err := h3utils.GetCell(location.Longitude, location.Latitude, 15)
if err != nil {
return nil, fmt.Errorf("latlngtocell: %w", err)
}
return &GeocodeResult{
Address: types.Address{
Country: feature.Properties.Context.ISO3166A3,
GID: feature.Properties.GID,
Locality: feature.Properties.Context.WhosOnFirst.Locality.Name,
Number: feature.Properties.AddressComponents.Number,
PostalCode: feature.Properties.AddressComponents.PostalCode,
Raw: feature.Properties.FormattedAddressLine,
Region: feature.Properties.Context.WhosOnFirst.Region.Name,
Street: feature.Properties.AddressComponents.Street,
Unit: "",
},
Cell: cell,
Location: location,
}, nil
}

View file

@ -8,6 +8,7 @@ import (
type Address struct { type Address struct {
Country string `db:"country" json:"country"` Country string `db:"country" json:"country"`
GID string `db:"gid" json:"gid"`
Locality string `db:"locality" json:"locality"` Locality string `db:"locality" json:"locality"`
Number string `db:"number" json:"number"` Number string `db:"number" json:"number"`
PostalCode string `db:"postal_code" json:"postal_code"` PostalCode string `db:"postal_code" json:"postal_code"`

View file

@ -2,19 +2,38 @@ package resource
import ( import (
"context" "context"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform/geocode"
"net/http" "net/http"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
ngeocode "github.com/Gleipnir-Technology/nidus-sync/platform/geocode"
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
"github.com/gorilla/mux"
//"github.com/rs/zerolog/log" //"github.com/rs/zerolog/log"
"github.com/uber/h3-go/v4"
) )
type geocodeR struct { type geocodeR struct {
router *router router *router
} }
type geocode struct {
Address types.Address `json:"address"`
Cell h3.Cell `json:"cell"`
Location types.Location `json:"location"`
}
func newGeocode(g *ngeocode.GeocodeResult) *geocode {
return &geocode{
Address: g.Address,
Cell: g.Cell,
Location: g.Location,
}
}
type geocodeSuggestion struct { type geocodeSuggestion struct {
Detail string `json:"detail"` Detail string `json:"detail"`
GID string `json:"gid"`
Locality string `json:"locality"` Locality string `json:"locality"`
Type string `json:"type"`
} }
func Geocode(r *router) *geocodeR { func Geocode(r *router) *geocodeR {
@ -23,11 +42,30 @@ func Geocode(r *router) *geocodeR {
} }
} }
func (res *geocodeR) ByGID(ctx context.Context, r *http.Request, query QueryParams) (*geocode, *nhttp.ErrorWithStatus) {
vars := mux.Vars(r)
gid := vars["id"]
if gid == "" {
return nil, nhttp.NewBadRequest("no id")
}
g, err := ngeocode.ByGID(ctx, gid)
if err != nil {
return nil, nhttp.NewError("bygid: %w", err)
}
return newGeocode(g), nil
}
func (res *geocodeR) Reverse(ctx context.Context, r *http.Request, location types.Location) (*geocode, *nhttp.ErrorWithStatus) {
g, err := ngeocode.ReverseGeocode(ctx, location)
if err != nil {
return nil, nhttp.NewError("reverse: %w", err)
}
return newGeocode(g), nil
}
func (res *geocodeR) SuggestionList(ctx context.Context, r *http.Request, query QueryParams) ([]*geocodeSuggestion, *nhttp.ErrorWithStatus) { func (res *geocodeR) SuggestionList(ctx context.Context, r *http.Request, query QueryParams) ([]*geocodeSuggestion, *nhttp.ErrorWithStatus) {
if query.Query == nil { if query.Query == nil {
return nil, nhttp.NewBadRequest("you must include a query") return nil, nhttp.NewBadRequest("you must include a query")
} }
completions, err := geocode.Autocomplete(ctx, nil, *query.Query) completions, err := ngeocode.Autocomplete(ctx, nil, *query.Query)
if err != nil { if err != nil {
return nil, nhttp.NewError("geocode: %w", err) return nil, nhttp.NewError("geocode: %w", err)
} }
@ -35,7 +73,9 @@ func (res *geocodeR) SuggestionList(ctx context.Context, r *http.Request, query
for i, c := range completions { for i, c := range completions {
result[i] = &geocodeSuggestion{ result[i] = &geocodeSuggestion{
Detail: c.Detail, Detail: c.Detail,
GID: c.GID,
Locality: c.Locality, Locality: c.Locality,
Type: c.Layer,
} }
} }
return result, nil return result, nil

View file

@ -110,40 +110,44 @@ type GeocodeGeometry struct {
// GeocodeProperties contains the properties of a geocoding result // GeocodeProperties contains the properties of a geocoding result
type GeocodeProperties struct { type GeocodeProperties struct {
Addendum map[string]interface{} `json:"addendum,omitempty"` Addendum map[string]interface{} `json:"addendum,omitempty"`
AddressComponents AddressComponents `json:"address_components,omitempty"` AddressComponents AddressComponents `json:"address_components,omitempty"`
Accuracy string `json:"accuracy"` // 'point' Accuracy string `json:"accuracy"` // 'point'
CoarseLocation *string `json:"coarse_location"` // 'Riverton, UT, USA' CoarseLocation *string `json:"coarse_location"` // 'Riverton, UT, USA'
Confidence float64 `json:"confidence"` // 1 Confidence float64 `json:"confidence"` // 1
Context Context `json:"context,omitempty"` // bunch of stuff Context Context `json:"context,omitempty"` // bunch of stuff
Country string `json:"country"` // 'United States' Country string `json:"country"` // 'United States'
CountryA string `json:"country_a"` // 'USA' CountryA string `json:"country_a"` // 'USA'
CountryCode string `json:"country_code"` // 'US' CountryCode string `json:"country_code"` // 'US'
CountryGID string `json:"country_gid"` // 'whosonfirst:country:85633793' CountryGID string `json:"country_gid"` // 'whosonfirst:country:85633793'
County string `json:"county"` // "Tulare County" County string `json:"county"` // "Tulare County"
CountyA string `json:"county_a"` // 'TL' CountyA string `json:"county_a"` // 'TL'
CountyGID string `json:"county_gid"` // 'whosonfirst:county:102082895' CountyGID string `json:"county_gid"` // 'whosonfirst:county:102082895'
GID string `json:"gid"` // 'openaddresses:address:us/ca/tulare-addresses-county:fe9dfab3d45c4550' FormattedAddressLine string `json:"formatted_address_line"` // '123 Main Street, Riverton, Utah 84065, United States of America'
HouseNumber string `json:"housenumber"` // '1234' FormattedAddressLines []string `json:"formatted_address_lines"` // '123 Main Street', 'Riverton, Utah 84065', 'United States of America'
ID string `json:"id"` // us/ca/tulare-addresses-county:fe9dfab3d45c4550 GID string `json:"gid"` // 'openaddresses:address:us/ca/tulare-addresses-county:fe9dfab3d45c4550'
Label string `json:"label"` // 1234 Main St, Dinuba, CA, USA HouseNumber string `json:"housenumber"` // '1234'
Layer string `json:"layer"` // 'address' ID string `json:"id"` // us/ca/tulare-addresses-county:fe9dfab3d45c4550
Locality string `json:"locality"` // 'Dinuba' Label string `json:"label"` // 1234 Main St, Dinuba, CA, USA
LocalityGID string `json:"locality_gid"` // 'whosonfirst:locality:85922491' Layer string `json:"layer"` // 'address'
MatchType string `json:"match_type"` // 'exact' Locality string `json:"locality"` // 'Dinuba'
Name string `json:"name"` // '1234 Main St' LocalityGID string `json:"locality_gid"` // 'whosonfirst:locality:85922491'
PostalCode string `json:"postalcode"` // '93618' MatchType string `json:"match_type"` // 'exact'
Precision string `json:"precision"` // 'centroid' Name string `json:"name"` // '1234 Main St'
Region string `json:"region"` // 'California' PostalCode string `json:"postalcode"` // '93618'
RegionA string `json:"region_a"` // 'CA' Precision string `json:"precision"` // 'centroid'
RegionGID string `json:"region_gid"` // 'whosonfirst:region:85688637' Region string `json:"region"` // 'California'
Source string `json:"source"` // 'openaddresses' RegionA string `json:"region_a"` // 'CA'
SourceID string `json:"source"` // 'us/ca/tulare-addresses-county:fe9dfab3d45c4550' RegionGID string `json:"region_gid"` // 'whosonfirst:region:85688637'
Street string `json:"street"` // 'Main Street' Source string `json:"source"` // 'openaddresses'
Sources []GeocodeSource `json:"sources"`
SourceID string `json:"source_id"` // 'us/ca/tulare-addresses-county:fe9dfab3d45c4550'
Street string `json:"street"` // 'Main Street'
} }
// GeocodeSource represents a source of geocoding data // GeocodeSource represents a source of geocoding data
type GeocodeSource struct { type GeocodeSource struct {
FixitURL string `json:"fixit_url"`
Source string `json:"source"` Source string `json:"source"`
SourceID string `json:"source_id"` SourceID string `json:"source_id"`
} }