request.go
1package graphhopper
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "net/url"
10 "os"
11 "sync/atomic"
12)
13
14var token atomic.Value
15var graphhopperURL atomic.Value
16
17func init() {
18 token.Store(os.Getenv("GRAPHHOPPER_API_KEY"))
19 graphhopperURL.Store("http://127.0.0.1:8989")
20}
21
22type PointLngLat = [2]float64
23
24// SetRouterURL defines the URL to the Graphhopper-compatible routing API
25func SetRouterURL(url string) {
26 graphhopperURL.Store(url)
27}
28
29// SetToken defines the token for the external Graphhopper routing instance. Can also be set by the
30// environment variable GRAPHHOPPER_API_KEY
31func SetToken(tokenValue string) {
32 token.Store(tokenValue)
33}
34
35// Request to the Graphhopper API. Details:
36// https://docs.graphhopper.com/openapi/routing/postroute
37type Request struct {
38 Profile string `json:"profile"` // routing profile: car, car_avoid_motorway, car_avoid_toll
39 Points []PointLngLat `json:"points"`
40 Locale string `json:"locale,omitempty"`
41 Elevation bool `json:"elevation,omitempty"` // return elevation as XYZ coordinates
42 // Available: leg_time, leg_distance, country, time, distance, max_speed, toll, road_class,
43 // road_class_link, road_environment, lanes, surface, smoothness, +others
44 // See: https://discuss.graphhopper.com/t/new-routing-api-feature-path-details-and-support-for-avoiding-motorway-ferry-toll/2539
45 Details []string `json:"details,omitempty"`
46 Optimize bool `json:"optimize,omitempty"` // default: false, points reordered for best route
47 Instructions bool `json:"instructions"` // default: true, include instructions in response
48 PointsEncoded bool `json:"points_encoded"` // default: true=polyline6 false=json[lon, lat, elevation?]
49 CHDisable bool `json:"ch.disable,omitempty"` // disable CH to use custom model
50 CustomModel Model `json:"custom_model,omitempty"` // custom model parameters. see: https://docs.graphhopper.com/openapi/routing/postroute#tag/Custom-Model
51 PassThrough bool `json:"pass_through,omitempty"` // like via (no U-turns), requires CHDisable
52 Algorithm string `json:"algorithm,omitempty"` // options: round_trip, alternative_route - introduces randomness to provide alternate routes or roundtrip not over same path
53
54 token string
55}
56
57type Option = func(r *Request) error
58
59func WithProfile(profile string) Option {
60 return func(r *Request) error {
61 r.Profile = profile
62 return nil
63 }
64}
65
66func WithToken(token string) Option {
67 return func(r *Request) error {
68 r.token = token
69 return nil
70 }
71}
72
73func WithDetails(detail ...string) Option {
74 return func(r *Request) error {
75 for _, d := range detail {
76 switch d {
77 case "leg_time", "leg_distance", "country", "time", "distance", "max_speed", "toll", "road_class", "road_class_link", "road_environment", "lanes", "surface", "smoothness":
78 continue
79 default:
80 return fmt.Errorf("graphhopper request: unknown detail %s", d)
81 }
82 }
83 r.Details = detail
84 return nil
85 }
86}
87
88func WithElevation(should bool) Option {
89 return func(r *Request) error {
90 r.Elevation = should
91 if should {
92 // turn off polyline6 when elevation included
93 r.PointsEncoded = false
94 }
95 return nil
96 }
97}
98
99func WithCustomModel(model Model) Option {
100 return func(r *Request) error {
101 r.CHDisable = true
102 r.CustomModel = model
103 return nil
104 }
105}
106
107func WithVia(should bool) Option {
108 return func(r *Request) error {
109 if should {
110 r.CHDisable = true
111 r.PassThrough = true
112 }
113 return nil
114 }
115}
116
117func defaultOptions() *Request {
118 return &Request{
119 Profile: "car",
120 Elevation: true,
121 Details: []string{"time", "distance"},
122 Instructions: false,
123 PointsEncoded: false,
124 CHDisable: true,
125 CustomModel: NewModel(
126 WithPriorityRules(
127 If(IsHighway(), MultiplyBy(0.7)),
128 )),
129 }
130}
131
132func NewRequest(pts []PointLngLat, opts ...Option) (*http.Request, error) {
133 if len(pts) < 2 {
134 return nil, fmt.Errorf("error constructing route request: must have at least 2 locations")
135 }
136 request := defaultOptions()
137 for _, opt := range opts {
138 if err := opt(request); err != nil {
139 return nil, fmt.Errorf("error constructing route request: %w", err)
140 }
141 }
142 if request.token == "" {
143 globalToken := token.Load().(string)
144 if globalToken != "" {
145 // use cached value if available from env
146 request.token = globalToken
147 }
148 }
149 ghUrl := graphhopperURL.Load().(string)
150 joined, err := url.JoinPath(ghUrl, "route")
151 if err != nil {
152 return nil, fmt.Errorf("error constructing route request: %w", err)
153 }
154 u, err := url.Parse(joined)
155 if err != nil {
156 return nil, fmt.Errorf("error constructing route request: %w", err)
157 }
158 if request.token != "" {
159 q := u.Query()
160 q.Set("key", request.token)
161 u.RawQuery = q.Encode()
162 }
163 request.Points = pts
164 body, err := json.Marshal(request)
165 if err != nil {
166 return nil, fmt.Errorf("error constructing route request: %w", err)
167 }
168
169 // Debug log the request body (mask the API key)
170 debugBody := make(map[string]interface{})
171 json.Unmarshal(body, &debugBody)
172 delete(debugBody, "token") // Don't log the token
173 debugJSON, _ := json.Marshal(debugBody)
174 slog.Debug("graphhopper.NewRequest: request prepared",
175 "url", u.String(),
176 "body", string(debugJSON))
177
178 req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
179 if err != nil {
180 return nil, fmt.Errorf("error constructing route request: %w", err)
181 }
182 req.Header.Set("Content-Type", "application/json")
183 return req, nil
184}