From 427b60132aeeaae92c88b0bb6c1aab803a803b0c Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Sat, 14 Feb 2026 15:40:12 +0000 Subject: [PATCH] Add initial Stadia maps integration --- stadia/bulk.go | 53 +++++++++++++++++ stadia/cmd/bulk-geocode/main.go | 38 ++++++++++++ stadia/cmd/structured-geocode/main.go | 30 ++++++++++ stadia/logger.go | 44 ++++++++++++++ stadia/request_type.go | 22 +++++++ stadia/response_type.go | 85 +++++++++++++++++++++++++++ stadia/stadia.go | 28 +++++++++ stadia/structured_geocode.go | 68 +++++++++++++++++++++ 8 files changed, 368 insertions(+) create mode 100644 stadia/bulk.go create mode 100644 stadia/cmd/bulk-geocode/main.go create mode 100644 stadia/cmd/structured-geocode/main.go create mode 100644 stadia/logger.go create mode 100644 stadia/request_type.go create mode 100644 stadia/response_type.go create mode 100644 stadia/stadia.go create mode 100644 stadia/structured_geocode.go diff --git a/stadia/bulk.go b/stadia/bulk.go new file mode 100644 index 00000000..672fb1b7 --- /dev/null +++ b/stadia/bulk.go @@ -0,0 +1,53 @@ +package stadia + +import ( + "fmt" +) + +type BulkGeocodeQuery interface { + endpoint() string +} + +// BulkGeocodeRequestItem represents a single request in a bulk geocoding operation +type BulkGeocodeRequestItem struct { + Endpoint string `json:"endpoint"` + Query BulkGeocodeQuery `json:"query"` +} + +// BulkGeocodeResponseItem represents a single response in a bulk geocoding operation +type BulkGeocodeResponseItem struct { + Response *GeocodeResponse `json:"response,omitempty"` + Status int `json:"status"` + Message string `json:"msg,omitempty"` +} + +func (s *StadiaMaps) BulkGeocode(requests []BulkGeocodeQuery) ([]BulkGeocodeResponseItem, error) { + // https://docs.stadiamaps.com/geocoding-search-autocomplete/bulk-geocoding-search/ + // POST 'https://api.stadiamaps.com/geocoding/v1/search/bulk?api_key=YOUR-API-KEY' + body := make([]BulkGeocodeRequestItem, 0) + for _, r := range requests { + body = append(body, BulkGeocodeRequestItem{ + Endpoint: r.endpoint(), + Query: r, + }) + } + var results []BulkGeocodeResponseItem + + resp, err := s.client.R(). + SetBody(body). + SetContentType("application/json"). + SetPathParam("urlBase", s.urlBase). + SetQueryParam("api_key", s.APIKey). + SetResult(&results). + Post("https://{urlBase}/geocoding/v1/search/bulk") + + if err != nil { + return nil, fmt.Errorf("bulk geocode request: %w", err) + } + + if !resp.IsSuccess() { + return nil, fmt.Errorf("bulk geocoding request failed with status code: %d", resp.StatusCode()) + } + + return results, nil +} diff --git a/stadia/cmd/bulk-geocode/main.go b/stadia/cmd/bulk-geocode/main.go new file mode 100644 index 00000000..13658ec6 --- /dev/null +++ b/stadia/cmd/bulk-geocode/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "log" + "os" + + "github.com/Gleipnir-Technology/nidus-sync/stadia" +) + +func main() { + key := os.Getenv("STADIA_MAPS_API_KEY") + if key == "" { + log.Println("stadia maps api key is empty") + os.Exit(1) + } + client := stadia.NewStadiaMaps(key) + requests := make([]stadia.BulkGeocodeQuery, 0) + requests = append(requests, stadia.StructuredGeocodeRequest{ + Address: strPtr("12932 Ave 404"), + PostalCode: strPtr("93615"), + }) + requests = append(requests, stadia.StructuredGeocodeRequest{ + Address: strPtr("1187 N Arno Rd"), + PostalCode: strPtr("93618"), + }) + resp, err := client.BulkGeocode(requests) + if err != nil { + log.Printf("err: %v\n", err) + os.Exit(2) + } + for _, r := range resp { + log.Printf("Status: %s", r.Status) + } +} + +func strPtr(s string) *string { + return &s +} diff --git a/stadia/cmd/structured-geocode/main.go b/stadia/cmd/structured-geocode/main.go new file mode 100644 index 00000000..43858fb3 --- /dev/null +++ b/stadia/cmd/structured-geocode/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "log" + "os" + + "github.com/Gleipnir-Technology/nidus-sync/stadia" +) + +func main() { + key := os.Getenv("STADIA_MAPS_API_KEY") + if key == "" { + log.Println("stadia maps api key is empty") + os.Exit(1) + } + client := stadia.NewStadiaMaps(key) + resp, err := client.StructuredGeocode(stadia.StructuredGeocodeRequest{ + Address: strPtr("12932 Ave 404"), + PostalCode: strPtr("93615"), + }) + if err != nil { + log.Printf("err: %v\n", err) + os.Exit(2) + } + log.Printf("type: %s", resp.Type) +} + +func strPtr(s string) *string { + return &s +} diff --git a/stadia/logger.go b/stadia/logger.go new file mode 100644 index 00000000..a5b8469b --- /dev/null +++ b/stadia/logger.go @@ -0,0 +1,44 @@ +// Package restyzerolog provides a wrapper for [zerolog.Logger] to be used with resty +// See: +// - https://resty.dev +// - https://pkg.go.dev/github.com/go-resty/resty/v3#Logger +package stadia + +import ( + "github.com/rs/zerolog" +) + +// Logger is a wrapper for [zerolog.Logger] to be used as logger for resty. +// Contains an instance of [zerolog.Logger], which is used to log messages. +// Not exported, because it's not necessary to use it directly. +type Logger struct { + logger zerolog.Logger +} + +// New creates a new instance of [Logger] with provided [zerolog.Logger]. +// +// Example of wrapping the default global zerolog logger: +// +// client := resty.New() +// client.SetLogger(restyzerolog.New(log.Logger)) +// +// See: +// +// - https://pkg.go.dev/github.com/rs/zerolog/log#pkg-variables +func NewLogger(logger zerolog.Logger) *Logger { + return &Logger{ + logger: logger, + } +} + +func (r *Logger) Errorf(format string, v ...any) { + r.logger.Error().Msgf(format, v...) +} + +func (r *Logger) Warnf(format string, v ...any) { + r.logger.Warn().Msgf(format, v...) +} + +func (r *Logger) Debugf(format string, v ...any) { + r.logger.Debug().Msgf(format, v...) +} diff --git a/stadia/request_type.go b/stadia/request_type.go new file mode 100644 index 00000000..ab279829 --- /dev/null +++ b/stadia/request_type.go @@ -0,0 +1,22 @@ +package stadia + +// FocusPoint represents focus point coordinates +type FocusPoint struct { + Lat *float64 `url:"focus.point.lat,omitempty"` + Lon *float64 `url:"focus.point.lon,omitempty"` +} + +// BoundaryRect represents a bounding rectangle +type BoundaryRect struct { + MinLon *float64 `url:"boundary.rect.min_lon,omitempty"` + MaxLon *float64 `url:"boundary.rect.max_lon,omitempty"` + MinLat *float64 `url:"boundary.rect.min_lat,omitempty"` + MaxLat *float64 `url:"boundary.rect.max_lat,omitempty"` +} + +// BoundaryCircle represents a bounding circle +type BoundaryCircle struct { + Lat *float64 `url:"boundary.circle.lat,omitempty"` + Lon *float64 `url:"boundary.circle.lon,omitempty"` + Radius *float64 `url:"boundary.circle.radius,omitempty"` +} diff --git a/stadia/response_type.go b/stadia/response_type.go new file mode 100644 index 00000000..f169b79d --- /dev/null +++ b/stadia/response_type.go @@ -0,0 +1,85 @@ +package stadia + +// GeocodeResponse represents the top-level response from the geocoding API +type GeocodeResponse struct { + Geocode GeocodeMeta `json:"geocoding"` + Type string `json:"type"` // Should be "FeatureCollection" + BBox []float64 `json:"bbox"` // [W, S, E, N] + Features []GeocodeFeature `json:"features"` +} + +// GeocodeMeta contains metadata about the geocoding request +type GeocodeMeta struct { + Attribution string `json:"attribution"` + Query map[string]interface{} `json:"query,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Errors []string `json:"errors,omitempty"` // v1 + Error string `json:"error,omitempty"` // v2 +} + +// GeocodeFeature represents a GeoJSON feature in the response +type GeocodeFeature struct { + Type string `json:"type"` // Should be "Feature" + Geometry GeocodeGeometry `json:"geometry"` + Properties GeocodeProperties `json:"properties"` +} + +// GeocodeGeometry represents the GeoJSON geometry +type GeocodeGeometry struct { + Type string `json:"type"` // "Point", "Polygon", etc. + Coordinates []float64 `json:"coordinates"` +} + +// GeocodeProperties contains the properties of a geocoding result +type GeocodeProperties struct { + GID string `json:"gid"` + Layer string `json:"layer"` + Sources []GeocodeSource `json:"sources"` + Precision string `json:"precision"` + Name string `json:"name"` + FormattedAddressLines []string `json:"formatted_address_lines"` + FormattedAddressLine string `json:"formatted_address_line"` + CoarseLocation string `json:"coarse_location"` + AddressComponents AddressComponents `json:"address_components,omitempty"` + Context GeocodeContext `json:"context,omitempty"` + Confidence float64 `json:"confidence,omitempty"` + Distance float64 `json:"distance,omitempty"` + Addendum map[string]interface{} `json:"addendum,omitempty"` +} + +// GeocodeSource represents a source of geocoding data +type GeocodeSource struct { + Source string `json:"source"` + SourceID string `json:"source_id"` +} + +// AddressComponents represents the structured components of an address +type AddressComponents struct { + Number string `json:"number,omitempty"` + Street string `json:"street,omitempty"` + Unit string `json:"unit,omitempty"` + PostalCode string `json:"postal_code,omitempty"` +} + +// GeocodeContext represents the geographic context of a result +type GeocodeContext struct { + WhosOnFirst WhosOnFirstContext `json:"whosonfirst,omitempty"` + ISO3166A2 string `json:"iso_3166_a2,omitempty"` + ISO3166A3 string `json:"iso_3166_a3,omitempty"` +} + +// WhosOnFirstContext contains geographic hierarchy information +type WhosOnFirstContext struct { + Country *ContextPlace `json:"country,omitempty"` + Region *ContextPlace `json:"region,omitempty"` + County *ContextPlace `json:"county,omitempty"` + Locality *ContextPlace `json:"locality,omitempty"` + Neighbourhood *ContextPlace `json:"neighbourhood,omitempty"` + Borough *ContextPlace `json:"borough,omitempty"` +} + +// ContextPlace represents a place in the geographic hierarchy +type ContextPlace struct { + GID string `json:"gid"` + Name string `json:"name"` +} diff --git a/stadia/stadia.go b/stadia/stadia.go new file mode 100644 index 00000000..2de48192 --- /dev/null +++ b/stadia/stadia.go @@ -0,0 +1,28 @@ +package stadia + +import ( + "resty.dev/v3" + //"github.com/rs/zerolog/log" +) + +type StadiaMaps struct { + APIKey string + + client *resty.Client + urlBase string +} + +func NewStadiaMaps(api_key string) *StadiaMaps { + //logger := NewLogger(log.Logger) + //r := resty.New().SetLogger(logger).SetDebug(true) + r := resty.New().SetDebug(true) + return &StadiaMaps{ + APIKey: api_key, + client: r, + urlBase: "api.stadiamaps.com", + } +} + +func (s *StadiaMaps) Close() { + s.client.Close() +} diff --git a/stadia/structured_geocode.go b/stadia/structured_geocode.go new file mode 100644 index 00000000..a4965b65 --- /dev/null +++ b/stadia/structured_geocode.go @@ -0,0 +1,68 @@ +package stadia + +import ( + "fmt" + "net/url" + + "github.com/google/go-querystring/query" +) + +// StructuredGeocodeRequest represents the query parameters for structured geocoding +type StructuredGeocodeRequest struct { + // Address components + Address *string `url:"address,omitempty" json:"address,omitempty"` + Neighbourhood *string `url:"neighbourhood,omitempty" json:"neighbourhood,omitempty"` + Borough *string `url:"borough,omitempty" json:"borough,omitempty"` + Locality *string `url:"locality,omitempty" json:"locality,omitempty"` + County *string `url:"county,omitempty" json:"county,omitempty"` + Region *string `url:"region,omitempty" json:"region,omitempty"` + PostalCode *string `url:"postalcode,omitempty" json:"postalcode,omitempty"` + Country *string `url:"country,omitempty" json:"country,omitempty"` + + // Focus point + FocusPoint *FocusPoint `url:",omitempty" json:",omitempty"` + + // Boundary parameters + BoundaryRect *BoundaryRect `url:",omitempty" json:",omitempty"` + BoundaryCircle *BoundaryCircle `url:",omitempty" json:",omitempty"` + BoundaryCountry []string `url:"boundary.country,omitempty,comma" json:"boundary.country,omitempty,comma"` + BoundaryGid *string `url:"boundary.gid,omitempty" json:"boundary.gid,omitempty"` + + // Other parameters + Layers []string `url:"layers,omitempty,comma" json:"layers,omitempty,comma"` + Sources []string `url:"sources,omitempty,comma" json:"sources,omitempty,comma"` + Size *int `url:"size,omitempty" json:"size,omitempty"` + Lang *string `url:"lang,omitempty" json:"lang,omitempty"` +} + +func (s *StadiaMaps) StructuredGeocode(req StructuredGeocodeRequest) (*GeocodeResponse, error) { + // https://docs.stadiamaps.com/geocoding-search-autocomplete/structured-search/ + // curl "https://api.stadiamaps.com/geocoding/v1/search/structured?address=P%C3%B5hja%20pst%2027a®ion=Harju&country=EE&api_key=YOUR-API-KEY" + var result GeocodeResponse + + query, err := req.toQueryParams() + if err != nil { + return nil, fmt.Errorf("structured geocode query: %w", err) + } + resp, err := s.client.R(). + SetQueryParamsFromValues(query). + SetResult(&result). + SetPathParam("urlBase", s.urlBase). + SetQueryParam("api_key", s.APIKey). + Get("https://{urlBase}/geocoding/v1/search/structured") + if err != nil { + return nil, fmt.Errorf("structured geocoding get: %w", err) + } + + if !resp.IsSuccess() { + return nil, fmt.Errorf("structude geocoding status: %w", err) + } + return &result, nil +} + +func (sgr StructuredGeocodeRequest) endpoint() string { + return "/v1/search/structured" +} +func (sgr StructuredGeocodeRequest) toQueryParams() (url.Values, error) { + return query.Values(sgr) +}