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}