response.go
1package graphhopper
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "log/slog"
8 "net/http"
9 "time"
10)
11
12type Path struct {
13 Distance float64 `json:"distance"` //meters
14 Time int64 `json:"time"` // milliseconds
15 Ascend float64 `json:"ascend"` // meters
16 Descend float64 `json:"descend"` // meters
17 Points json.RawMessage `json:"points"` // either json or polyline6
18 SnappedWaypoints json.RawMessage `json:"snapped_waypoints"` // start/end snap points json or polyline6
19 PointsEncoded bool `json:"points_encoded"` // whether polyline6=true
20 Bbox []float64 `json:"bbox"`
21 Details json.RawMessage `json:"details"` // het list array<[int, int, string]> = index_from, index_to, value
22}
23
24type Response struct {
25 Hints json.RawMessage `json:"hints"` // stats (not needed)
26 Info json.RawMessage `json:"info"` // copyright, attribution
27 Paths []Path `json:"paths"`
28 Instructions json.RawMessage `json:"instructions"`
29 decodedPoints [][]float64
30 decodedElevation []float64
31}
32
33func Route(pts []PointLngLat, opts ...Option) (*Response, error) {
34 req, err := NewRequest(pts, opts...)
35 if err != nil {
36 return nil, fmt.Errorf("routing error: req: %w", err)
37 }
38
39 slog.Debug("graphhopper.Route: sending request",
40 "url", req.URL.String(),
41 "method", req.Method,
42 "numPoints", len(pts))
43
44 client := http.Client{Timeout: 30 * time.Second}
45 resp, err := client.Do(req)
46 if err != nil {
47 return nil, fmt.Errorf("routing error: send: %w", err)
48 }
49 defer resp.Body.Close()
50
51 slog.Debug("graphhopper.Route: received response",
52 "statusCode", resp.StatusCode,
53 "status", resp.Status)
54
55 // Check for HTTP error status
56 if resp.StatusCode != http.StatusOK {
57 body, _ := io.ReadAll(resp.Body)
58 slog.Error("graphhopper.Route: API returned error status",
59 "statusCode", resp.StatusCode,
60 "status", resp.Status,
61 "body", string(body))
62 return nil, ParseAPIError(resp.StatusCode, string(body))
63 }
64
65 var out = new(Response)
66 if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
67 slog.Error("graphhopper.Route: failed to decode response",
68 "error", err)
69 return nil, fmt.Errorf("routing error: decode: %w", err)
70 }
71
72 slog.Debug("graphhopper.Route: response decoded",
73 "numPaths", len(out.Paths))
74
75 return out, nil
76}
77
78// geoJSONLineString represents a GeoJSON LineString geometry
79type geoJSONLineString struct {
80 Type string `json:"type"`
81 Coordinates [][]float64 `json:"coordinates"`
82}
83
84// memoize the result of decoding the encoded paths and stitching them together into a final array of [lon,lat] points and array [elevation]
85func (r *Response) decode() error {
86 slog.Debug("graphhopper.Response.decode: starting",
87 "numPaths", len(r.Paths),
88 "alreadyDecoded", len(r.decodedPoints) > 0)
89
90 // memoized decode
91 if len(r.decodedPoints) > 0 {
92 slog.Debug("graphhopper.Response.decode: already decoded, returning cached")
93 return nil
94 } else {
95 r.decodedPoints = make([][]float64, 0)
96 r.decodedElevation = make([]float64, 0)
97 }
98
99 for pathIdx, p := range r.Paths {
100 slog.Debug("graphhopper.Response.decode: processing path",
101 "pathIndex", pathIdx,
102 "pointsEncoded", p.PointsEncoded,
103 "rawPointsLen", len(p.Points))
104
105 if p.PointsEncoded {
106 slog.Error("graphhopper.Response.decode: polyline6 encoding not supported")
107 panic("TODO: polyline6 decoding not implemented")
108 } else {
109 // API returns GeoJSON LineString when points_encoded=false
110 var geoJSON geoJSONLineString
111 if err := json.Unmarshal(p.Points, &geoJSON); err != nil {
112 slog.Error("graphhopper.Response.decode: error unmarshaling GeoJSON",
113 "pathIndex", pathIdx,
114 "error", err,
115 "rawData", string(p.Points[:min(200, len(p.Points))]))
116 return fmt.Errorf("routing response: error decoding points: %w", err)
117 }
118
119 pts := geoJSON.Coordinates
120
121 slog.Debug("graphhopper.Response.decode: unmarshaled GeoJSON",
122 "pathIndex", pathIdx,
123 "geoJSONType", geoJSON.Type,
124 "numPoints", len(pts))
125
126 if len(pts) == 0 {
127 slog.Warn("graphhopper.Response.decode: no points in path",
128 "pathIndex", pathIdx)
129 continue
130 }
131
132 if len(pts[0]) < 2 {
133 slog.Warn("graphhopper.Response.decode: unexpected point dimensions",
134 "pathIndex", pathIdx,
135 "numPoints", len(pts),
136 "firstPointLen", len(pts[0]))
137 continue
138 }
139
140 slog.Debug("graphhopper.Response.decode: first point sample",
141 "pathIndex", pathIdx,
142 "firstPoint", pts[0])
143
144 // Extract [lon, lat] from each point
145 for ptIdx, pt := range pts {
146 if len(pt) >= 2 {
147 r.decodedPoints = append(r.decodedPoints, []float64{pt[0], pt[1]})
148 } else {
149 slog.Warn("graphhopper.Response.decode: skipping point with insufficient coordinates",
150 "pathIndex", pathIdx,
151 "pointIndex", ptIdx,
152 "pointLen", len(pt))
153 }
154 if len(pt) == 3 {
155 r.decodedElevation = append(r.decodedElevation, pt[2])
156 }
157 }
158
159 slog.Debug("graphhopper.Response.decode: extracted from path",
160 "pathIndex", pathIdx,
161 "extractedPoints", len(r.decodedPoints),
162 "extractedElevation", len(r.decodedElevation))
163 }
164 }
165
166 slog.Debug("graphhopper.Response.decode: completed",
167 "totalPoints", len(r.decodedPoints),
168 "totalElevation", len(r.decodedElevation))
169
170 return nil
171}
172
173func min(a, b int) int {
174 if a < b {
175 return a
176 }
177 return b
178}
179
180// Points returns an array of [lon, lat] points from the encoded response, combining all paths into a single path
181func (r *Response) Points() ([][]float64, error) {
182 if err := r.decode(); err != nil {
183 return nil, err
184 }
185 return r.decodedPoints, nil
186}
187
188// Elevation returns an array of elevation in meters along the full path
189func (r *Response) Elevation() ([]float64, error) {
190 if err := r.decode(); err != nil {
191 return nil, err
192 }
193 return r.decodedElevation, nil
194}
195
196type Stats struct {
197 Distance float64
198 Time float64
199 Ascend float64
200 Descend float64
201}
202
203// Stats returns time, distance, elevation change for the full route path
204func (r *Response) Stats() Stats {
205 var d, t, up, down float64 = 0.0, 0.0, 0.0, 0.0
206 for _, p := range r.Paths {
207 d += p.Distance
208 t += float64(p.Time) / 1000.0 // in fractional seconds from original millisecond precision
209 up += p.Ascend
210 down += p.Descend
211 }
212 return Stats{
213 Distance: d,
214 Time: t,
215 Ascend: up,
216 Descend: down,
217 }
218}
219
220// GeoJSONFeature represents a GeoJSON Feature object with LineString geometry
221type GeoJSONFeature struct {
222 Type string `json:"type"`
223 Geometry GeoJSONLineString `json:"geometry"`
224 Properties map[string]interface{} `json:"properties"`
225}
226
227// GeoJSONLineString represents a GeoJSON LineString geometry
228type GeoJSONLineString struct {
229 Type string `json:"type"`
230 Coordinates [][]float64 `json:"coordinates"`
231}
232
233// GeoJSONOption is a functional option for configuring GeoJSON output
234type GeoJSONOption func(*GeoJSONFeature)
235
236// WithProperty adds a custom property to the GeoJSON Feature
237func WithProperty(key string, value any) GeoJSONOption {
238 return func(f *GeoJSONFeature) {
239 f.Properties[key] = value
240 }
241}
242
243// ToGeoJSON decodes a Graphhopper response into a Feature with LineString geometry.
244// Coordinates are in Maplibre format [lon, lat].
245// Use functional options to add custom properties.
246func (r *Response) ToGeoJSON(opts ...GeoJSONOption) (*GeoJSONFeature, error) {
247 if err := r.decode(); err != nil {
248 return nil, err
249 }
250
251 feature := &GeoJSONFeature{
252 Type: "Feature",
253 Geometry: GeoJSONLineString{
254 Type: "LineString",
255 Coordinates: r.decodedPoints,
256 },
257 Properties: make(map[string]any),
258 }
259
260 // Apply functional options
261 for _, opt := range opts {
262 opt(feature)
263 }
264
265 return feature, nil
266}