use super::{RouteRequest, RoutingRequestGenerationError};
use crate::models::{GeographicCoordinate, UserLocation, Waypoint, WaypointKind};
use crate::routing_adapters::RouteRequestGenerator;
#[cfg(all(not(feature = "std"), feature = "alloc"))]
use alloc::collections::BTreeMap as HashMap;
use serde_json::{Map, Value as JsonValue, json};
#[cfg(feature = "std")]
use std::collections::HashMap;
use crate::routing_adapters::error::InstantiationError;
#[cfg(feature = "alloc")]
use alloc::{
string::{String, ToString},
vec::Vec,
};
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
#[cfg(feature = "wasm-bindgen")]
use tsify::Tsify;
#[derive(Copy, Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[cfg_attr(feature = "wasm-bindgen", derive(Tsify))]
#[cfg_attr(feature = "wasm-bindgen", tsify(into_wasm_abi, from_wasm_abi))]
pub struct ValhallaWaypointProperties {
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub heading: Option<u16>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub heading_tolerance: Option<u16>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub minimum_reachability: Option<u16>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub radius: Option<u16>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub preferred_side: Option<ValhallaWaypointPreferredSide>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub display_coordinate: Option<GeographicCoordinate>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub search_cutoff: Option<u32>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub node_snap_tolerance: Option<u16>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub street_side_tolerance: Option<u16>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub street_side_max_distance: Option<u16>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub street_side_cutoff: Option<ValhallaRoadClass>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub search_filter: Option<ValhallaLocationSearchFilter>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub allow_uturns: Option<bool>,
}
impl Waypoint {
pub fn new_with_valhalla_properties(
coordinate: GeographicCoordinate,
kind: WaypointKind,
properties: ValhallaWaypointProperties,
) -> Self {
Self {
coordinate,
kind,
#[expect(clippy::missing_panics_doc)]
properties: Some(serde_json::to_vec(&properties).expect("Serialization of Valhalla waypoint properties failed. This is a bug in Ferrostar; please open an issue report on GitHub."))
}
}
}
#[cfg(feature = "uniffi")]
#[uniffi::export]
pub fn create_waypoint_with_valhalla_properties(
coordinate: GeographicCoordinate,
kind: WaypointKind,
properties: ValhallaWaypointProperties,
) -> Waypoint {
Waypoint::new_with_valhalla_properties(coordinate, kind, properties)
}
#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
#[cfg_attr(feature = "wasm-bindgen", derive(Tsify))]
#[cfg_attr(feature = "wasm-bindgen", tsify(into_wasm_abi, from_wasm_abi))]
#[serde(rename_all = "snake_case")]
pub enum ValhallaWaypointPreferredSide {
Same,
Opposite,
Either,
}
#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
#[cfg_attr(feature = "wasm-bindgen", derive(Tsify))]
#[cfg_attr(feature = "wasm-bindgen", tsify(into_wasm_abi, from_wasm_abi))]
#[serde(rename_all = "snake_case")]
pub enum ValhallaRoadClass {
Motorway,
Trunk,
Primary,
Secondary,
Tertiary,
Unclassified,
Residential,
ServiceOther,
}
#[skip_serializing_none]
#[derive(Copy, Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[cfg_attr(feature = "wasm-bindgen", derive(Tsify))]
#[cfg_attr(feature = "wasm-bindgen", tsify(into_wasm_abi, from_wasm_abi))]
#[serde(rename_all = "snake_case")]
pub struct ValhallaLocationSearchFilter {
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub exclude_tunnel: Option<bool>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub exclude_bridge: Option<bool>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub exclude_tolls: Option<bool>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub exclude_ferry: Option<bool>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub exclude_ramp: Option<bool>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub exclude_closures: Option<bool>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub min_road_class: Option<ValhallaRoadClass>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub max_road_class: Option<ValhallaRoadClass>,
#[cfg_attr(feature = "uniffi", uniffi(default))]
pub level: Option<f32>,
}
#[derive(Debug)]
pub struct ValhallaHttpRequestGenerator {
endpoint_url: String,
profile: String,
options: Map<String, JsonValue>,
}
impl ValhallaHttpRequestGenerator {
pub fn new<U: Into<String>, P: Into<String>>(
endpoint_url: U,
profile: P,
options: Map<String, JsonValue>,
) -> Self {
Self {
endpoint_url: endpoint_url.into(),
profile: profile.into(),
options,
}
}
pub fn with_options_json<U: Into<String>, P: Into<String>>(
endpoint_url: U,
profile: P,
options_json: Option<&str>,
) -> Result<Self, InstantiationError> {
let parsed_options = match options_json {
Some(options) => serde_json::from_str::<JsonValue>(options)?
.as_object()
.ok_or(InstantiationError::OptionsJsonParseError)?
.to_owned(),
None => Map::new(),
};
Ok(Self {
endpoint_url: endpoint_url.into(),
profile: profile.into(),
options: parsed_options,
})
}
}
impl RouteRequestGenerator for ValhallaHttpRequestGenerator {
fn generate_request(
&self,
user_location: UserLocation,
waypoints: Vec<Waypoint>,
) -> Result<RouteRequest, RoutingRequestGenerationError> {
if waypoints.is_empty() {
Err(RoutingRequestGenerationError::NotEnoughWaypoints)
} else {
let headers =
HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
let mut start = json!({
"lat": user_location.coordinates.lat,
"lon": user_location.coordinates.lng,
"street_side_tolerance": core::cmp::max(5, user_location.horizontal_accuracy as u16),
});
if let Some(course) = user_location.course_over_ground {
start["heading"] = course.degrees.into();
}
let waypoints: Vec<_> = waypoints
.into_iter()
.map(|waypoint| {
let intermediate = json!({
"lat": waypoint.coordinate.lat,
"lon": waypoint.coordinate.lng,
"type": match waypoint.kind {
WaypointKind::Break => "break",
WaypointKind::Via => "via",
},
});
Ok(merge_optional_waypoint_properties(
waypoint.kind,
intermediate,
if let Some(props) = waypoint.properties.as_deref() {
serde_json::from_slice(props)?
} else {
None
},
))
})
.collect::<Result<_, RoutingRequestGenerationError>>()?;
let locations: Vec<JsonValue> = core::iter::once(start).chain(waypoints).collect();
let mut args = json!({
"format": "osrm",
"filters": {
"action": "include",
"attributes": [
"shape_attributes.speed",
"shape_attributes.speed_limit",
"shape_attributes.time",
"shape_attributes.length"
]
},
"banner_instructions": true,
"voice_instructions": true,
"costing": &self.profile,
"locations": locations,
});
for (k, v) in &self.options {
args[k] = v.clone();
}
let body = serde_json::to_vec(&args)?;
Ok(RouteRequest::HttpPost {
url: self.endpoint_url.clone(),
headers,
body,
})
}
}
}
fn merge_optional_waypoint_properties(
waypoint_kind: WaypointKind,
location: JsonValue,
waypoint_properties: Option<ValhallaWaypointProperties>,
) -> JsonValue {
let Some(ValhallaWaypointProperties {
heading,
heading_tolerance,
minimum_reachability,
radius,
preferred_side,
display_coordinate,
search_cutoff,
node_snap_tolerance,
street_side_tolerance,
street_side_max_distance,
street_side_cutoff,
search_filter,
allow_uturns,
}) = waypoint_properties
else {
return location;
};
let mut result = location;
if allow_uturns.is_some_and(|value| value == false) {
result["type"] = match waypoint_kind {
WaypointKind::Break => "break_through".into(),
WaypointKind::Via => "through".into(),
}
}
if let Some(heading) = heading {
result["heading"] = heading.into();
}
if let Some(heading_tolerance) = heading_tolerance {
result["heading_tolerance"] = heading_tolerance.into();
}
if let Some(minimum_reachability) = minimum_reachability {
result["minimum_reachability"] = minimum_reachability.into();
}
if let Some(radius) = radius {
result["radius"] = radius.into();
}
if let Some(preferred_side) = preferred_side {
result["preferred_side"] =
serde_json::to_value(preferred_side).expect("This should never fail");
}
if let Some(display_coordinate) = display_coordinate {
result["display_lat"] = display_coordinate.lat.into();
result["display_lon"] = display_coordinate.lng.into();
}
if let Some(search_cutoff) = search_cutoff {
result["search_cutoff"] = search_cutoff.into();
}
if let Some(node_snap_tolerance) = node_snap_tolerance {
result["node_snap_tolerance"] = node_snap_tolerance.into();
}
if let Some(street_side_tolerance) = street_side_tolerance {
result["street_side_tolerance"] = street_side_tolerance.into();
}
if let Some(street_side_max_distance) = street_side_max_distance {
result["street_side_max_distance"] = street_side_max_distance.into();
}
if let Some(street_side_cutoff) = street_side_cutoff {
result["street_side_cutoff"] =
serde_json::to_value(street_side_cutoff).expect("This should never fail");
}
if let Some(search_filter) = search_filter {
result["search_filter"] =
serde_json::to_value(search_filter).expect("This should never fail");
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{CourseOverGround, GeographicCoordinate};
use assert_json_diff::assert_json_include;
use serde_json::{from_slice, json};
use std::sync::LazyLock;
#[cfg(all(feature = "std", not(feature = "web-time")))]
use std::time::SystemTime;
#[cfg(feature = "web-time")]
use web_time::SystemTime;
const ENDPOINT_URL: &str = "https://api.stadiamaps.com/route/v1";
const COSTING: &str = "bicycle";
const USER_LOCATION: UserLocation = UserLocation {
coordinates: GeographicCoordinate { lat: 0.0, lng: 0.0 },
horizontal_accuracy: 6.0,
course_over_ground: None,
timestamp: SystemTime::UNIX_EPOCH,
speed: None,
};
const USER_LOCATION_WITH_COURSE: UserLocation = UserLocation {
coordinates: GeographicCoordinate { lat: 0.0, lng: 0.0 },
horizontal_accuracy: 6.0,
course_over_ground: Some(CourseOverGround {
degrees: 42,
accuracy: Some(12),
}),
timestamp: SystemTime::UNIX_EPOCH,
speed: None,
};
static WAYPOINTS: LazyLock<[Waypoint; 2]> = LazyLock::new(|| {
[
Waypoint {
coordinate: GeographicCoordinate { lat: 0.0, lng: 1.0 },
kind: WaypointKind::Break,
properties: None,
},
Waypoint::new_with_valhalla_properties(
GeographicCoordinate { lat: 2.0, lng: 3.0 },
WaypointKind::Break,
ValhallaWaypointProperties {
preferred_side: Some(ValhallaWaypointPreferredSide::Same),
search_filter: Some(ValhallaLocationSearchFilter {
exclude_bridge: Some(true),
min_road_class: Some(ValhallaRoadClass::Residential),
..Default::default()
}),
..Default::default()
},
),
]
});
#[test]
fn not_enough_locations() {
let generator = ValhallaHttpRequestGenerator::new(ENDPOINT_URL, COSTING, Map::new());
assert!(matches!(
generator.generate_request(USER_LOCATION, Vec::new()),
Err(RoutingRequestGenerationError::NotEnoughWaypoints)
));
}
fn generate_body(
user_location: UserLocation,
waypoints: Vec<Waypoint>,
options_json: Option<&str>,
) -> JsonValue {
let generator =
ValhallaHttpRequestGenerator::with_options_json(ENDPOINT_URL, COSTING, options_json)
.expect("Unable to create request generator");
match generator.generate_request(user_location, waypoints) {
Ok(RouteRequest::HttpPost {
url: request_url,
headers,
body,
}) => {
assert_eq!(ENDPOINT_URL, request_url);
assert_eq!(headers["Content-Type"], "application/json".to_string());
from_slice(&body).expect("Failed to parse request body as JSON")
}
Ok(RouteRequest::HttpGet { .. }) => unreachable!(
"The Valhalla HTTP request generator currently only generates POST requests"
),
Err(e) => {
println!("Failed to generate request: {:?}", e);
json!(null)
}
}
}
#[test]
fn request_body_without_course() {
let body_json = generate_body(USER_LOCATION, WAYPOINTS.to_vec(), None);
assert_json_include!(
actual: body_json,
expected: json!({
"costing": COSTING,
"locations": [
{
"lat": 0.0,
"lon": 0.0,
"street_side_tolerance": 6,
},
{
"lat": 0.0,
"lon": 1.0
},
{
"lat": 2.0,
"lon": 3.0,
}
],
})
);
}
#[test]
fn request_body_with_custom_waypoint_types() {
let body_json = generate_body(
USER_LOCATION,
vec![
Waypoint {
coordinate: GeographicCoordinate { lat: 0.0, lng: 1.0 },
kind: WaypointKind::Via,
properties: None,
},
Waypoint::new_with_valhalla_properties(
GeographicCoordinate { lat: 2.0, lng: 3.0 },
WaypointKind::Break,
ValhallaWaypointProperties {
allow_uturns: Some(false),
..Default::default()
},
),
Waypoint::new_with_valhalla_properties(
GeographicCoordinate { lat: 4.0, lng: 5.0 },
WaypointKind::Via,
ValhallaWaypointProperties {
allow_uturns: Some(false),
..Default::default()
},
),
Waypoint {
coordinate: GeographicCoordinate { lat: 6.0, lng: 7.0 },
kind: WaypointKind::Break,
properties: None,
},
],
None,
);
assert_json_include!(
actual: body_json,
expected: json!({
"costing": COSTING,
"locations": [
{
"lat": 0.0,
"lon": 0.0,
"street_side_tolerance": 6,
},
{
"lat": 0.0,
"lon": 1.0,
"type": "via",
},
{
"lat": 2.0,
"lon": 3.0,
"type": "break_through"
},
{
"lat": 4.0,
"lon": 5.0,
"type": "through"
},
{
"lat": 6.0,
"lon": 7.0,
"type": "break"
}
],
})
);
}
#[test]
fn request_body_with_course() {
let body_json = generate_body(USER_LOCATION_WITH_COURSE, WAYPOINTS.to_vec(), None);
assert_json_include!(
actual: body_json,
expected: json!({
"costing": COSTING,
"locations": [
{
"lat": 0.0,
"lon": 0.0,
"street_side_tolerance": 6,
"heading": 42,
},
{
"lat": 0.0,
"lon": 1.0
},
{
"lat": 2.0,
"lon": 3.0,
"preferred_side": "same",
"search_filter": {
"exclude_bridge": true,
"min_road_class": "residential",
}
}
],
})
);
}
#[test]
fn request_body_without_costing_options() {
let body_json = generate_body(USER_LOCATION, WAYPOINTS.to_vec(), None);
assert!(body_json["costing_options"].is_null());
}
#[test]
#[should_panic]
fn request_body_invalid_costing_options() {
let body_json = generate_body(
USER_LOCATION,
WAYPOINTS.to_vec(),
Some(r#"["costing_options"]"#),
);
assert!(body_json["costing_options"].is_null());
}
#[test]
fn request_body_with_costing_options() {
let body_json = generate_body(
USER_LOCATION,
WAYPOINTS.to_vec(),
Some(r#"{"costing_options": {"bicycle": {"bicycle_type": "Road"}}}"#),
);
assert_json_include!(
actual: body_json,
expected: json!({
"costing_options": {
"bicycle": {
"bicycle_type": "Road",
},
},
})
);
}
#[test]
fn request_body_with_multiple_options() {
let body_json = generate_body(
USER_LOCATION,
WAYPOINTS.to_vec(),
Some(r#"{"units": "mi", "costing_options": {"bicycle": {"bicycle_type": "Road"}}}"#),
);
assert_json_include!(
actual: body_json,
expected: json!({
"costing_options": {
"bicycle": {
"bicycle_type": "Road",
},
},
"units": "mi"
})
);
}
#[test]
fn request_body_with_invalid_horizontal_accuracy() {
let generator = ValhallaHttpRequestGenerator::new(ENDPOINT_URL, COSTING, Map::new());
let location = UserLocation {
coordinates: GeographicCoordinate { lat: 0.0, lng: 0.0 },
horizontal_accuracy: -6.0,
course_over_ground: None,
timestamp: SystemTime::now(),
speed: None,
};
let RouteRequest::HttpPost {
url: request_url,
headers,
body,
} = generator
.generate_request(location, WAYPOINTS.to_vec())
.unwrap()
else {
unreachable!(
"The Valhalla HTTP request generator currently only generates POST requests"
);
};
assert_eq!(ENDPOINT_URL, request_url);
assert_eq!(headers["Content-Type"], "application/json".to_string());
let body_json: JsonValue = from_slice(&body).expect("Failed to parse request body as JSON");
assert_json_include!(
actual: body_json,
expected: json!({
"costing": COSTING,
"locations": [
{
"lat": 0.0,
"lon": 0.0,
"street_side_tolerance": 5,
},
{
"lat": 0.0,
"lon": 1.0
},
{
"lat": 2.0,
"lon": 3.0,
"preferred_side": "same",
"search_filter": {
"exclude_bridge": true,
"min_road_class": "residential",
}
}
],
})
);
}
}