request.go

  1package valhalla
  2
  3import (
  4	"bytes"
  5	"encoding/json"
  6	"fmt"
  7	"log/slog"
  8	"net/http"
  9	"os"
 10)
 11
 12func init() {
 13	// attempt to read value of token from env and cache
 14	token = os.Getenv("STADIA_API_KEY")
 15}
 16
 17// cached value of token
 18var token string
 19
 20// DirectionsType is the format of the returned route. Only None is supported in this library.
 21type DirectionsType string
 22
 23const (
 24	None         DirectionsType = "none"
 25	Maneuvers    DirectionsType = "maneuvers"
 26	Instructions DirectionsType = "instructions"
 27)
 28
 29type ShapeFormat string
 30
 31const (
 32	GeoJSON   ShapeFormat = "geojson"
 33	Polyline6 ShapeFormat = "polyline6"
 34)
 35
 36type Request struct {
 37	Locations      []Location     `json:"locations"`
 38	Units          string         `json:"units,omitempty"`        //default: km, mi
 39	Language       string         `json:"language,omitempty"`     //default: en-US
 40	ShapeFormat    ShapeFormat    `json:"shape_format,omitempty"` // default: polyline6, geojson
 41	DirectionsType DirectionsType `json:"directions_type,omitempty"`
 42	Alternates     uint8          `json:"alternates,omitempty"` // default: 1, no-op on multipoint routes
 43	//defaults only: format (json), shape_format (polyline6)
 44	Costing        string          `json:"costing"`
 45	CostingOptions *CostingOptions `json:"costing_options,omitempty"`
 46
 47	token string
 48}
 49
 50type CostingOptions struct {
 51	Motorcycle *Motorcycle `json:"motorcycle,omitempty"`
 52	Auto       *Auto       `json:"auto,omitempty"`
 53}
 54
 55// Motorcycle costing options
 56type Motorcycle struct {
 57	// range [0.0, 1.0]
 58	UseHighways float32 `json:"use_highways,omitempty"` // default: 0.5, propensity to highway
 59	UseTrails   float32 `json:"use_trails,omitempty"`   // default: 0.0, use dirt tracks & secondary roads
 60}
 61
 62type Auto struct {
 63	TopSpeed               uint8   `json:"top_speed,omitempty"`                // default: 140kph
 64	UseTracks              float32 `json:"use_tracks,omitempty"`               // default: 0 (auto) 0.5 (moto), propensity to track roads
 65	UseHighways            float32 `json:"use_highways,omitempty"`             // default: 0.5 (shared config with motorcycle)
 66	CountryCrossingCost    int     `json:"country_crossing_cost,omitempty"`    // default: 600sec, added to time to cross a country border
 67	CountryCrossingPenalty int     `json:"country_crossing_penalty,omitempty"` // default: 0 (maybe 15?)
 68}
 69
 70func defaultOptions() *Request {
 71	return &Request{
 72		//	DirectionsType: None,
 73		Costing: "auto",
 74		//CostingOptions: &CostingOptions{
 75		//	Auto: &Auto{
 76		//		UseTracks:   0.0,
 77		//			UseHighways: 1.0,
 78		//		},
 79		//	},
 80		ShapeFormat: Polyline6,
 81	}
 82}
 83
 84// profile to use when initial routing fails (route does not reach intended destination)
 85// tests for whether a car route to the destinaion is possible
 86func defaultConservative() *Request {
 87	return &Request{
 88		Costing: "auto",
 89		CostingOptions: &CostingOptions{
 90			Auto: &Auto{
 91				UseTracks:   0.0,
 92				UseHighways: 1.0,
 93			},
 94		},
 95	}
 96}
 97
 98// LocationType sets allowed turns at a location and whether departure/arrival legs are generated
 99type LocationType string
100
101const (
102	// Break = u-turns allowed
103	Break LocationType = "break"
104	// Through = no u-turns, no arrival/departure
105	Through LocationType = "through"
106	// Via = u-turns allowed, no arrival/departure
107	Via LocationType = "via"
108	// BreakThrough = no u-turns, arrival/departure
109	BreakThrough LocationType = "break-through"
110)
111
112// Location is a single waypoint, send at least two for a route. Default LocationType is break.
113type Location struct {
114	Lon  float64      `json:"lon"`
115	Lat  float64      `json:"lat"`
116	Type LocationType `json:"type,omitempty"` //default: break, through, via, break-through
117}
118
119type RequestOption = func(*Request) error
120
121// NewRequest creates an http.Request to the Stadia API with the chosen costing options.
122// It will attempt to read the token from STADIA_API_KEY if it exists. Use WithToken
123// to pass the value manually.
124func NewRequest(pts []Location, opts ...RequestOption) (*http.Request, error) {
125	request := defaultOptions()
126	if request.token == "" {
127		if token != "" {
128			// use cached value if available from env
129			request.token = token
130		} else {
131			return nil, fmt.Errorf("error constructing route request: token missing")
132		}
133	}
134	if err := apply(request, opts...); err != nil {
135		return nil, fmt.Errorf("error constructing route request: %w", err)
136	}
137	if len(pts) < 2 {
138		return nil, fmt.Errorf("error constructing route request: must have at least 2 locations")
139	}
140
141	url := fmt.Sprintf("https://api.stadiamaps.com/route/v1?api_key=%s", request.token)
142	request.Locations = pts
143	body, err := json.Marshal(request)
144	if err != nil {
145		return nil, fmt.Errorf("error constructing route request: %w", err)
146	}
147	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
148	if err != nil {
149		return nil, fmt.Errorf("error constructing route request: %w", err)
150	}
151	return req, nil
152}
153
154// NewRequest creates an http.Request to the Stadia API with the chosen costing options.
155// It will attempt to read the token from STADIA_API_KEY if it exists. Use WithToken
156// to pass the value manually.
157func NewLocalRequest(pts []Location, opts ...RequestOption) (*http.Request, error) {
158	request := defaultOptions()
159	if request.token == "" {
160		if token != "" {
161			// use cached value if available from env
162			request.token = token
163		} else {
164			return nil, fmt.Errorf("error constructing route request: token missing")
165		}
166	}
167	if err := apply(request, opts...); err != nil {
168		return nil, fmt.Errorf("error constructing route request: %w", err)
169	}
170	if len(pts) < 2 {
171		return nil, fmt.Errorf("error constructing route request: must have at least 2 locations")
172	}
173
174	request.Locations = pts
175	body, err := json.Marshal(request)
176	if err != nil {
177		return nil, fmt.Errorf("error constructing route request: %w", err)
178	}
179	url := fmt.Sprintf("http://127.0.0.1:8002/route?json=%s", body)
180	slog.Info("request created", "body", body)
181	req, err := http.NewRequest(http.MethodGet, url, nil)
182	if err != nil {
183		return nil, fmt.Errorf("error constructing route request: %w", err)
184	}
185	return req, nil
186}
187
188// WithToken sets the API token for the request. If not provided via this function, STADIA_API_KEY
189// must be set to a non-empty string.  Setting the token explicitly overrides the env value.
190func WithToken(token string) RequestOption {
191	return func(r *Request) error {
192		r.token = token
193		return nil
194	}
195}
196
197// WithMaxSpeed sets the top speed of the vehicle to exclude roads with a higher posted limit
198// Default: 140kph (87mph)
199func WithMaxSpeed(kph int) RequestOption {
200	return func(r *Request) error {
201		// limits of Valhalla API 10-252kph
202		kph = clamp(kph, 10, 252)
203		if r.CostingOptions.Auto != nil {
204			r.CostingOptions.Auto.TopSpeed = uint8(kph)
205		} else {
206			r.CostingOptions.Auto = &Auto{TopSpeed: uint8(kph)}
207		}
208		return nil
209	}
210}
211
212// WithAdventure is a shorthand way to set multiple costing options in a sane way
213// input: 0-100, with 100 preferring more secondary roads, dirt tracks
214// Sets:
215//   - motorcycle costing for values >= 20, UseTrails max of 0.8
216//   - auto costing for values < 20, UseTracks max of 0.2, value of 0 uses fastest car routing
217func WithAdventure(adv int) RequestOption {
218	return func(r *Request) error {
219		if adv >= 20 {
220			// use motorcycle costing, max value of UseTrails/UseTracks is 0.8, UseHighways 1-UseTrails
221			r.Costing = "motorcycle"
222			tracks := float32(adv) / 100.0 * 0.8
223			return apply(r, WithUseTrails(tracks), WithUseTracks(tracks), WithUseHighways(1.0-tracks))
224		} else {
225			// low adv values, switch to car routing with a tiny bit of UseTracks
226			tracks := float32(adv) / 100.0
227			r.Costing = "auto"
228			return apply(r, WithUseTracks(tracks), WithUseHighways(1.0-tracks))
229		}
230	}
231}
232
233// WithUseTrails sets the propensity to take secondary roads and dirt tracks for motorcycle routing only
234// Prefer using WithAdventure to set related costing options in a sane way.
235func WithUseTrails(propensity float32) RequestOption {
236	return func(r *Request) error {
237		propensity = clamp(propensity, 0.0, 1.0)
238		r.Costing = "motorcycle"
239		if r.CostingOptions.Motorcycle != nil {
240			r.CostingOptions.Motorcycle.UseTrails = propensity
241		} else {
242			r.CostingOptions.Motorcycle = &Motorcycle{UseTrails: propensity}
243		}
244		return nil
245	}
246}
247
248// WithUseHighways sets the propensity to take highways for the chosen costing model, works with both
249// auto and motorcycle routing.  Sets the same config value for both.
250// Prefer using WithAdventure to set related costing options in a sane way.
251func WithUseHighways(propensity float32) RequestOption {
252	return func(r *Request) error {
253		propensity = clamp(propensity, 0.0, 1.0)
254
255		// set same config value for both motorcycle and auto costing options in case
256		// routing model switches to auto
257		if r.CostingOptions.Motorcycle != nil {
258			r.CostingOptions.Motorcycle.UseHighways = propensity
259		} else {
260			r.CostingOptions.Motorcycle = &Motorcycle{UseHighways: propensity}
261		}
262
263		if r.CostingOptions.Auto != nil {
264			r.CostingOptions.Auto.UseHighways = propensity
265		} else {
266			r.CostingOptions.Auto = &Auto{UseHighways: propensity}
267		}
268		return nil
269	}
270}
271
272// WithUseTracks sets the propensity to use secondary track roads for auto routing.
273// Default is 0 for auto routing, 0.5 for motorcycles.
274// Prefer using WithAdventure to set related costing options in a sane way.
275func WithUseTracks(propensity float32) RequestOption {
276	return func(r *Request) error {
277		propensity = clamp(propensity, 0.0, 1.0)
278		if r.CostingOptions.Auto != nil {
279			r.CostingOptions.Auto.UseTracks = propensity
280		} else {
281			r.CostingOptions.Auto = &Auto{UseTracks: propensity}
282		}
283		return nil
284	}
285}
286
287func WithConservativeDefaults() RequestOption {
288	return func(r *Request) error {
289		c := defaultConservative()
290		r.Costing = c.Costing
291		r.CostingOptions = c.CostingOptions
292		return nil
293	}
294}
295
296func clamp[T ~int | ~float32 | ~int32 | ~float64](value T, low T, high T) T {
297	switch {
298	case value < low:
299		return low
300	case value > high:
301		return high
302	default:
303		return value
304	}
305}
306
307func apply(base *Request, opts ...RequestOption) error {
308	for _, opt := range opts {
309		if err := opt(base); err != nil {
310			return err
311		}
312	}
313	return nil
314}