From b9892edb60a5cdb8678208551420ceb3f154ac42 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Wed, 3 Apr 2024 16:42:36 -0700 Subject: [PATCH 1/4] [travelmux] Copy v3 -> v4 (no changes in behavior yet) --- services/travelmux/src/api/mod.rs | 1 + services/travelmux/src/api/v4/mod.rs | 1 + services/travelmux/src/api/v4/plan.rs | 735 ++++++++++++++++++ .../src/bin/travelmux-server/main.rs | 11 +- 4 files changed, 743 insertions(+), 5 deletions(-) create mode 100644 services/travelmux/src/api/v4/mod.rs create mode 100644 services/travelmux/src/api/v4/plan.rs diff --git a/services/travelmux/src/api/mod.rs b/services/travelmux/src/api/mod.rs index e2d9842c7..9df3af70f 100644 --- a/services/travelmux/src/api/mod.rs +++ b/services/travelmux/src/api/mod.rs @@ -4,3 +4,4 @@ pub use app_state::AppState; pub mod health; pub mod v2; pub mod v3; +pub mod v4; diff --git a/services/travelmux/src/api/v4/mod.rs b/services/travelmux/src/api/v4/mod.rs new file mode 100644 index 000000000..7764a5c30 --- /dev/null +++ b/services/travelmux/src/api/v4/mod.rs @@ -0,0 +1 @@ +pub mod plan; diff --git a/services/travelmux/src/api/v4/plan.rs b/services/travelmux/src/api/v4/plan.rs new file mode 100644 index 000000000..dac199967 --- /dev/null +++ b/services/travelmux/src/api/v4/plan.rs @@ -0,0 +1,735 @@ +use actix_web::{get, web, HttpRequest, HttpResponseBuilder}; +use geo::algorithm::BoundingRect; +use geo::geometry::{LineString, Point, Rect}; +use polyline::decode_polyline; +use reqwest::header::{HeaderName, HeaderValue}; +use serde::de::IntoDeserializer; +use serde::ser::SerializeStruct; +use serde::{de, de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; + +use crate::api::AppState; +use crate::otp::otp_api; +use crate::util::{deserialize_point_from_lat_lon, extend_bounds}; +use crate::valhalla::valhalla_api; +use crate::{DistanceUnit, Error, TravelMode}; + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlanQuery { + #[serde(deserialize_with = "deserialize_point_from_lat_lon")] + to_place: Point, + + #[serde(deserialize_with = "deserialize_point_from_lat_lon")] + from_place: Point, + + num_itineraries: u32, + + mode: TravelModes, + + /// Ignored by OTP - transit trips will always be metric. + /// Examine the `distance_units` in the response `Itinerary` to correctly interpret the response. + preferred_distance_units: Option, +} + +use crate::error::ErrorType; +use error::PlanResponseErr; + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +struct PlanResponseOk { + plan: Plan, + + // The raw response from the upstream OTP /plan service + #[serde(rename = "_otp")] + _otp: Option, + + // The raw response from the upstream Valhalla /route service + #[serde(rename = "_valhalla")] + _valhalla: Option, +} + +mod error { + use super::*; + use crate::error::ErrorType; + use actix_web::body::BoxBody; + use actix_web::HttpResponse; + use std::fmt; + + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct UnboxedPlanResponseErr { + pub error: PlanError, + // The raw response from the upstream OTP /plan service + #[serde(rename = "_otp")] + _otp: Option, + + // The raw response from the upstream Valhalla /route service + #[serde(rename = "_valhalla")] + _valhalla: Option, + } + pub type PlanResponseErr = Box; + + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct PlanError { + pub status_code: u16, + pub error_code: u32, + pub message: String, + } + + impl From for PlanResponseErr { + fn from(value: valhalla_api::RouteResponseError) -> Self { + Self::new(UnboxedPlanResponseErr { + error: (&value).into(), + _valhalla: Some(value), + _otp: None, + }) + } + } + + impl From for PlanResponseErr { + fn from(value: otp_api::PlanError) -> Self { + Self::new(UnboxedPlanResponseErr { + error: (&value).into(), + _valhalla: None, + _otp: Some(value), + }) + } + } + + impl From<&valhalla_api::RouteResponseError> for PlanError { + fn from(value: &valhalla_api::RouteResponseError) -> Self { + PlanError { + status_code: value.status_code, + error_code: value.error_code + 2000, + message: value.error.clone(), + } + } + } + + impl fmt::Display for PlanResponseErr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "status_code: {}, error_code: {}, message: {}", + self.error.status_code, self.error.error_code, self.error.message + ) + } + } + + impl std::error::Error for PlanResponseErr {} + + impl From for PlanResponseErr { + fn from(value: Error) -> Self { + Self::new(UnboxedPlanResponseErr { + error: value.into(), + _valhalla: None, + _otp: None, + }) + } + } + + impl From for PlanError { + fn from(value: Error) -> Self { + let error_code = value.error_type as u32; + debug_assert!(error_code < 2000); + debug_assert!(error_code > 1000); + match value.error_type { + ErrorType::ThisTransitAreaNotCovered => Self { + status_code: 400, + error_code, + message: value.source.to_string(), + }, + ErrorType::User => Self { + status_code: 400, + error_code, + message: value.source.to_string(), + }, + ErrorType::Server => Self { + status_code: 500, + error_code, + message: value.source.to_string(), + }, + } + } + } + + impl From<&otp_api::PlanError> for PlanError { + fn from(value: &otp_api::PlanError) -> Self { + Self { + // This might be overzealous, but anecdotally, I haven't encountered any 500ish + // errors with OTP surfaced in this way yet + status_code: 400, + error_code: value.id, + message: value.msg.clone(), + } + } + } + + impl actix_web::ResponseError for PlanResponseErr { + fn status_code(&self) -> actix_web::http::StatusCode { + self.error.status_code.try_into().unwrap_or_else(|e| { + log::error!( + "invalid status code: {}, err: {e:?}", + self.error.status_code + ); + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR + }) + } + + fn error_response(&self) -> HttpResponse { + HttpResponseBuilder::new(self.status_code()) + .content_type("application/json") + .json(self) + } + } + + impl PlanResponseOk { + pub fn from_otp( + mode: TravelMode, + mut otp: otp_api::PlanResponse, + ) -> std::result::Result { + if let Some(otp_error) = otp.error { + return Err(otp_error.into()); + } + + otp.plan + .itineraries + .sort_by(|a, b| a.end_time.cmp(&b.end_time)); + + let itineraries_result: crate::Result> = otp + .plan + .itineraries + .iter() + .map(|itinerary: &otp_api::Itinerary| Itinerary::from_otp(itinerary, mode)) + .collect(); + + let itineraries = itineraries_result?; + + Ok(PlanResponseOk { + plan: Plan { itineraries }, + _otp: Some(otp), + _valhalla: None, + }) + } + + pub fn from_valhalla( + mode: TravelMode, + valhalla: valhalla_api::ValhallaRouteResponseResult, + ) -> std::result::Result { + let valhalla = match valhalla { + valhalla_api::ValhallaRouteResponseResult::Ok(valhalla) => valhalla, + valhalla_api::ValhallaRouteResponseResult::Err(err) => return Err(err), + }; + + let mut itineraries = vec![Itinerary::from_valhalla(&valhalla.trip, mode)]; + if let Some(alternates) = &valhalla.alternates { + for alternate in alternates { + itineraries.push(Itinerary::from_valhalla(&alternate.trip, mode)); + } + } + + Ok(PlanResponseOk { + plan: Plan { itineraries }, + _otp: None, + _valhalla: Some(valhalla), + }) + } + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +struct Plan { + itineraries: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +struct Itinerary { + mode: TravelMode, + duration: f64, + distance: f64, + distance_units: DistanceUnit, + #[serde(serialize_with = "serialize_rect_to_lng_lat")] + bounds: Rect, + legs: Vec, +} + +impl Itinerary { + fn from_valhalla(valhalla: &valhalla_api::Trip, mode: TravelMode) -> Self { + let bounds = Rect::new( + geo::coord!(x: valhalla.summary.min_lon, y: valhalla.summary.min_lat), + geo::coord!(x: valhalla.summary.max_lon, y: valhalla.summary.max_lat), + ); + Self { + mode, + duration: valhalla.summary.time, + distance: valhalla.summary.length, + bounds, + distance_units: valhalla.units, + legs: valhalla + .legs + .iter() + .map(|v_leg| Leg::from_valhalla(v_leg, mode)) + .collect(), + } + } + + fn from_otp(itinerary: &otp_api::Itinerary, mode: TravelMode) -> crate::Result { + // OTP responses are always in meters + let distance_meters: f64 = itinerary.legs.iter().map(|l| l.distance).sum(); + let Ok(legs): std::result::Result, _> = + itinerary.legs.iter().map(Leg::from_otp).collect() + else { + return Err(Error::server("failed to parse legs")); + }; + + let mut legs_iter = legs.iter(); + let Some(first_leg) = legs_iter.next() else { + return Err(Error::server("itinerary had no legs")); + }; + let Ok(Some(mut itinerary_bounds)) = first_leg.bounding_rect() else { + return Err(Error::server("first leg has no bounding_rect")); + }; + for leg in legs_iter { + let Ok(Some(leg_bounds)) = leg.bounding_rect() else { + return Err(Error::server("leg has no bounding_rect")); + }; + extend_bounds(&mut itinerary_bounds, &leg_bounds); + } + Ok(Self { + duration: itinerary.duration as f64, + mode, + distance: distance_meters / 1000.0, + distance_units: DistanceUnit::Kilometers, + bounds: itinerary_bounds, + legs, + }) + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +struct Leg { + /// encoded polyline. 1e-6 scale, (lat, lon) + geometry: String, + + /// Some transit agencies have a color associated with their routes + route_color: Option, + + /// Which mode is this leg of the journey? + mode: TravelMode, + + maneuvers: Option>, +} + +// Eventually we might want to coalesce this into something not valhalla specific +// but for now we only use it for valhalla trips +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Maneuver { + pub instruction: String, + pub cost: f64, + pub begin_shape_index: u64, + pub end_shape_index: u64, + pub highway: Option, + pub length: f64, + pub street_names: Option>, + pub time: f64, + pub travel_mode: String, + pub travel_type: String, + pub r#type: u64, + pub verbal_post_transition_instruction: Option, + pub verbal_pre_transition_instruction: Option, + pub verbal_succinct_transition_instruction: Option, +} + +impl Maneuver { + fn from_valhalla(valhalla: valhalla_api::Maneuver) -> Self { + Self { + instruction: valhalla.instruction, + cost: valhalla.cost, + begin_shape_index: valhalla.begin_shape_index, + end_shape_index: valhalla.end_shape_index, + highway: valhalla.highway, + length: valhalla.length, + street_names: valhalla.street_names, + time: valhalla.time, + travel_mode: valhalla.travel_mode, + travel_type: valhalla.travel_type, + r#type: valhalla.r#type, + verbal_post_transition_instruction: valhalla.verbal_post_transition_instruction, + verbal_pre_transition_instruction: valhalla.verbal_pre_transition_instruction, + verbal_succinct_transition_instruction: valhalla.verbal_succinct_transition_instruction, + } + } +} + +impl Leg { + const GEOMETRY_PRECISION: u32 = 6; + + fn decoded_geometry(&self) -> std::result::Result { + decode_polyline(&self.geometry, Self::GEOMETRY_PRECISION) + } + + fn bounding_rect(&self) -> std::result::Result, String> { + let line_string = self.decoded_geometry()?; + Ok(line_string.bounding_rect()) + } + + fn from_otp(otp: &otp_api::Leg) -> std::result::Result { + let line = decode_polyline(&otp.leg_geometry.points, 5)?; + let geometry = polyline::encode_coordinates(line, Self::GEOMETRY_PRECISION)?; + + Ok(Self { + geometry, + route_color: otp.route_color.clone(), + mode: otp.mode.into(), + maneuvers: None, + }) + } + + fn from_valhalla(valhalla: &valhalla_api::Leg, travel_mode: TravelMode) -> Self { + Self { + geometry: valhalla.shape.clone(), + route_color: None, + mode: travel_mode, + maneuvers: Some( + valhalla + .maneuvers + .iter() + .cloned() + .map(Maneuver::from_valhalla) + .collect(), + ), + } + } +} + +// Comma separated list of travel modes +#[derive(Debug, Serialize, PartialEq, Eq, Clone)] +struct TravelModes(Vec); + +impl<'de> Deserialize<'de> for TravelModes { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + struct CommaSeparatedVecVisitor; + + impl<'de> Visitor<'de> for CommaSeparatedVecVisitor { + type Value = TravelModes; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a comma-separated string") + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: de::Error, + { + let modes = value + .split(',') + .map(|s| TravelMode::deserialize(s.into_deserializer())) + .collect::>()?; + Ok(TravelModes(modes)) + } + } + + deserializer.deserialize_str(CommaSeparatedVecVisitor) + } +} + +fn serialize_rect_to_lng_lat( + rect: &Rect, + serializer: S, +) -> std::result::Result { + let mut struct_serializer = serializer.serialize_struct("BBox", 2)?; + struct_serializer.serialize_field("min", &[rect.min().x, rect.min().y])?; + struct_serializer.serialize_field("max", &[rect.max().x, rect.max().y])?; + struct_serializer.end() +} + +impl actix_web::Responder for PlanResponseOk { + type Body = actix_web::body::BoxBody; + + fn respond_to(self, _req: &HttpRequest) -> actix_web::HttpResponse { + let mut response = HttpResponseBuilder::new(actix_web::http::StatusCode::OK); + response.content_type("application/json"); + response.json(self) + } +} + +#[get("/v4/plan")] +pub async fn get_plan( + query: web::Query, + req: HttpRequest, + app_state: web::Data, +) -> std::result::Result { + let Some(primary_mode) = query.mode.0.first() else { + return Err(PlanResponseErr::from(Error::user("mode is required"))); + }; + + let distance_units = query + .preferred_distance_units + .unwrap_or(DistanceUnit::Kilometers); + + // TODO: Handle bus+bike if bike is first, for now all our clients are responsible for enforcing this + match primary_mode { + TravelMode::Transit => { + let Some(mut router_url) = app_state + .otp_cluster() + .find_router_url(query.from_place, query.to_place) + else { + Err( + Error::user("Transit directions not available for this area.") + .error_type(ErrorType::ThisTransitAreaNotCovered), + )? + }; + + // if we end up building this manually rather than passing it through, we'll need to be sure + // to handle the bike+bus case + router_url.set_query(Some(req.query_string())); + log::debug!( + "found matching router. Forwarding request to: {}", + router_url + ); + + let otp_response: reqwest::Response = reqwest::get(router_url).await.map_err(|e| { + log::error!("error while fetching from otp service: {e}"); + PlanResponseErr::from(Error::server(e)) + })?; + if !otp_response.status().is_success() { + log::warn!( + "upstream HTTP Error from otp service: {}", + otp_response.status() + ) + } + + let mut response = HttpResponseBuilder::new(otp_response.status()); + debug_assert_eq!( + otp_response + .headers() + .get(HeaderName::from_static("content-type")), + Some(&HeaderValue::from_str("application/json").unwrap()) + ); + response.content_type("application/json"); + + let otp_plan_response: otp_api::PlanResponse = + otp_response.json().await.map_err(|e| { + log::error!("error while parsing otp response: {e}"); + PlanResponseErr::from(Error::server(e)) + })?; + + let plan_response = PlanResponseOk::from_otp(*primary_mode, otp_plan_response)?; + Ok(plan_response) + } + other => { + debug_assert!(query.mode.0.len() == 1, "valhalla only supports one mode"); + + let mode = match other { + TravelMode::Transit => unreachable!("handled above"), + TravelMode::Bicycle => valhalla_api::ModeCosting::Bicycle, + TravelMode::Car => valhalla_api::ModeCosting::Auto, + TravelMode::Walk => valhalla_api::ModeCosting::Pedestrian, + }; + + // route?json={%22locations%22:[{%22lat%22:47.575837,%22lon%22:-122.339414},{%22lat%22:47.651048,%22lon%22:-122.347234}],%22costing%22:%22auto%22,%22alternates%22:3,%22units%22:%22miles%22} + let router_url = app_state.valhalla_router().plan_url( + query.from_place, + query.to_place, + mode, + query.num_itineraries, + distance_units, + )?; + let valhalla_response: reqwest::Response = + reqwest::get(router_url).await.map_err(|e| { + log::error!("error while fetching from valhalla service: {e}"); + PlanResponseErr::from(Error::server(e)) + })?; + if !valhalla_response.status().is_success() { + log::warn!( + "upstream HTTP Error from valhalla service: {}", + valhalla_response.status() + ) + } + + let mut response = HttpResponseBuilder::new(valhalla_response.status()); + debug_assert_eq!( + valhalla_response + .headers() + .get(HeaderName::from_static("content-type")), + Some(&HeaderValue::from_str("application/json;charset=utf-8").unwrap()) + ); + response.content_type("application/json;charset=utf-8"); + + let valhalla_route_response: valhalla_api::ValhallaRouteResponseResult = + valhalla_response.json().await.map_err(|e| { + log::error!("error while parsing valhalla response: {e}"); + PlanResponseErr::from(Error::server(e)) + })?; + + let plan_response = + PlanResponseOk::from_valhalla(*primary_mode, valhalla_route_response)?; + Ok(plan_response) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + use serde_json::Value; + use std::fs::File; + use std::io::BufReader; + + #[test] + fn from_valhalla() { + let stubbed_response = + File::open("tests/fixtures/requests/valhalla_route_walk.json").unwrap(); + let valhalla: valhalla_api::RouteResponse = + serde_json::from_reader(BufReader::new(stubbed_response)).unwrap(); + + let valhalla_response_result = valhalla_api::ValhallaRouteResponseResult::Ok(valhalla); + let plan_response = + PlanResponseOk::from_valhalla(TravelMode::Walk, valhalla_response_result).unwrap(); + assert_eq!(plan_response.plan.itineraries.len(), 3); + + // itineraries + let first_itinerary = &plan_response.plan.itineraries[0]; + assert_eq!(first_itinerary.mode, TravelMode::Walk); + assert_relative_eq!(first_itinerary.distance, 9.148); + assert_relative_eq!(first_itinerary.duration, 6488.443); + assert_relative_eq!( + first_itinerary.bounds, + Rect::new( + geo::coord!(x: -122.347201, y: 47.575663), + geo::coord!(x: -122.335618, y: 47.651047) + ) + ); + + // legs + assert_eq!(first_itinerary.legs.len(), 1); + let first_leg = &first_itinerary.legs[0]; + let geometry = decode_polyline(&first_leg.geometry, 6).unwrap(); + assert_relative_eq!( + geometry.0[0], + geo::coord!(x: -122.33922, y: 47.57583), + epsilon = 1e-4 + ); + assert_eq!(first_leg.route_color, None); + assert_eq!(first_leg.mode, TravelMode::Walk); + let maneuvers = first_leg.maneuvers.as_ref().unwrap(); + assert_eq!(maneuvers.len(), 21); + } + + #[test] + fn from_otp() { + let stubbed_response = + File::open("tests/fixtures/requests/opentripplanner_plan_transit.json").unwrap(); + let otp: otp_api::PlanResponse = + serde_json::from_reader(BufReader::new(stubbed_response)).unwrap(); + let plan_response = PlanResponseOk::from_otp(TravelMode::Transit, otp).unwrap(); + + let itineraries = plan_response.plan.itineraries; + assert_eq!(itineraries.len(), 5); + + // itineraries + let first_itinerary = &itineraries[0]; + assert_eq!(first_itinerary.mode, TravelMode::Transit); + assert_relative_eq!(first_itinerary.distance, 10.69944); + assert_relative_eq!(first_itinerary.duration, 3273.0); + + // legs + assert_eq!(first_itinerary.legs.len(), 7); + let first_leg = &first_itinerary.legs[0]; + let geometry = polyline::decode_polyline(&first_leg.geometry, 6).unwrap(); + assert_relative_eq!( + geometry.0[0], + geo::coord!(x: -122.33922, y: 47.57583), + epsilon = 1e-4 + ); + + assert_eq!(first_leg.route_color, None); + assert_eq!(first_leg.mode, TravelMode::Walk); + + let fourth_leg = &first_itinerary.legs[3]; + assert_eq!(fourth_leg.route_color, Some("28813F".to_string())); + assert_eq!(fourth_leg.mode, TravelMode::Transit); + } + + #[test] + fn test_maneuver_from_valhalla_json() { + // deserialize a maneuver from a JSON string + let json = r#" + { + "begin_shape_index": 0, + "cost": 246.056, + "end_shape_index": 69, + "highway": true, + "instruction": "Drive northeast on Fauntleroy Way Southwest.", + "length": 2.218, + "street_names": [ + "Fauntleroy Way Southwest" + ], + "time": 198.858, + "travel_mode": "drive", + "travel_type": "car", + "type": 2, + "verbal_post_transition_instruction": "Continue for 2 miles.", + "verbal_pre_transition_instruction": "Drive northeast on Fauntleroy Way Southwest.", + "verbal_succinct_transition_instruction": "Drive northeast." + }"#; + + let valhalla_maneuver: valhalla_api::Maneuver = serde_json::from_str(json).unwrap(); + assert_eq!(valhalla_maneuver.r#type, 2); + assert_eq!( + valhalla_maneuver.instruction, + "Drive northeast on Fauntleroy Way Southwest." + ); + + let maneuver = Maneuver::from_valhalla(valhalla_maneuver); + let actual = serde_json::to_string(&maneuver).unwrap(); + dbg!(&actual); + // parse the JSON string back into an Object Value + let actual_object: Value = serde_json::from_str(&actual).unwrap(); + + let expected_object = serde_json::json!({ + "beginShapeIndex": 0, + "cost": 246.056, + "endShapeIndex": 69, + "highway": true, + "instruction": "Drive northeast on Fauntleroy Way Southwest.", + "length": 2.218, + "streetNames": ["Fauntleroy Way Southwest"], + "time": 198.858, + "travelMode": "drive", + "travelType": "car", + "type": 2, + "verbalPostTransitionInstruction": "Continue for 2 miles.", + "verbalPreTransitionInstruction": "Drive northeast on Fauntleroy Way Southwest.", + "verbalSuccinctTransitionInstruction": "Drive northeast." + }); + + assert_eq!(actual_object, expected_object); + } + + #[test] + fn error_from_valhalla() { + let json = serde_json::json!({ + "error_code": 154, + "error": "Path distance exceeds the max distance limit: 200000 meters", + "status_code": 400, + "status": "Bad Request" + }) + .to_string(); + + let valhalla_error: valhalla_api::RouteResponseError = serde_json::from_str(&json).unwrap(); + let plan_error = PlanResponseErr::from(valhalla_error); + assert_eq!(plan_error.error.status_code, 400); + assert_eq!(plan_error.error.error_code, 2154); + } +} diff --git a/services/travelmux/src/bin/travelmux-server/main.rs b/services/travelmux/src/bin/travelmux-server/main.rs index af67d0cac..9fea7bf30 100644 --- a/services/travelmux/src/bin/travelmux-server/main.rs +++ b/services/travelmux/src/bin/travelmux-server/main.rs @@ -3,7 +3,7 @@ use url::Url; use std::env; -use travelmux::api::{health, v2, v3, AppState}; +use travelmux::api::{self, AppState}; use travelmux::Result; #[actix_web::main] @@ -48,10 +48,11 @@ async fn main() -> Result<()> { App::new() .wrap(Logger::default()) .app_data(web::Data::new(app_state.clone())) - .service(v2::plan::get_plan) - .service(v3::plan::get_plan) - .service(health::get_ready) - .service(health::get_alive) + .service(api::v2::plan::get_plan) + .service(api::v3::plan::get_plan) + .service(api::v4::plan::get_plan) + .service(api::health::get_ready) + .service(api::health::get_alive) }) .bind(("0.0.0.0", port))? .run() From de08350049cc880f5651579a3d5c4dbfd39627ee Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Fri, 5 Apr 2024 10:39:52 -0700 Subject: [PATCH 2/4] [travelmux] NBD: breaking code into modules without changing behavior --- services/travelmux/src/api/v2/plan.rs | 1 - services/travelmux/src/api/v3/plan.rs | 1 - services/travelmux/src/api/v4/error.rs | 215 ++++++++++++++++++++++ services/travelmux/src/api/v4/mod.rs | 3 + services/travelmux/src/api/v4/plan.rs | 235 +------------------------ services/travelmux/src/util.rs | 14 +- services/travelmux/tests/v2_plan.rs | 2 - 7 files changed, 239 insertions(+), 232 deletions(-) create mode 100644 services/travelmux/src/api/v4/error.rs diff --git a/services/travelmux/src/api/v2/plan.rs b/services/travelmux/src/api/v2/plan.rs index 160396b2c..118c6cc08 100644 --- a/services/travelmux/src/api/v2/plan.rs +++ b/services/travelmux/src/api/v2/plan.rs @@ -556,7 +556,6 @@ mod tests { let maneuver = Maneuver::from_valhalla(valhalla_maneuver); let actual = serde_json::to_string(&maneuver).unwrap(); - dbg!(&actual); // parse the JSON string back into an Object Value let actual_object: Value = serde_json::from_str(&actual).unwrap(); diff --git a/services/travelmux/src/api/v3/plan.rs b/services/travelmux/src/api/v3/plan.rs index 9ae166fb3..f7b53d404 100644 --- a/services/travelmux/src/api/v3/plan.rs +++ b/services/travelmux/src/api/v3/plan.rs @@ -693,7 +693,6 @@ mod tests { let maneuver = Maneuver::from_valhalla(valhalla_maneuver); let actual = serde_json::to_string(&maneuver).unwrap(); - dbg!(&actual); // parse the JSON string back into an Object Value let actual_object: Value = serde_json::from_str(&actual).unwrap(); diff --git a/services/travelmux/src/api/v4/error.rs b/services/travelmux/src/api/v4/error.rs new file mode 100644 index 000000000..b823a7e0f --- /dev/null +++ b/services/travelmux/src/api/v4/error.rs @@ -0,0 +1,215 @@ +use crate::otp::otp_api; +use crate::valhalla::valhalla_api; +use crate::{Error, TravelMode}; +use actix_web::HttpResponseBuilder; +use serde::{Deserialize, Serialize}; + +use super::Plan; +use crate::error::ErrorType; +use actix_web::body::BoxBody; +use actix_web::HttpResponse; +use std::fmt; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UnboxedPlanResponseErr { + pub error: PlanError, + // The raw response from the upstream OTP /plan service + #[serde(rename = "_otp")] + _otp: Option, + + // The raw response from the upstream Valhalla /route service + #[serde(rename = "_valhalla")] + _valhalla: Option, +} +pub type PlanResponseErr = Box; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanError { + pub status_code: u16, + pub error_code: u32, + pub message: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlanResponseOk { + pub(crate) plan: Plan, + + // The raw response from the upstream OTP /plan service + #[serde(rename = "_otp")] + _otp: Option, + + // The raw response from the upstream Valhalla /route service + #[serde(rename = "_valhalla")] + _valhalla: Option, +} + +impl From for PlanResponseErr { + fn from(value: valhalla_api::RouteResponseError) -> Self { + Self::new(UnboxedPlanResponseErr { + error: (&value).into(), + _valhalla: Some(value), + _otp: None, + }) + } +} + +impl From for PlanResponseErr { + fn from(value: otp_api::PlanError) -> Self { + Self::new(UnboxedPlanResponseErr { + error: (&value).into(), + _valhalla: None, + _otp: Some(value), + }) + } +} + +impl From<&valhalla_api::RouteResponseError> for PlanError { + fn from(value: &valhalla_api::RouteResponseError) -> Self { + PlanError { + status_code: value.status_code, + error_code: value.error_code + 2000, + message: value.error.clone(), + } + } +} + +impl fmt::Display for PlanResponseErr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "status_code: {}, error_code: {}, message: {}", + self.error.status_code, self.error.error_code, self.error.message + ) + } +} + +impl std::error::Error for PlanResponseErr {} + +impl From for PlanResponseErr { + fn from(value: Error) -> Self { + Self::new(UnboxedPlanResponseErr { + error: value.into(), + _valhalla: None, + _otp: None, + }) + } +} + +impl From for PlanError { + fn from(value: Error) -> Self { + let error_code = value.error_type as u32; + debug_assert!(error_code < 2000); + debug_assert!(error_code > 1000); + match value.error_type { + ErrorType::ThisTransitAreaNotCovered => Self { + status_code: 400, + error_code, + message: value.source.to_string(), + }, + ErrorType::User => Self { + status_code: 400, + error_code, + message: value.source.to_string(), + }, + ErrorType::Server => Self { + status_code: 500, + error_code, + message: value.source.to_string(), + }, + } + } +} + +impl From<&otp_api::PlanError> for PlanError { + fn from(value: &otp_api::PlanError) -> Self { + Self { + // This might be overzealous, but anecdotally, I haven't encountered any 500ish + // errors with OTP surfaced in this way yet + status_code: 400, + error_code: value.id, + message: value.msg.clone(), + } + } +} + +impl actix_web::ResponseError for PlanResponseErr { + fn status_code(&self) -> actix_web::http::StatusCode { + self.error.status_code.try_into().unwrap_or_else(|e| { + log::error!( + "invalid status code: {}, err: {e:?}", + self.error.status_code + ); + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR + }) + } + + fn error_response(&self) -> HttpResponse { + HttpResponseBuilder::new(self.status_code()) + .content_type("application/json") + .json(self) + } +} + +impl PlanResponseOk { + pub fn from_otp( + mode: TravelMode, + mut otp: otp_api::PlanResponse, + ) -> Result { + if let Some(otp_error) = otp.error { + return Err(otp_error.into()); + } + + otp.plan + .itineraries + .sort_by(|a, b| a.end_time.cmp(&b.end_time)); + + let itineraries_result: crate::Result> = otp + .plan + .itineraries + .iter() + .map(|itinerary: &otp_api::Itinerary| { + crate::api::v4::plan::Itinerary::from_otp(itinerary, mode) + }) + .collect(); + + let itineraries = itineraries_result?; + + Ok(PlanResponseOk { + plan: Plan { itineraries }, + _otp: Some(otp), + _valhalla: None, + }) + } + + pub fn from_valhalla( + mode: TravelMode, + valhalla: valhalla_api::ValhallaRouteResponseResult, + ) -> Result { + let valhalla = match valhalla { + valhalla_api::ValhallaRouteResponseResult::Ok(valhalla) => valhalla, + valhalla_api::ValhallaRouteResponseResult::Err(err) => return Err(err), + }; + + let mut itineraries = vec![crate::api::v4::plan::Itinerary::from_valhalla( + &valhalla.trip, + mode, + )]; + if let Some(alternates) = &valhalla.alternates { + for alternate in alternates { + itineraries.push(crate::api::v4::plan::Itinerary::from_valhalla( + &alternate.trip, + mode, + )); + } + } + + Ok(PlanResponseOk { + plan: Plan { itineraries }, + _otp: None, + _valhalla: Some(valhalla), + }) + } +} diff --git a/services/travelmux/src/api/v4/mod.rs b/services/travelmux/src/api/v4/mod.rs index 7764a5c30..0f8b781c2 100644 --- a/services/travelmux/src/api/v4/mod.rs +++ b/services/travelmux/src/api/v4/mod.rs @@ -1 +1,4 @@ +mod error; pub mod plan; + +pub use plan::Plan; diff --git a/services/travelmux/src/api/v4/plan.rs b/services/travelmux/src/api/v4/plan.rs index dac199967..695560c70 100644 --- a/services/travelmux/src/api/v4/plan.rs +++ b/services/travelmux/src/api/v4/plan.rs @@ -3,13 +3,14 @@ use geo::algorithm::BoundingRect; use geo::geometry::{LineString, Point, Rect}; use polyline::decode_polyline; use reqwest::header::{HeaderName, HeaderValue}; -use serde::de::IntoDeserializer; -use serde::ser::SerializeStruct; -use serde::{de, de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; +use serde::{de, de::IntoDeserializer, de::Visitor, Deserialize, Deserializer, Serialize}; use std::fmt; +use super::error::{PlanResponseErr, PlanResponseOk}; use crate::api::AppState; +use crate::error::ErrorType; use crate::otp::otp_api; +use crate::util::serialize_rect_to_lng_lat; use crate::util::{deserialize_point_from_lat_lon, extend_bounds}; use crate::valhalla::valhalla_api; use crate::{DistanceUnit, Error, TravelMode}; @@ -32,222 +33,15 @@ pub struct PlanQuery { preferred_distance_units: Option, } -use crate::error::ErrorType; -use error::PlanResponseErr; - -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -struct PlanResponseOk { - plan: Plan, - - // The raw response from the upstream OTP /plan service - #[serde(rename = "_otp")] - _otp: Option, - - // The raw response from the upstream Valhalla /route service - #[serde(rename = "_valhalla")] - _valhalla: Option, -} - -mod error { - use super::*; - use crate::error::ErrorType; - use actix_web::body::BoxBody; - use actix_web::HttpResponse; - use std::fmt; - - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - pub struct UnboxedPlanResponseErr { - pub error: PlanError, - // The raw response from the upstream OTP /plan service - #[serde(rename = "_otp")] - _otp: Option, - - // The raw response from the upstream Valhalla /route service - #[serde(rename = "_valhalla")] - _valhalla: Option, - } - pub type PlanResponseErr = Box; - - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - pub struct PlanError { - pub status_code: u16, - pub error_code: u32, - pub message: String, - } - - impl From for PlanResponseErr { - fn from(value: valhalla_api::RouteResponseError) -> Self { - Self::new(UnboxedPlanResponseErr { - error: (&value).into(), - _valhalla: Some(value), - _otp: None, - }) - } - } - - impl From for PlanResponseErr { - fn from(value: otp_api::PlanError) -> Self { - Self::new(UnboxedPlanResponseErr { - error: (&value).into(), - _valhalla: None, - _otp: Some(value), - }) - } - } - - impl From<&valhalla_api::RouteResponseError> for PlanError { - fn from(value: &valhalla_api::RouteResponseError) -> Self { - PlanError { - status_code: value.status_code, - error_code: value.error_code + 2000, - message: value.error.clone(), - } - } - } - - impl fmt::Display for PlanResponseErr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "status_code: {}, error_code: {}, message: {}", - self.error.status_code, self.error.error_code, self.error.message - ) - } - } - - impl std::error::Error for PlanResponseErr {} - - impl From for PlanResponseErr { - fn from(value: Error) -> Self { - Self::new(UnboxedPlanResponseErr { - error: value.into(), - _valhalla: None, - _otp: None, - }) - } - } - - impl From for PlanError { - fn from(value: Error) -> Self { - let error_code = value.error_type as u32; - debug_assert!(error_code < 2000); - debug_assert!(error_code > 1000); - match value.error_type { - ErrorType::ThisTransitAreaNotCovered => Self { - status_code: 400, - error_code, - message: value.source.to_string(), - }, - ErrorType::User => Self { - status_code: 400, - error_code, - message: value.source.to_string(), - }, - ErrorType::Server => Self { - status_code: 500, - error_code, - message: value.source.to_string(), - }, - } - } - } - - impl From<&otp_api::PlanError> for PlanError { - fn from(value: &otp_api::PlanError) -> Self { - Self { - // This might be overzealous, but anecdotally, I haven't encountered any 500ish - // errors with OTP surfaced in this way yet - status_code: 400, - error_code: value.id, - message: value.msg.clone(), - } - } - } - - impl actix_web::ResponseError for PlanResponseErr { - fn status_code(&self) -> actix_web::http::StatusCode { - self.error.status_code.try_into().unwrap_or_else(|e| { - log::error!( - "invalid status code: {}, err: {e:?}", - self.error.status_code - ); - actix_web::http::StatusCode::INTERNAL_SERVER_ERROR - }) - } - - fn error_response(&self) -> HttpResponse { - HttpResponseBuilder::new(self.status_code()) - .content_type("application/json") - .json(self) - } - } - - impl PlanResponseOk { - pub fn from_otp( - mode: TravelMode, - mut otp: otp_api::PlanResponse, - ) -> std::result::Result { - if let Some(otp_error) = otp.error { - return Err(otp_error.into()); - } - - otp.plan - .itineraries - .sort_by(|a, b| a.end_time.cmp(&b.end_time)); - - let itineraries_result: crate::Result> = otp - .plan - .itineraries - .iter() - .map(|itinerary: &otp_api::Itinerary| Itinerary::from_otp(itinerary, mode)) - .collect(); - - let itineraries = itineraries_result?; - - Ok(PlanResponseOk { - plan: Plan { itineraries }, - _otp: Some(otp), - _valhalla: None, - }) - } - - pub fn from_valhalla( - mode: TravelMode, - valhalla: valhalla_api::ValhallaRouteResponseResult, - ) -> std::result::Result { - let valhalla = match valhalla { - valhalla_api::ValhallaRouteResponseResult::Ok(valhalla) => valhalla, - valhalla_api::ValhallaRouteResponseResult::Err(err) => return Err(err), - }; - - let mut itineraries = vec![Itinerary::from_valhalla(&valhalla.trip, mode)]; - if let Some(alternates) = &valhalla.alternates { - for alternate in alternates { - itineraries.push(Itinerary::from_valhalla(&alternate.trip, mode)); - } - } - - Ok(PlanResponseOk { - plan: Plan { itineraries }, - _otp: None, - _valhalla: Some(valhalla), - }) - } - } -} - #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] -struct Plan { - itineraries: Vec, +pub struct Plan { + pub(crate) itineraries: Vec, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] -struct Itinerary { +pub struct Itinerary { mode: TravelMode, duration: f64, distance: f64, @@ -258,7 +52,7 @@ struct Itinerary { } impl Itinerary { - fn from_valhalla(valhalla: &valhalla_api::Trip, mode: TravelMode) -> Self { + pub fn from_valhalla(valhalla: &valhalla_api::Trip, mode: TravelMode) -> Self { let bounds = Rect::new( geo::coord!(x: valhalla.summary.min_lon, y: valhalla.summary.min_lat), geo::coord!(x: valhalla.summary.max_lon, y: valhalla.summary.max_lat), @@ -277,7 +71,7 @@ impl Itinerary { } } - fn from_otp(itinerary: &otp_api::Itinerary, mode: TravelMode) -> crate::Result { + pub fn from_otp(itinerary: &otp_api::Itinerary, mode: TravelMode) -> crate::Result { // OTP responses are always in meters let distance_meters: f64 = itinerary.legs.iter().map(|l| l.distance).sum(); let Ok(legs): std::result::Result, _> = @@ -442,16 +236,6 @@ impl<'de> Deserialize<'de> for TravelModes { } } -fn serialize_rect_to_lng_lat( - rect: &Rect, - serializer: S, -) -> std::result::Result { - let mut struct_serializer = serializer.serialize_struct("BBox", 2)?; - struct_serializer.serialize_field("min", &[rect.min().x, rect.min().y])?; - struct_serializer.serialize_field("max", &[rect.max().x, rect.max().y])?; - struct_serializer.end() -} - impl actix_web::Responder for PlanResponseOk { type Body = actix_web::body::BoxBody; @@ -693,7 +477,6 @@ mod tests { let maneuver = Maneuver::from_valhalla(valhalla_maneuver); let actual = serde_json::to_string(&maneuver).unwrap(); - dbg!(&actual); // parse the JSON string back into an Object Value let actual_object: Value = serde_json::from_str(&actual).unwrap(); diff --git a/services/travelmux/src/util.rs b/services/travelmux/src/util.rs index 8f6508aec..0aa8df4ab 100644 --- a/services/travelmux/src/util.rs +++ b/services/travelmux/src/util.rs @@ -1,5 +1,5 @@ use geo::{Point, Rect}; -use serde::{Deserialize, Deserializer}; +use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serializer}; use std::time::Duration; pub fn deserialize_point_from_lat_lon<'de, D>(deserializer: D) -> Result @@ -44,7 +44,7 @@ pub fn serialize_duration_as_seconds( serializer: S, ) -> Result where - S: serde::Serializer, + S: Serializer, { serializer.serialize_u64(duration.as_secs()) } @@ -60,3 +60,13 @@ pub fn extend_bounds(bounds: &mut Rect, extension: &Rect) { ); std::mem::swap(bounds, &mut new_bounds); } + +pub fn serialize_rect_to_lng_lat( + rect: &Rect, + serializer: S, +) -> Result { + let mut struct_serializer = serializer.serialize_struct("BBox", 2)?; + struct_serializer.serialize_field("min", &[rect.min().x, rect.min().y])?; + struct_serializer.serialize_field("max", &[rect.max().x, rect.max().y])?; + struct_serializer.end() +} diff --git a/services/travelmux/tests/v2_plan.rs b/services/travelmux/tests/v2_plan.rs index 180a8f19d..b9cbacf66 100644 --- a/services/travelmux/tests/v2_plan.rs +++ b/services/travelmux/tests/v2_plan.rs @@ -14,7 +14,6 @@ mod integration_tests { let response = reqwest::blocking::get(url).unwrap(); let status = response.status(); if !status.is_success() { - dbg!(&response); let body = response.text().unwrap(); panic!("status was: {status}, body: {body}"); } @@ -48,7 +47,6 @@ mod integration_tests { let response = reqwest::blocking::get(url).unwrap(); let status = response.status(); if !status.is_success() { - dbg!(&response); let body = response.text().unwrap(); panic!("status was: {status}, body: {body}"); } From 09d1322b934dbe3010dfd0ddfbfe7ba01e445035 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Fri, 5 Apr 2024 13:03:27 -0700 Subject: [PATCH 3/4] [travelmux] plan/v4 api returns transit plan Note 1: though the frontend is "using" the /v4 API, it's still peaking into the raw _otp response. Note 2: I removed OtpTravelMode.Train - it seems like it never existed. Was I hallucinating? --- .../frontend/www-app/src/models/Itinerary.ts | 1 - .../src/services/OpenTripPlannerAPI.ts | 1 - .../www-app/src/services/TravelmuxClient.ts | 2 +- services/travelmux/Cargo.lock | 12 + services/travelmux/Cargo.toml | 1 + services/travelmux/src/api/v2/plan.rs | 6 +- services/travelmux/src/api/v3/plan.rs | 5 +- services/travelmux/src/api/v4/plan.rs | 272 ++++++++++++++---- services/travelmux/src/otp/otp_api.rs | 64 +++++ .../travelmux/src/valhalla/valhalla_api.rs | 56 +++- 10 files changed, 356 insertions(+), 64 deletions(-) diff --git a/services/frontend/www-app/src/models/Itinerary.ts b/services/frontend/www-app/src/models/Itinerary.ts index 4846698ae..01fe8be4c 100644 --- a/services/frontend/www-app/src/models/Itinerary.ts +++ b/services/frontend/www-app/src/models/Itinerary.ts @@ -167,7 +167,6 @@ export class ItineraryLeg { case OTPMode.Bus: case OTPMode.Transit: return '🚍'; - case OTPMode.Train: case OTPMode.Rail: return '🚆'; case OTPMode.Subway: diff --git a/services/frontend/www-app/src/services/OpenTripPlannerAPI.ts b/services/frontend/www-app/src/services/OpenTripPlannerAPI.ts index 810af8346..02539dc6e 100644 --- a/services/frontend/www-app/src/services/OpenTripPlannerAPI.ts +++ b/services/frontend/www-app/src/services/OpenTripPlannerAPI.ts @@ -13,7 +13,6 @@ export enum OTPMode { Gondola = 'GONDOLA', Rail = 'RAIL', Subway = 'SUBWAY', - Train = 'TRAIN', Tram = 'TRAM', Transit = 'TRANSIT', Walk = 'WALK', diff --git a/services/frontend/www-app/src/services/TravelmuxClient.ts b/services/frontend/www-app/src/services/TravelmuxClient.ts index bc5c5a0ab..aff4b4919 100644 --- a/services/frontend/www-app/src/services/TravelmuxClient.ts +++ b/services/frontend/www-app/src/services/TravelmuxClient.ts @@ -116,7 +116,7 @@ export class TravelmuxClient { const query = new URLSearchParams(params).toString(); - const response = await fetch('/travelmux/v3/plan?' + query); + const response = await fetch('/travelmux/v4/plan?' + query); if (response.ok) { const travelmuxResponseJson: TravelmuxPlanResponse = diff --git a/services/travelmux/Cargo.lock b/services/travelmux/Cargo.lock index d2f144b6d..2ecf0e7e4 100644 --- a/services/travelmux/Cargo.lock +++ b/services/travelmux/Cargo.lock @@ -1472,6 +1472,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2e6b945e9d3df726b65d6ee24060aff8e3533d431f677a9695db04eff9dfdb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.57", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1755,6 +1766,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_repr", "url", "wkt", ] diff --git a/services/travelmux/Cargo.toml b/services/travelmux/Cargo.toml index 1810fe65c..f803f655e 100644 --- a/services/travelmux/Cargo.toml +++ b/services/travelmux/Cargo.toml @@ -19,6 +19,7 @@ polyline = "0.10.1" reqwest = { version = "0.11.13", features = ["json", "stream"] } serde = { version = "1.0.152", features = ["derive"] } serde_json = "1.0.91" +serde_repr = "0.1.18" url = "2.4.0" wkt = "0.10.3" diff --git a/services/travelmux/src/api/v2/plan.rs b/services/travelmux/src/api/v2/plan.rs index 118c6cc08..467d74d1c 100644 --- a/services/travelmux/src/api/v2/plan.rs +++ b/services/travelmux/src/api/v2/plan.rs @@ -218,6 +218,8 @@ struct Leg { maneuvers: Option>, } +pub type ManeuverType = valhalla_api::ManeuverType; + // Eventually we might want to coalesce this into something not valhalla specific // but for now we only use it for valhalla trips #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] @@ -233,7 +235,7 @@ pub struct Maneuver { pub time: f64, pub travel_mode: String, pub travel_type: String, - pub r#type: u64, + pub r#type: ManeuverType, pub verbal_post_transition_instruction: Option, pub verbal_pre_transition_instruction: Option, pub verbal_succinct_transition_instruction: Option, @@ -548,7 +550,7 @@ mod tests { }"#; let valhalla_maneuver: valhalla_api::Maneuver = serde_json::from_str(json).unwrap(); - assert_eq!(valhalla_maneuver.r#type, 2); + assert_eq!(valhalla_maneuver.r#type, ManeuverType::StartRight); assert_eq!( valhalla_maneuver.instruction, "Drive northeast on Fauntleroy Way Southwest." diff --git a/services/travelmux/src/api/v3/plan.rs b/services/travelmux/src/api/v3/plan.rs index f7b53d404..59b7a6aef 100644 --- a/services/travelmux/src/api/v3/plan.rs +++ b/services/travelmux/src/api/v3/plan.rs @@ -33,6 +33,7 @@ pub struct PlanQuery { } use crate::error::ErrorType; +use crate::valhalla::valhalla_api::ManeuverType; use error::PlanResponseErr; #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] @@ -340,7 +341,7 @@ pub struct Maneuver { pub time: f64, pub travel_mode: String, pub travel_type: String, - pub r#type: u64, + pub r#type: ManeuverType, pub verbal_post_transition_instruction: Option, pub verbal_pre_transition_instruction: Option, pub verbal_succinct_transition_instruction: Option, @@ -685,7 +686,7 @@ mod tests { }"#; let valhalla_maneuver: valhalla_api::Maneuver = serde_json::from_str(json).unwrap(); - assert_eq!(valhalla_maneuver.r#type, 2); + assert_eq!(valhalla_maneuver.r#type, ManeuverType::StartRight); assert_eq!( valhalla_maneuver.instruction, "Drive northeast on Fauntleroy Way Southwest." diff --git a/services/travelmux/src/api/v4/plan.rs b/services/travelmux/src/api/v4/plan.rs index 695560c70..ae30ffde3 100644 --- a/services/travelmux/src/api/v4/plan.rs +++ b/services/travelmux/src/api/v4/plan.rs @@ -10,9 +10,11 @@ use super::error::{PlanResponseErr, PlanResponseOk}; use crate::api::AppState; use crate::error::ErrorType; use crate::otp::otp_api; +use crate::otp::otp_api::RelativeDirection; use crate::util::serialize_rect_to_lng_lat; use crate::util::{deserialize_point_from_lat_lon, extend_bounds}; use crate::valhalla::valhalla_api; +use crate::valhalla::valhalla_api::ManeuverType; use crate::{DistanceUnit, Error, TravelMode}; #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] @@ -43,7 +45,12 @@ pub struct Plan { #[serde(rename_all = "camelCase")] pub struct Itinerary { mode: TravelMode, + /// seconds duration: f64, + /// unix millis, UTC + start_time: u64, + /// unix millis, UTC + end_time: u64, distance: f64, distance_units: DistanceUnit, #[serde(serialize_with = "serialize_rect_to_lng_lat")] @@ -57,8 +64,21 @@ impl Itinerary { geo::coord!(x: valhalla.summary.min_lon, y: valhalla.summary.min_lat), geo::coord!(x: valhalla.summary.max_lon, y: valhalla.summary.max_lat), ); + + use std::time::Duration; + fn time_since_epoch() -> Duration { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time is after unix epoch") + } + + let start_time = time_since_epoch().as_millis() as u64; + let end_time = start_time + (valhalla.summary.time * 1000.0) as u64; Self { mode, + start_time, + end_time, duration: valhalla.summary.time, distance: valhalla.summary.length, bounds, @@ -95,6 +115,8 @@ impl Itinerary { } Ok(Self { duration: itinerary.duration as f64, + start_time: itinerary.start_time, + end_time: itinerary.end_time, mode, distance: distance_meters / 1000.0, distance_units: DistanceUnit::Kilometers, @@ -110,13 +132,25 @@ struct Leg { /// encoded polyline. 1e-6 scale, (lat, lon) geometry: String, - /// Some transit agencies have a color associated with their routes - route_color: Option, - /// Which mode is this leg of the journey? mode: TravelMode, - maneuvers: Option>, + #[serde(flatten)] + mode_leg: ModeLeg, +} + +// Should we just pass the entire OTP leg? +type TransitLeg = otp_api::Leg; + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +enum ModeLeg { + // REVIEW: rename? There is a boolean field for OTP called TransitLeg + #[serde(rename = "transitLeg")] + Transit(TransitLeg), + + #[serde(rename = "maneuvers")] + NonTransit(Vec), } // Eventually we might want to coalesce this into something not valhalla specific @@ -124,39 +158,51 @@ struct Leg { #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Maneuver { - pub instruction: String, - pub cost: f64, - pub begin_shape_index: u64, - pub end_shape_index: u64, - pub highway: Option, - pub length: f64, - pub street_names: Option>, - pub time: f64, - pub travel_mode: String, - pub travel_type: String, - pub r#type: u64, + pub instruction: Option, + // pub cost: f64, + // pub begin_shape_index: u64, + // pub end_shape_index: u64, + // pub highway: Option, + // pub length: f64, + // pub street_names: Option>, + // pub time: f64, + // pub travel_mode: String, + // pub travel_type: String, + pub r#type: ManeuverType, pub verbal_post_transition_instruction: Option, - pub verbal_pre_transition_instruction: Option, - pub verbal_succinct_transition_instruction: Option, + // pub verbal_pre_transition_instruction: Option, + // pub verbal_succinct_transition_instruction: Option, } impl Maneuver { fn from_valhalla(valhalla: valhalla_api::Maneuver) -> Self { Self { - instruction: valhalla.instruction, - cost: valhalla.cost, - begin_shape_index: valhalla.begin_shape_index, - end_shape_index: valhalla.end_shape_index, - highway: valhalla.highway, - length: valhalla.length, - street_names: valhalla.street_names, - time: valhalla.time, - travel_mode: valhalla.travel_mode, - travel_type: valhalla.travel_type, + instruction: Some(valhalla.instruction), r#type: valhalla.r#type, verbal_post_transition_instruction: valhalla.verbal_post_transition_instruction, - verbal_pre_transition_instruction: valhalla.verbal_pre_transition_instruction, - verbal_succinct_transition_instruction: valhalla.verbal_succinct_transition_instruction, + } + } + + fn from_otp(otp: otp_api::Step) -> Self { + let maneuver_type = match otp.relative_direction { + RelativeDirection::Depart => ManeuverType::Start, + RelativeDirection::HardLeft => ManeuverType::SharpLeft, + RelativeDirection::Left => ManeuverType::Left, + RelativeDirection::SlightlyLeft => ManeuverType::SlightLeft, + RelativeDirection::Continue => ManeuverType::Continue, + RelativeDirection::SlightlyRight => ManeuverType::SlightRight, + RelativeDirection::Right => ManeuverType::Right, + RelativeDirection::HardRight => ManeuverType::SharpRight, + RelativeDirection::CircleClockwise => ManeuverType::RoundaboutEnter, + RelativeDirection::CircleCounterclockwise => ManeuverType::RoundaboutEnter, + RelativeDirection::Elevator => ManeuverType::ElevatorEnter, + RelativeDirection::UturnLeft => ManeuverType::UturnLeft, + RelativeDirection::UturnRight => ManeuverType::UturnRight, + }; + Self { + instruction: None, + r#type: maneuver_type, + verbal_post_transition_instruction: None, } } } @@ -177,20 +223,30 @@ impl Leg { let line = decode_polyline(&otp.leg_geometry.points, 5)?; let geometry = polyline::encode_coordinates(line, Self::GEOMETRY_PRECISION)?; + let mode_leg = match otp.mode { + otp_api::TransitMode::Walk + | otp_api::TransitMode::Bicycle + | otp_api::TransitMode::Car => { + ModeLeg::NonTransit(otp.steps.iter().cloned().map(Maneuver::from_otp).collect()) + } + _ => { + // assume everything else is transit + ModeLeg::Transit(otp.clone()) + } + }; + Ok(Self { geometry, - route_color: otp.route_color.clone(), mode: otp.mode.into(), - maneuvers: None, + mode_leg, }) } fn from_valhalla(valhalla: &valhalla_api::Leg, travel_mode: TravelMode) -> Self { Self { geometry: valhalla.shape.clone(), - route_color: None, mode: travel_mode, - maneuvers: Some( + mode_leg: ModeLeg::NonTransit( valhalla .maneuvers .iter() @@ -260,7 +316,8 @@ pub async fn get_plan( .preferred_distance_units .unwrap_or(DistanceUnit::Kilometers); - // TODO: Handle bus+bike if bike is first, for now all our clients are responsible for enforcing this + // TODO: Handle bus+bike if bike is first, for now all our clients are responsible for enforcing that + // the "primary" mode appears first. match primary_mode { TravelMode::Transit => { let Some(mut router_url) = app_state @@ -366,12 +423,12 @@ pub async fn get_plan( mod tests { use super::*; use approx::assert_relative_eq; - use serde_json::Value; + use serde_json::{json, Value}; use std::fs::File; use std::io::BufReader; #[test] - fn from_valhalla() { + fn parse_from_valhalla() { let stubbed_response = File::open("tests/fixtures/requests/valhalla_route_walk.json").unwrap(); let valhalla: valhalla_api::RouteResponse = @@ -404,14 +461,17 @@ mod tests { geo::coord!(x: -122.33922, y: 47.57583), epsilon = 1e-4 ); - assert_eq!(first_leg.route_color, None); + + let ModeLeg::NonTransit(maneuvers) = &first_leg.mode_leg else { + panic!("unexpected transit leg") + }; + assert_eq!(first_leg.mode, TravelMode::Walk); - let maneuvers = first_leg.maneuvers.as_ref().unwrap(); assert_eq!(maneuvers.len(), 21); } #[test] - fn from_otp() { + fn parse_from_otp() { let stubbed_response = File::open("tests/fixtures/requests/opentripplanner_plan_transit.json").unwrap(); let otp: otp_api::PlanResponse = @@ -437,16 +497,129 @@ mod tests { epsilon = 1e-4 ); - assert_eq!(first_leg.route_color, None); assert_eq!(first_leg.mode, TravelMode::Walk); + let ModeLeg::NonTransit(maneuvers) = &first_leg.mode_leg else { + panic!("expected non-transit leg") + }; + assert_eq!(maneuvers.len(), 4); + assert_eq!(maneuvers[0].r#type, ManeuverType::Start); + assert_eq!(maneuvers[1].r#type, ManeuverType::Left); let fourth_leg = &first_itinerary.legs[3]; - assert_eq!(fourth_leg.route_color, Some("28813F".to_string())); assert_eq!(fourth_leg.mode, TravelMode::Transit); + let ModeLeg::Transit(transit_leg) = &fourth_leg.mode_leg else { + panic!("expected transit leg") + }; + assert_eq!(transit_leg.route_color, Some("28813F".to_string())); + } + + #[test] + fn serialize_response_from_otp() { + let stubbed_response = + File::open("tests/fixtures/requests/opentripplanner_plan_transit.json").unwrap(); + let otp: otp_api::PlanResponse = + serde_json::from_reader(BufReader::new(stubbed_response)).unwrap(); + let plan_response = PlanResponseOk::from_otp(TravelMode::Transit, otp).unwrap(); + let response = serde_json::to_string(&plan_response).unwrap(); + let parsed_response: serde_json::Value = serde_json::from_str(&response).unwrap(); + let response_object = parsed_response.as_object().expect("expected Object"); + let plan = response_object + .get("plan") + .unwrap() + .as_object() + .expect("expected Object"); + let first_itinerary = plan + .get("itineraries") + .unwrap() + .as_array() + .unwrap() + .get(0) + .unwrap(); + let legs = first_itinerary.get("legs").unwrap().as_array().unwrap(); + + // Verify walking leg + let first_leg = legs.get(0).unwrap().as_object().unwrap(); + let mode = first_leg.get("mode").unwrap().as_str().unwrap(); + assert_eq!(mode, "WALK"); + assert!(first_leg.get("transitLeg").is_none()); + let maneuvers = first_leg.get("maneuvers").unwrap().as_array().unwrap(); + let first_maneuver = maneuvers.get(0).unwrap(); + let expected_maneuver = json!({ + "type": 1, + "instruction": null, + "verbalPostTransitionInstruction": null + }); + assert_eq!(first_maneuver, &expected_maneuver); + + // Verify Transit leg + let fourth_leg = legs.get(3).unwrap().as_object().unwrap(); + let mode = fourth_leg.get("mode").unwrap().as_str().unwrap(); + assert_eq!(mode, "TRANSIT"); + assert!(fourth_leg.get("maneuvers").is_none()); + let transit_leg = fourth_leg + .get("transitLeg") + .unwrap() + .as_object() + .expect("json object"); + dbg!(transit_leg); + + // Brittle: If the fixtures are updated, these values might change due to time of day or whatever. + assert_eq!( + transit_leg.get("agencyName").unwrap().as_str().unwrap(), + "Sound Transit" + ); + + assert_eq!( + transit_leg.get("route").unwrap().as_str().unwrap(), + "Northgate - Angle Lake" + ); + } + + #[test] + fn serialize_response_from_valhalla() { + let stubbed_response = + File::open("tests/fixtures/requests/valhalla_route_walk.json").unwrap(); + let valhalla: valhalla_api::RouteResponse = + serde_json::from_reader(BufReader::new(stubbed_response)).unwrap(); + + let valhalla_response_result = valhalla_api::ValhallaRouteResponseResult::Ok(valhalla); + let plan_response = + PlanResponseOk::from_valhalla(TravelMode::Walk, valhalla_response_result).unwrap(); + + let response = serde_json::to_string(&plan_response).unwrap(); + let parsed_response: serde_json::Value = serde_json::from_str(&response).unwrap(); + let response_object = parsed_response.as_object().expect("expected Object"); + let plan = response_object + .get("plan") + .unwrap() + .as_object() + .expect("expected Object"); + let first_itinerary = plan + .get("itineraries") + .unwrap() + .as_array() + .unwrap() + .get(0) + .unwrap(); + let legs = first_itinerary.get("legs").unwrap().as_array().unwrap(); + + // Verify walking leg + let first_leg = legs.get(0).unwrap().as_object().unwrap(); + let mode = first_leg.get("mode").unwrap().as_str().unwrap(); + assert_eq!(mode, "WALK"); + assert!(first_leg.get("transitLeg").is_none()); + let maneuvers = first_leg.get("maneuvers").unwrap().as_array().unwrap(); + let first_maneuver = maneuvers.get(0).unwrap(); + let expected_maneuver = json!({ + "type": 2, + "instruction": "Walk south on East Marginal Way South.", + "verbalPostTransitionInstruction": "Continue for 20 meters." + }); + assert_eq!(first_maneuver, &expected_maneuver); } #[test] - fn test_maneuver_from_valhalla_json() { + fn parse_maneuver_from_valhalla_json() { // deserialize a maneuver from a JSON string let json = r#" { @@ -469,7 +642,7 @@ mod tests { }"#; let valhalla_maneuver: valhalla_api::Maneuver = serde_json::from_str(json).unwrap(); - assert_eq!(valhalla_maneuver.r#type, 2); + assert_eq!(valhalla_maneuver.r#type, ManeuverType::StartRight); assert_eq!( valhalla_maneuver.instruction, "Drive northeast on Fauntleroy Way Southwest." @@ -481,27 +654,16 @@ mod tests { let actual_object: Value = serde_json::from_str(&actual).unwrap(); let expected_object = serde_json::json!({ - "beginShapeIndex": 0, - "cost": 246.056, - "endShapeIndex": 69, - "highway": true, "instruction": "Drive northeast on Fauntleroy Way Southwest.", - "length": 2.218, - "streetNames": ["Fauntleroy Way Southwest"], - "time": 198.858, - "travelMode": "drive", - "travelType": "car", "type": 2, "verbalPostTransitionInstruction": "Continue for 2 miles.", - "verbalPreTransitionInstruction": "Drive northeast on Fauntleroy Way Southwest.", - "verbalSuccinctTransitionInstruction": "Drive northeast." }); assert_eq!(actual_object, expected_object); } #[test] - fn error_from_valhalla() { + fn parse_error_from_valhalla() { let json = serde_json::json!({ "error_code": 154, "error": "Path distance exceeds the max distance limit: 200000 meters", diff --git a/services/travelmux/src/otp/otp_api.rs b/services/travelmux/src/otp/otp_api.rs index 15477b165..da94baf97 100644 --- a/services/travelmux/src/otp/otp_api.rs +++ b/services/travelmux/src/otp/otp_api.rs @@ -52,8 +52,12 @@ pub struct Plan { #[derive(Debug, Deserialize, Serialize, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct Itinerary { + /// seconds pub duration: u64, pub legs: Vec, + /// unix mills, UTC + pub start_time: u64, + /// unix mills, UTC pub end_time: u64, #[serde(flatten)] @@ -67,11 +71,71 @@ pub struct Leg { pub distance: f64, pub leg_geometry: LegGeometry, pub route_color: Option, + // Present, but empty, for transit legs. Non-empty for non-transit legs. + pub steps: Vec, + #[serde(flatten)] + pub extra: HashMap, +} +#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Step { + /// The distance in meters that this step takes. + pub distance: f64, + /// The relative direction of this step. + pub relative_direction: RelativeDirection, + /// The name of the street. + pub street_name: String, + /// The absolute direction of this step. + pub absolute_direction: AbsoluteDirection, + /// When exiting a highway or traffic circle, the exit name/number. + pub exit: Option, + /// Indicates whether or not a street changes direction at an intersection. + pub stay_on: Option, + /// This step is on an open area, such as a plaza or train platform, and thus the directions should say something like "cross" + pub area: Option, + /// The name of this street was generated by the system, so we should only display it once, and generally just display right/left directions + pub bogus_name: Option, + /// The longitude of start of the step + pub lon: f64, + /// The latitude of start of the step + pub lat: f64, + // pub alerts: Vec, #[serde(flatten)] pub extra: HashMap, } +#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AbsoluteDirection { + North, + Northeast, + East, + Southeast, + South, + Southwest, + West, + Northwest, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RelativeDirection { + Depart, + HardLeft, + Left, + SlightlyLeft, + Continue, + SlightlyRight, + Right, + HardRight, + CircleClockwise, + CircleCounterclockwise, + Elevator, + UturnLeft, + UturnRight, +} + #[derive(Debug, Deserialize, Serialize, PartialEq, Clone, Copy)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum TransitMode { diff --git a/services/travelmux/src/valhalla/valhalla_api.rs b/services/travelmux/src/valhalla/valhalla_api.rs index f00ad3f1b..434969bd4 100644 --- a/services/travelmux/src/valhalla/valhalla_api.rs +++ b/services/travelmux/src/valhalla/valhalla_api.rs @@ -1,6 +1,7 @@ use crate::DistanceUnit; use geo::Point; use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use std::collections::HashMap; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -104,7 +105,7 @@ pub struct Maneuver { pub time: f64, pub travel_mode: String, pub travel_type: String, - pub r#type: u64, + pub r#type: ManeuverType, pub verbal_post_transition_instruction: Option, // Usually, but not always, present - e.g. missing from: // { @@ -123,6 +124,57 @@ pub struct Maneuver { pub verbal_succinct_transition_instruction: Option, } +// Corresponding to valhalla/src/odin/maneuver.cc +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize_repr, Deserialize_repr)] +#[repr(i32)] // Using u8 assuming all values fit into an 8-bit unsigned integer +#[non_exhaustive] +pub enum ManeuverType { + None = 0, + Start = 1, + StartRight = 2, + StartLeft = 3, + Destination = 4, + DestinationRight = 5, + DestinationLeft = 6, + Becomes = 7, + Continue = 8, + SlightRight = 9, + Right = 10, + SharpRight = 11, + UturnRight = 12, + UturnLeft = 13, + SharpLeft = 14, + Left = 15, + SlightLeft = 16, + RampStraight = 17, + RampRight = 18, + RampLeft = 19, + ExitRight = 20, + ExitLeft = 21, + StayStraight = 22, + StayRight = 23, + StayLeft = 24, + Merge = 25, + RoundaboutEnter = 26, + RoundaboutExit = 27, + FerryEnter = 28, + FerryExit = 29, + Transit = 30, + TransitTransfer = 31, + TransitRemainOn = 32, + TransitConnectionStart = 33, + TransitConnectionTransfer = 34, + TransitConnectionDestination = 35, + PostTransitConnectionDestination = 36, + MergeRight = 37, + MergeLeft = 38, + ElevatorEnter = 39, + StepsEnter = 40, + EscalatorEnter = 41, + BuildingEnter = 42, + BuildingExit = 43, +} + impl From for LngLat { fn from(value: Point) -> Self { Self { @@ -160,7 +212,7 @@ mod tests { }"#; let maneuver: Maneuver = serde_json::from_str(json).unwrap(); - assert_eq!(maneuver.r#type, 2); + assert_eq!(maneuver.r#type, ManeuverType::StartRight); assert_eq!( maneuver.instruction, "Drive northeast on Fauntleroy Way Southwest." From ac93f0d691c9f40733e0bb84d1878d62b67152c2 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Sat, 6 Apr 2024 10:26:37 -0700 Subject: [PATCH 4/4] [travelmux] retire v2 plan api --- services/travelmux/src/api/mod.rs | 1 - services/travelmux/src/api/v2/mod.rs | 1 - services/travelmux/src/api/v2/plan.rs | 583 ------------------ .../src/bin/travelmux-server/main.rs | 1 - 4 files changed, 586 deletions(-) delete mode 100644 services/travelmux/src/api/v2/mod.rs delete mode 100644 services/travelmux/src/api/v2/plan.rs diff --git a/services/travelmux/src/api/mod.rs b/services/travelmux/src/api/mod.rs index 9df3af70f..d72f98603 100644 --- a/services/travelmux/src/api/mod.rs +++ b/services/travelmux/src/api/mod.rs @@ -2,6 +2,5 @@ mod app_state; pub use app_state::AppState; pub mod health; -pub mod v2; pub mod v3; pub mod v4; diff --git a/services/travelmux/src/api/v2/mod.rs b/services/travelmux/src/api/v2/mod.rs deleted file mode 100644 index 7764a5c30..000000000 --- a/services/travelmux/src/api/v2/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod plan; diff --git a/services/travelmux/src/api/v2/plan.rs b/services/travelmux/src/api/v2/plan.rs deleted file mode 100644 index 467d74d1c..000000000 --- a/services/travelmux/src/api/v2/plan.rs +++ /dev/null @@ -1,583 +0,0 @@ -use actix_web::{get, web, HttpRequest, HttpResponseBuilder, Responder}; -use geo::algorithm::BoundingRect; -use geo::geometry::{LineString, Point, Rect}; -use polyline::decode_polyline; -use reqwest::header::{HeaderName, HeaderValue}; -use serde::de::IntoDeserializer; -use serde::ser::SerializeStruct; -use serde::{de, de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; -use std::fmt; - -use crate::api::AppState; -use crate::otp::otp_api; -use crate::util::{deserialize_point_from_lat_lon, extend_bounds}; -use crate::valhalla::valhalla_api; -use crate::{DistanceUnit, Error, TravelMode}; - -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PlanQuery { - #[serde(deserialize_with = "deserialize_point_from_lat_lon")] - to_place: Point, - - #[serde(deserialize_with = "deserialize_point_from_lat_lon")] - from_place: Point, - - num_itineraries: u32, - - mode: TravelModes, - - /// Ignored by OTP - transit trips will always be metric. - /// Examine the `distance_units` in the response `Itinerary` to correctly interpret the response. - preferred_distance_units: Option, -} - -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -struct PlanResponse { - plan: Plan, - - // The raw response from the upstream OTP /plan service - #[serde(rename = "_otp")] - _otp: Option, - - // The raw response from the upstream Valhalla /route service - #[serde(rename = "_valhalla")] - _valhalla: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -enum PlanError { - Valhalla(valhalla_api::RouteResponseError), - Travelmux(Error), -} - -impl From for PlanError { - fn from(value: valhalla_api::RouteResponseError) -> Self { - Self::Valhalla(value) - } -} - -impl From for PlanError { - fn from(value: Error) -> Self { - Self::Travelmux(value) - } -} - -#[derive(Debug, Serialize)] -#[serde(untagged)] -enum PlanResult { - Ok(Box), - Err(PlanError), -} - -impl PlanResult { - // used in tests - #[allow(dead_code)] - fn unwrap(self) -> Box { - match self { - PlanResult::Ok(plan) => plan, - PlanResult::Err(err) => panic!("unexpected error: {:?}", err), - } - } -} - -impl PlanResponse { - fn from_otp(mode: TravelMode, mut otp: otp_api::PlanResponse) -> PlanResult { - otp.plan - .itineraries - .sort_by(|a, b| a.end_time.cmp(&b.end_time)); - - let itineraries_result: crate::Result> = otp - .plan - .itineraries - .iter() - .map(|itinerary: &otp_api::Itinerary| Itinerary::from_otp(itinerary, mode)) - .collect(); - - let itineraries = match itineraries_result { - Ok(itineraries) => itineraries, - Err(err) => return PlanResult::Err(err.into()), - }; - - PlanResult::Ok(Box::new(PlanResponse { - plan: Plan { itineraries }, - _otp: Some(otp), - _valhalla: None, - })) - } - - fn from_valhalla( - mode: TravelMode, - valhalla: valhalla_api::ValhallaRouteResponseResult, - ) -> PlanResult { - let valhalla = match valhalla { - valhalla_api::ValhallaRouteResponseResult::Ok(valhalla) => valhalla, - valhalla_api::ValhallaRouteResponseResult::Err(err) => { - return PlanResult::Err(err.into()) - } - }; - - let mut itineraries = vec![Itinerary::from_valhalla(&valhalla.trip, mode)]; - if let Some(alternates) = &valhalla.alternates { - for alternate in alternates { - itineraries.push(Itinerary::from_valhalla(&alternate.trip, mode)); - } - } - - PlanResult::Ok(Box::new(PlanResponse { - plan: Plan { itineraries }, - _otp: None, - _valhalla: Some(valhalla), - })) - } -} - -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -struct Plan { - itineraries: Vec, -} - -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -struct Itinerary { - mode: TravelMode, - duration: f64, - distance: f64, - distance_units: DistanceUnit, - #[serde(serialize_with = "serialize_rect_to_lng_lat")] - bounds: Rect, - legs: Vec, -} - -impl Itinerary { - fn from_valhalla(valhalla: &valhalla_api::Trip, mode: TravelMode) -> Self { - let bounds = Rect::new( - geo::coord!(x: valhalla.summary.min_lon, y: valhalla.summary.min_lat), - geo::coord!(x: valhalla.summary.max_lon, y: valhalla.summary.max_lat), - ); - Self { - mode, - duration: valhalla.summary.time, - distance: valhalla.summary.length, - bounds, - distance_units: valhalla.units, - legs: valhalla - .legs - .iter() - .map(|v_leg| Leg::from_valhalla(v_leg, mode)) - .collect(), - } - } - - fn from_otp(itinerary: &otp_api::Itinerary, mode: TravelMode) -> crate::Result { - // OTP responses are always in meters - let distance_meters: f64 = itinerary.legs.iter().map(|l| l.distance).sum(); - let Ok(legs): Result, _> = itinerary.legs.iter().map(Leg::from_otp).collect() else { - return Err(Error::server("failed to parse legs")); - }; - - let mut legs_iter = legs.iter(); - let Some(first_leg) = legs_iter.next() else { - return Err(Error::server("itinerary had no legs")); - }; - let Ok(Some(mut itinerary_bounds)) = first_leg.bounding_rect() else { - return Err(Error::server("first leg has no bounding_rect")); - }; - for leg in legs_iter { - let Ok(Some(leg_bounds)) = leg.bounding_rect() else { - return Err(Error::server("leg has no bounding_rect")); - }; - extend_bounds(&mut itinerary_bounds, &leg_bounds); - } - Ok(Self { - duration: itinerary.duration as f64, - mode, - distance: distance_meters / 1000.0, - distance_units: DistanceUnit::Kilometers, - bounds: itinerary_bounds, - legs, - }) - } -} - -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -struct Leg { - /// encoded polyline. 1e-6 scale, (lat, lon) - geometry: String, - - /// Some transit agencies have a color associated with their routes - route_color: Option, - - /// Which mode is this leg of the journey? - mode: TravelMode, - - maneuvers: Option>, -} - -pub type ManeuverType = valhalla_api::ManeuverType; - -// Eventually we might want to coalesce this into something not valhalla specific -// but for now we only use it for valhalla trips -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Maneuver { - pub instruction: String, - pub cost: f64, - pub begin_shape_index: u64, - pub end_shape_index: u64, - pub highway: Option, - pub length: f64, - pub street_names: Option>, - pub time: f64, - pub travel_mode: String, - pub travel_type: String, - pub r#type: ManeuverType, - pub verbal_post_transition_instruction: Option, - pub verbal_pre_transition_instruction: Option, - pub verbal_succinct_transition_instruction: Option, -} - -impl Maneuver { - fn from_valhalla(valhalla: valhalla_api::Maneuver) -> Self { - Self { - instruction: valhalla.instruction, - cost: valhalla.cost, - begin_shape_index: valhalla.begin_shape_index, - end_shape_index: valhalla.end_shape_index, - highway: valhalla.highway, - length: valhalla.length, - street_names: valhalla.street_names, - time: valhalla.time, - travel_mode: valhalla.travel_mode, - travel_type: valhalla.travel_type, - r#type: valhalla.r#type, - verbal_post_transition_instruction: valhalla.verbal_post_transition_instruction, - verbal_pre_transition_instruction: valhalla.verbal_pre_transition_instruction, - verbal_succinct_transition_instruction: valhalla.verbal_succinct_transition_instruction, - } - } -} - -impl Leg { - const GEOMETRY_PRECISION: u32 = 6; - - fn decoded_geometry(&self) -> Result { - decode_polyline(&self.geometry, Self::GEOMETRY_PRECISION) - } - - fn bounding_rect(&self) -> Result, String> { - let line_string = self.decoded_geometry()?; - Ok(line_string.bounding_rect()) - } - - fn from_otp(otp: &otp_api::Leg) -> Result { - let line = decode_polyline(&otp.leg_geometry.points, 5)?; - let geometry = polyline::encode_coordinates(line, Self::GEOMETRY_PRECISION)?; - - Ok(Self { - geometry, - route_color: otp.route_color.clone(), - mode: otp.mode.into(), - maneuvers: None, - }) - } - - fn from_valhalla(valhalla: &valhalla_api::Leg, travel_mode: TravelMode) -> Self { - Self { - geometry: valhalla.shape.clone(), - route_color: None, - mode: travel_mode, - maneuvers: Some( - valhalla - .maneuvers - .iter() - .cloned() - .map(Maneuver::from_valhalla) - .collect(), - ), - } - } -} - -// Comma separated list of travel modes -#[derive(Debug, Serialize, PartialEq, Eq, Clone)] -struct TravelModes(Vec); - -impl<'de> Deserialize<'de> for TravelModes { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct CommaSeparatedVecVisitor; - - impl<'de> Visitor<'de> for CommaSeparatedVecVisitor { - type Value = TravelModes; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a comma-separated string") - } - - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - let modes = value - .split(',') - .map(|s| TravelMode::deserialize(s.into_deserializer())) - .collect::>()?; - Ok(TravelModes(modes)) - } - } - - deserializer.deserialize_str(CommaSeparatedVecVisitor) - } -} - -fn serialize_rect_to_lng_lat(rect: &Rect, serializer: S) -> Result { - let mut struct_serializer = serializer.serialize_struct("BBox", 2)?; - struct_serializer.serialize_field("min", &[rect.min().x, rect.min().y])?; - struct_serializer.serialize_field("max", &[rect.max().x, rect.max().y])?; - struct_serializer.end() -} - -#[get("/v2/plan")] -pub async fn get_plan( - query: web::Query, - req: HttpRequest, - app_state: web::Data, -) -> impl Responder { - let Some(primary_mode) = query.mode.0.first() else { - return Err(Error::user("mode is required")); - }; - - let distance_units = query - .preferred_distance_units - .unwrap_or(DistanceUnit::Kilometers); - - // TODO: Handle bus+bike if bike is first, for now all our clients are responsible for enforicing this - match primary_mode { - TravelMode::Transit => { - let Some(mut router_url) = app_state - .otp_cluster() - .find_router_url(query.from_place, query.to_place) - else { - return Err(Error::user("no matching router found")); - }; - - // if we end up building this manually rather than passing it through, we'll need to be sure - // to handle the bike+bus case - router_url.set_query(Some(req.query_string())); - log::debug!( - "found matching router. Forwarding request to: {}", - router_url - ); - - let otp_response: reqwest::Response = reqwest::get(router_url).await?; - if !otp_response.status().is_success() { - log::warn!( - "upstream HTTP Error from otp service: {}", - otp_response.status() - ) - } - - let mut response = HttpResponseBuilder::new(otp_response.status()); - debug_assert_eq!( - otp_response - .headers() - .get(HeaderName::from_static("content-type")), - Some(&HeaderValue::from_str("application/json").unwrap()) - ); - response.content_type("application/json"); - - let otp_plan_response: otp_api::PlanResponse = otp_response.json().await?; - let plan_response = PlanResponse::from_otp(*primary_mode, otp_plan_response); - Ok(response.json(plan_response)) - } - other => { - debug_assert!(query.mode.0.len() == 1, "valhalla only supports one mode"); - - let mode = match other { - TravelMode::Transit => unreachable!("handled above"), - TravelMode::Bicycle => valhalla_api::ModeCosting::Bicycle, - TravelMode::Car => valhalla_api::ModeCosting::Auto, - TravelMode::Walk => valhalla_api::ModeCosting::Pedestrian, - }; - - // route?json={%22locations%22:[{%22lat%22:47.575837,%22lon%22:-122.339414},{%22lat%22:47.651048,%22lon%22:-122.347234}],%22costing%22:%22auto%22,%22alternates%22:3,%22units%22:%22miles%22} - let router_url = app_state.valhalla_router().plan_url( - query.from_place, - query.to_place, - mode, - query.num_itineraries, - distance_units, - )?; - let valhalla_response: reqwest::Response = reqwest::get(router_url).await?; - if !valhalla_response.status().is_success() { - log::warn!( - "upstream HTTP Error from valhalla service: {}", - valhalla_response.status() - ) - } - - let mut response = HttpResponseBuilder::new(valhalla_response.status()); - debug_assert_eq!( - valhalla_response - .headers() - .get(HeaderName::from_static("content-type")), - Some(&HeaderValue::from_str("application/json;charset=utf-8").unwrap()) - ); - response.content_type("application/json;charset=utf-8"); - - let valhalla_route_response: valhalla_api::ValhallaRouteResponseResult = - valhalla_response.json().await?; - let plan_response = PlanResponse::from_valhalla(*primary_mode, valhalla_route_response); - Ok(response.json(plan_response)) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use approx::assert_relative_eq; - use serde_json::Value; - use std::fs::File; - use std::io::BufReader; - - #[test] - fn from_valhalla() { - let stubbed_response = - File::open("tests/fixtures/requests/valhalla_route_walk.json").unwrap(); - let valhalla: valhalla_api::RouteResponse = - serde_json::from_reader(BufReader::new(stubbed_response)).unwrap(); - - let valhalla_response_result = valhalla_api::ValhallaRouteResponseResult::Ok(valhalla); - let plan_result = PlanResponse::from_valhalla(TravelMode::Walk, valhalla_response_result); - let plan_response = match plan_result { - PlanResult::Ok(plan_response) => plan_response, - PlanResult::Err(err) => panic!("unexpected error: {:?}", err), - }; - assert_eq!(plan_response.plan.itineraries.len(), 3); - - // itineraries - let first_itinerary = &plan_response.plan.itineraries[0]; - assert_eq!(first_itinerary.mode, TravelMode::Walk); - assert_relative_eq!(first_itinerary.distance, 9.148); - assert_relative_eq!(first_itinerary.duration, 6488.443); - assert_relative_eq!( - first_itinerary.bounds, - Rect::new( - geo::coord!(x: -122.347201, y: 47.575663), - geo::coord!(x: -122.335618, y: 47.651047) - ) - ); - - // legs - assert_eq!(first_itinerary.legs.len(), 1); - let first_leg = &first_itinerary.legs[0]; - let geometry = decode_polyline(&first_leg.geometry, 6).unwrap(); - assert_relative_eq!( - geometry.0[0], - geo::coord!(x: -122.33922, y: 47.57583), - epsilon = 1e-4 - ); - assert_eq!(first_leg.route_color, None); - assert_eq!(first_leg.mode, TravelMode::Walk); - let maneuvers = first_leg.maneuvers.as_ref().unwrap(); - assert_eq!(maneuvers.len(), 21); - } - - #[test] - fn from_otp() { - let stubbed_response = - File::open("tests/fixtures/requests/opentripplanner_plan_transit.json").unwrap(); - let otp: otp_api::PlanResponse = - serde_json::from_reader(BufReader::new(stubbed_response)).unwrap(); - let plan_response = PlanResponse::from_otp(TravelMode::Transit, otp); - - let itineraries = plan_response.unwrap().plan.itineraries; - assert_eq!(itineraries.len(), 5); - - // itineraries - let first_itinerary = &itineraries[0]; - assert_eq!(first_itinerary.mode, TravelMode::Transit); - assert_relative_eq!(first_itinerary.distance, 10.69944); - assert_relative_eq!(first_itinerary.duration, 3273.0); - - // legs - assert_eq!(first_itinerary.legs.len(), 7); - let first_leg = &first_itinerary.legs[0]; - let geometry = polyline::decode_polyline(&first_leg.geometry, 6).unwrap(); - assert_relative_eq!( - geometry.0[0], - geo::coord!(x: -122.33922, y: 47.57583), - epsilon = 1e-4 - ); - - assert_eq!(first_leg.route_color, None); - assert_eq!(first_leg.mode, TravelMode::Walk); - - let fourth_leg = &first_itinerary.legs[3]; - assert_eq!(fourth_leg.route_color, Some("28813F".to_string())); - assert_eq!(fourth_leg.mode, TravelMode::Transit); - } - - #[test] - fn test_maneuver_from_valhalla_json() { - // deserialize a maneuever from a JSON string - let json = r#" - { - "begin_shape_index": 0, - "cost": 246.056, - "end_shape_index": 69, - "highway": true, - "instruction": "Drive northeast on Fauntleroy Way Southwest.", - "length": 2.218, - "street_names": [ - "Fauntleroy Way Southwest" - ], - "time": 198.858, - "travel_mode": "drive", - "travel_type": "car", - "type": 2, - "verbal_post_transition_instruction": "Continue for 2 miles.", - "verbal_pre_transition_instruction": "Drive northeast on Fauntleroy Way Southwest.", - "verbal_succinct_transition_instruction": "Drive northeast." - }"#; - - let valhalla_maneuver: valhalla_api::Maneuver = serde_json::from_str(json).unwrap(); - assert_eq!(valhalla_maneuver.r#type, ManeuverType::StartRight); - assert_eq!( - valhalla_maneuver.instruction, - "Drive northeast on Fauntleroy Way Southwest." - ); - - let maneuver = Maneuver::from_valhalla(valhalla_maneuver); - let actual = serde_json::to_string(&maneuver).unwrap(); - // parse the JSON string back into an Object Value - let actual_object: Value = serde_json::from_str(&actual).unwrap(); - - let expected_object = serde_json::json!({ - "beginShapeIndex": 0, - "cost": 246.056, - "endShapeIndex": 69, - "highway": true, - "instruction": "Drive northeast on Fauntleroy Way Southwest.", - "length": 2.218, - "streetNames": ["Fauntleroy Way Southwest"], - "time": 198.858, - "travelMode": "drive", - "travelType": "car", - "type": 2, - "verbalPostTransitionInstruction": "Continue for 2 miles.", - "verbalPreTransitionInstruction": "Drive northeast on Fauntleroy Way Southwest.", - "verbalSuccinctTransitionInstruction": "Drive northeast." - }); - - assert_eq!(actual_object, expected_object); - } -} diff --git a/services/travelmux/src/bin/travelmux-server/main.rs b/services/travelmux/src/bin/travelmux-server/main.rs index 9fea7bf30..ea6c9c685 100644 --- a/services/travelmux/src/bin/travelmux-server/main.rs +++ b/services/travelmux/src/bin/travelmux-server/main.rs @@ -48,7 +48,6 @@ async fn main() -> Result<()> { App::new() .wrap(Logger::default()) .app_data(web::Data::new(app_state.clone())) - .service(api::v2::plan::get_plan) .service(api::v3::plan::get_plan) .service(api::v4::plan::get_plan) .service(api::health::get_ready)