railway-provider-motis-0.2.0/.cargo_vcs_info.json0000644000000001640000000000100154650ustar { "git": { "sha1": "a9a6d789d0f3e593124342aa187a9edb5fbf7641" }, "path_in_vcs": "railway-provider-motis" }railway-provider-motis-0.2.0/Cargo.toml0000644000000032530000000000100134650ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" name = "railway-provider-motis" version = "0.2.0" authors = ["Julian Schmidhuber "] build = false autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "Implementation of a Motis client for Railway" readme = "README.md" keywords = [ "railway-backend", "train", "public-transport", ] license = "AGPL-3.0-or-later OR EUPL-1.2" repository = "https://gitlab.com/schmiddi-on-mobile/railway-backend" [lib] name = "railway_provider_motis" path = "src/lib.rs" [dependencies.async-trait] version = "0.1" [dependencies.chrono] version = "0.4" [dependencies.chrono-tz] version = "0.8" [dependencies.motis] version = "1.0" package = "motis-openapi-sdk" [dependencies.rcore] version = "0.1" features = ["reqwest-requester"] package = "railway-core" [dependencies.serde] version = "1.0" features = ["derive"] [dependencies.serde_json] version = "1.0" [dependencies.url] version = "2.5.0" [dev-dependencies.env_logger] version = "0.10.1" [dev-dependencies.tokio] version = "1.35" features = [ "rt-multi-thread", "macros", ] [features] polylines = ["rcore/polylines"] rt-multi-thread = ["rcore/rt-multi-thread"] railway-provider-motis-0.2.0/Cargo.toml.orig000064400000000000000000000022061046102023000171430ustar 00000000000000[package] name = "railway-provider-motis" version = "0.2.0" authors = ["Julian Schmidhuber "] edition = "2021" description = "Implementation of a Motis client for Railway" repository = "https://gitlab.com/schmiddi-on-mobile/railway-backend" license = "AGPL-3.0-or-later OR EUPL-1.2" keywords = ["railway-backend", "train", "public-transport"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] rcore = { package = "railway-core", path = "../railway-core", version = "0.1", features = [ "reqwest-requester" ] } motis = { package = "motis-openapi-sdk", path = "../motis-openapi-sdk", version = "1.0" } serde = { version = "1.0", features = [ "derive" ] } serde_json = "1.0" chrono = { version = "0.4" } chrono-tz = "0.8" url = "2.5.0" async-trait = "0.1" [features] rt-multi-thread = [ "rcore/rt-multi-thread" ] polylines = [ "rcore/polylines" ] [dev-dependencies] tokio = { version = "1.35", features = [ "rt-multi-thread", "macros" ] } env_logger = "0.10.1" rcore = { package = "railway-core", path = "../railway-core", features = [ "reqwest-requester" ] } railway-provider-motis-0.2.0/README.md000064400000000000000000000010601046102023000155300ustar 00000000000000# Railway Motis Provider Implementation of a Motis client for Railway. This crate is part of [railway-backend](https://gitlab.com/schmiddi-on-mobile/railway-backend). You can find a high-level documentation of railway-backend [here](https://gitlab.com/schmiddi-on-mobile/railway-backend/-/tree/main/docs?ref_type=heads). This is mainly useful for the [Transitous](https://transitous.org/) instance. Documentation can be found [here](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/motis-project/motis/refs/heads/master/openapi.yaml). railway-provider-motis-0.2.0/src/error.rs000064400000000000000000000014311046102023000165410ustar 00000000000000use std::fmt::Display; #[derive(Debug)] pub enum Error { Geocoding(motis::apis::Error), Plan(motis::apis::Error), ChronoParsing(chrono::format::ParseError), RefreshJourneyNotFound, } impl Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { match &self { Self::Geocoding(e) => writeln!(f, "failure to geocode: {}", e), Self::Plan(e) => writeln!(f, "failure to plan trip: {}", e), Self::ChronoParsing(e) => writeln!(f, "failed to parse date-time returned: {}", e), Self::RefreshJourneyNotFound => write!(f, "refresh journey not found"), } } } impl std::error::Error for Error {} railway-provider-motis-0.2.0/src/lib.rs000064400000000000000000000470651046102023000161730ustar 00000000000000#![doc = include_str!("../README.md")] mod error; use chrono::{DateTime, Utc}; use error::*; use async_trait::async_trait; use rcore::{ IntermediateLocation, Journey, JourneysOptions, JourneysResponse, Leg, Line, Location, Mode, Operator, Place, Product, ProductsSelection, Provider, Requester, RequesterBuilder, ReqwestRequester, ReqwestRequesterBuilder, Station, Stop, }; use url::Url; use std::borrow::Cow; use std::collections::HashSet; use std::str::FromStr; pub const TRANSITOUS_URL: &str = "https://api.transitous.org/"; #[derive(Clone)] pub struct MotisClient { configuration: motis::apis::configuration::Configuration, } impl MotisClient { pub fn new(url: Url, requester: ReqwestRequesterBuilder) -> Self { Self { configuration: Self::configuration(url, requester), } } fn configuration( url: Url, requester: ReqwestRequesterBuilder, ) -> motis::apis::configuration::Configuration { motis::apis::configuration::Configuration { // XXX: `url.to_string()` does not contain the trailing `/`, but it is required by the OpenAPI generated code. base_path: url.to_string() + "/", user_agent: Some(format!( "{} {} ({})", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_REPOSITORY") )), client: requester.build().inner().clone(), ..Default::default() } } } #[cfg_attr(feature = "rt-multi-thread", async_trait)] #[cfg_attr(not(feature = "rt-multi-thread"), async_trait(?Send))] impl Provider for MotisClient { type Error = Error; async fn journeys( &self, from: rcore::Place, to: rcore::Place, opts: rcore::JourneysOptions, ) -> Result< rcore::JourneysResponse, rcore::Error<::Error, Self::Error>, > { let from_id = match from { Place::Station(s) => s.id, Place::Location(Location::Address { address: _, latitude, longitude, }) => format!("{},{},0", latitude, longitude), Place::Location(Location::Point { id: _, name: _, poi: _, latitude, longitude, }) => format!("{},{},0", latitude, longitude), }; let to_id = match to { Place::Station(s) => s.id, Place::Location(Location::Address { address: _, latitude, longitude, }) => format!("{},{},0", latitude, longitude), Place::Location(Location::Point { id: _, name: _, poi: _, latitude, longitude, }) => format!("{},{},0", latitude, longitude), }; let time = opts .departure .or(opts.arrival) .map(|d| d.with_timezone(&chrono::Utc).format("%FT%TZ").to_string()); let cursor = opts.earlier_than.or(opts.later_than); let transfers = match opts.transfers { rcore::TransferOptions::Unlimited => None, rcore::TransferOptions::Limited(i) => Some(i as i32), }; let result = motis::apis::routing_api::plan( &self.configuration, &from_id, &to_id, true, /* detailed transfers */ /* TODO: While we currently don't need detailed transfer information (e.g. steps), there is currently a bug in MOTIS where setting this to false returns the wrong scheduled time for transfers. */ None, /* via */ None, /* via minimum stay */ time, transfers, None, /* max hours */ Some(opts.transfer_time.num_minutes() as i32), None, /* additional transfer time */ None, /* transfer time factor */ None, /* max matching distance */ None, /* wheelchair */ None, /* use routed transfers */ Some(railway_products_to_motis_mode(opts.products)), None, /* direct modes */ None, /* pre transit modes */ None, /* post transit modes */ None, /* direct rental form factors */ None, /* pre transit rental form factors */ None, /* post transit rental form factors */ None, /* direct rental propulsion types */ None, /* pre transit rental propulsion types */ None, /* post transit rental propulsion types */ None, /* direct rental providers */ None, /* pre transit rental providers */ None, /* post transit rental providers */ Some(opts.results as i32), cursor.as_deref(), None, /* timetable view */ Some(opts.arrival.is_some()), None, /* search window */ None, /* bike transport */ None, /* max pre transit time */ None, /* max post transit time */ None, /* max direct time */ None, /* timeout */ ) .await .map_err(|e| { rcore::Error::<::Error, Self::Error>::Provider( crate::Error::Plan(e), ) })?; Ok(plan_response_to_journeys_response(result)) } async fn locations( &self, opts: rcore::LocationsOptions, ) -> Result< rcore::LocationsResponse, rcore::Error<::Error, Self::Error>, > { let matches = motis::apis::geocode_api::geocode( &self.configuration, &opts.query, opts.language.as_deref(), ) .await .map_err(|e| { rcore::Error::<::Error, Self::Error>::Provider( crate::Error::Geocoding(e), ) })?; Ok(matches.into_iter().map(match_to_place).collect()) } // Note: This method is not guaranteed to find the same journey. But I think this is the best we can do as search.ch does not provide a refresh-API. async fn refresh_journey( &self, journey: &Journey, _opts: rcore::RefreshJourneyOptions, ) -> Result< rcore::RefreshJourneyResponse, rcore::Error<::Error, Self::Error>, > { // Note: Each journey should have at least one leg. let from = &journey.legs[0].origin; let to = &journey.legs[journey.legs.len() - 1].destination; let jopts = JourneysOptions { // In most cases, the journey to refresh is likely the first one. Relax this requirement a bit. results: 3, departure: journey.legs[0].planned_departure, ..Default::default() }; self.journeys(from.clone(), to.clone(), jopts) .await? .journeys .into_iter() .find(|j| j.id == journey.id) .clone() .ok_or(rcore::Error::Provider(Error::RefreshJourneyNotFound)) } } fn plan_response_to_journeys_response(r: motis::models::Plan200Response) -> JourneysResponse { JourneysResponse { earlier_ref: Some(r.previous_page_cursor), later_ref: Some(r.next_page_cursor), journeys: r .itineraries .into_iter() .map(itinerary_to_journey) .collect(), } } fn railway_products_to_motis_mode(p: ProductsSelection) -> Vec { HashSet::from(p) .into_iter() .flat_map(railway_mode_to_motis_mode) .collect::>() .into_iter() .collect() } fn railway_mode_to_motis_mode(p: rcore::Mode) -> Vec { use motis::models::Mode as MMode; match p { Mode::HighSpeedTrain => vec![ MMode::HighspeedRail, MMode::LongDistance, MMode::RegionalFastRail, ], Mode::RegionalTrain => vec![MMode::RegionalRail], Mode::SuburbanTrain => vec![MMode::Metro], Mode::Subway => vec![MMode::Subway], Mode::Tram => vec![MMode::Tram], Mode::Bus => vec![MMode::Bus, MMode::Coach], Mode::Ferry => vec![MMode::Ferry], Mode::Cablecar => vec![], Mode::OnDemand => vec![], Mode::Unknown => vec![ MMode::Walk, MMode::Bike, MMode::Car, MMode::CarParking, MMode::Rental, MMode::Transit, MMode::Airplane, MMode::Rail, MMode::NightRail, MMode::Other, ], } } fn motis_mode_to_mode(m: &motis::models::Mode) -> Mode { match m { motis::models::Mode::Walk => Mode::Unknown, motis::models::Mode::Bike => Mode::Unknown, motis::models::Mode::Car => Mode::Unknown, motis::models::Mode::CarParking => Mode::Unknown, motis::models::Mode::Rental => Mode::Unknown, motis::models::Mode::Transit => Mode::Unknown, motis::models::Mode::Tram => Mode::Tram, motis::models::Mode::Subway => Mode::Subway, motis::models::Mode::Ferry => Mode::Ferry, motis::models::Mode::Airplane => Mode::Unknown, motis::models::Mode::Metro => Mode::SuburbanTrain, motis::models::Mode::Bus => Mode::Bus, motis::models::Mode::Coach => Mode::Bus, motis::models::Mode::Rail => Mode::Unknown, motis::models::Mode::HighspeedRail => Mode::HighSpeedTrain, motis::models::Mode::LongDistance => Mode::HighSpeedTrain, motis::models::Mode::NightRail => Mode::Unknown, motis::models::Mode::RegionalFastRail => Mode::HighSpeedTrain, motis::models::Mode::RegionalRail => Mode::RegionalTrain, motis::models::Mode::Other => Mode::Unknown, } } fn itinerary_to_journey(i: motis::models::Itinerary) -> Journey { let mut legs: Vec<_> = i .legs .into_iter() // When searching via latitude/longitude or when searching for a city returns a latitude/longitude pair, Motis starts and ends with walks, where the origin and destination are named "START" and "END". // While this may make sense when explicitly searching for latitude/longitude, I don't think this makes sense when searching for a city, as this should instead be interpreted "anywhere in the city" in my opinion. // As Railway does not yet support searching for latitude/longitude explicitly either way, remove such legs from itineraries. // XXX: Reconsider the removal when Railway supports latitude/longitude searches. .filter(|l| l.from.name != "START" && l.to.name != "END") .map(motis_leg_to_leg) .collect(); for i in 0..(legs.len() - 1) { let next_leg_departure = legs[i + 1].planned_departure.or(legs[i + 1].departure); let leg = &mut legs[i]; if leg .planned_arrival .or(leg.arrival) .is_some_and(|a| next_leg_departure.is_some_and(|d| d < a)) { leg.reachable = false; } } Journey { id: legs .iter() .flat_map(|l| l.trip_id.as_ref()) .map(|s| &s[..]) .collect(), legs, price: None, } } fn motis_leg_to_leg(l: motis::models::Leg) -> Leg { Leg { origin: motis_place_to_place(&l.from), destination: motis_place_to_place(&l.to), departure: DateTime::::from_str(&l.start_time) .ok() .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)), planned_departure: DateTime::::from_str(&l.scheduled_start_time) .ok() .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)), arrival: DateTime::::from_str(&l.end_time) .ok() .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)), planned_arrival: DateTime::::from_str(&l.scheduled_end_time) .ok() .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)), reachable: true, trip_id: l.trip_id, line: if l.mode != motis::models::Mode::Walk { Some(Line { name: l.route_short_name.clone(), fahrt_nr: None, mode: motis_mode_to_mode(&l.mode), product: Product { mode: motis_mode_to_mode(&l.mode), name: l .route_short_name .clone() .and_then(|n| n.split(' ').next().map(|s| s.to_owned())) .map(Cow::Owned) .unwrap_or(Cow::Borrowed("")), short: l .route_short_name .clone() .and_then(|n| n.split(' ').next().map(|s| s.to_owned())) .map(Cow::Owned) .unwrap_or(Cow::Borrowed("")), }, operator: l .agency_id .and_then(|id| l.agency_name.map(|name| (id, name))) .map(|(id, name)| Operator { id, name }), product_name: l .route_short_name .and_then(|n| n.split(' ').next().map(|s| s.to_owned())), }) } else { None }, direction: l.headsign, arrival_platform: l.to.track.clone(), planned_arrival_platform: l.to.scheduled_track.clone(), departure_platform: l.from.track.clone(), planned_departure_platform: l.from.scheduled_track.clone(), frequency: None, cancelled: false, intermediate_locations: std::iter::once(motis_place_to_intermediate_destination(*l.from)) .chain( l.intermediate_stops .unwrap_or_default() .into_iter() .map(motis_place_to_intermediate_destination), ) .chain(std::iter::once(motis_place_to_intermediate_destination( *l.to, ))) .collect(), load_factor: None, remarks: vec![], walking: l.mode == motis::models::Mode::Walk, transfer: false, distance: l.distance.map(|d| d as u64), } } fn motis_place_to_intermediate_destination(p: motis::models::Place) -> IntermediateLocation { IntermediateLocation::Stop(Stop { place: motis_place_to_place(&p), departure: p .departure .and_then(|d| DateTime::::from_str(&d).ok()) .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)), planned_departure: p .scheduled_departure .and_then(|d| DateTime::::from_str(&d).ok()) .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)), arrival: p .arrival .and_then(|d| DateTime::::from_str(&d).ok()) .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)), planned_arrival: p .scheduled_arrival .and_then(|d| DateTime::::from_str(&d).ok()) .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)), arrival_platform: p.track.clone(), planned_arrival_platform: p.scheduled_track.clone(), departure_platform: p.track, planned_departure_platform: p.scheduled_track, cancelled: false, remarks: vec![], }) } fn motis_place_to_place(p: &motis::models::Place) -> Place { if let Some(id) = &p.stop_id { Place::Station(Station { id: id.clone(), name: Some(p.name.clone()), location: Some(Location::Point { id: Some(id.clone()), name: Some(p.name.clone()), poi: None, latitude: p.lat as f32, longitude: p.lon as f32, }), products: vec![], }) } else { Place::Location(Location::Point { id: None, name: Some(p.name.clone()), poi: None, latitude: p.lat as f32, longitude: p.lon as f32, }) } } fn match_to_place(m: motis::models::model_match::Match) -> Place { match m.r#type { motis::models::model_match::Type::Stop => Place::Station(Station { id: m.id.clone(), name: Some(m.name.clone()), location: Some(Location::Point { id: Some(m.id), name: Some(m.name), poi: None, latitude: m.lat as f32, longitude: m.lon as f32, }), products: vec![], }), motis::models::model_match::Type::Address => Place::Location(Location::Address { address: format!( "{} {}, {}", m.street.unwrap_or_default(), m.house_number.unwrap_or_default(), m.zip.unwrap_or_default() ), latitude: m.lat as f32, longitude: m.lon as f32, }), motis::models::model_match::Type::Place => Place::Location(Location::Point { id: Some(m.id), name: Some(m.name), poi: None, latitude: m.lat as f32, longitude: m.lon as f32, }), } } #[cfg(test)] mod test { use rcore::{JourneysOptions, LocationsOptions, ReqwestRequesterBuilder, Station}; use super::*; pub async fn check_search>( search: S, expected: S, ) -> Result<(), Box> { let client = MotisClient::new( Url::parse(TRANSITOUS_URL).expect("Failed to parse SPLINE_URL"), ReqwestRequesterBuilder::default(), ); let locations = client .locations(LocationsOptions { query: search.as_ref().to_string(), ..Default::default() }) .await?; let results = locations .into_iter() .flat_map(|p| match p { Place::Station(s) => s.name, Place::Location(Location::Address { address, .. }) => Some(address), Place::Location(Location::Point { name, .. }) => name, }) .collect::>(); assert!( results.iter().find(|s| s == &expected.as_ref()).is_some(), "expected {} to be contained in {:#?}", expected.as_ref(), results ); Ok(()) } pub async fn check_journey>( from: S, to: S, ) -> Result<(), Box> { let client = MotisClient::new( Url::parse(TRANSITOUS_URL).expect("Failed to parse SPLINE_URL"), ReqwestRequesterBuilder::default(), ); let journeys = client .journeys( Place::Station(Station { id: from.as_ref().to_string(), ..Default::default() }), Place::Station(Station { id: to.as_ref().to_string(), ..Default::default() }), JourneysOptions::default(), ) .await?; assert!( !journeys.journeys.is_empty(), "expected journey from {} to {} to exist", from.as_ref(), to.as_ref() ); Ok(()) } #[tokio::test] async fn search_munich() -> Result<(), Box> { check_search("München Hb", "München Hbf").await } #[tokio::test] async fn journey_munich_nuremberg() -> Result<(), Box> { check_journey("de-DELFI_de:09162:100", "de-DELFI_de:09564:510").await } }