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}