railway-provider-search-ch-0.1.0/.cargo_vcs_info.json0000644000000001700000000000100161630ustar { "git": { "sha1": "0333950f69474deb120b3ba39113766a949cb0f8" }, "path_in_vcs": "railway-provider-search-ch" }railway-provider-search-ch-0.1.0/Cargo.toml0000644000000026550000000000100141730ustar # 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-search-ch" version = "0.1.0" authors = ["Julian Schmidhuber "] description = "Implementation of the search.ch 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" [dependencies.async-trait] version = "0.1" [dependencies.chrono] version = "0.4" [dependencies.chrono-tz] version = "0.8.6" [dependencies.rcore] version = "0.1" 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.11.3" [dev-dependencies.tokio] version = "1.37" features = [ "rt-multi-thread", "macros", ] [features] polylines = ["rcore/polylines"] rt-multi-thread = ["rcore/rt-multi-thread"] railway-provider-search-ch-0.1.0/Cargo.toml.orig000064400000000000000000000020221046102023000176400ustar 00000000000000[package] name = "railway-provider-search-ch" version = "0.1.0" authors = ["Julian Schmidhuber "] edition = "2021" description = "Implementation of the search.ch 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" } serde = { version = "1.0", features = [ "derive" ] } serde_json = "1.0" chrono = { version = "0.4" } chrono-tz = "0.8.6" url = "2.5.0" async-trait = "0.1" [features] rt-multi-thread = [ "rcore/rt-multi-thread" ] polylines = [ "rcore/polylines" ] [dev-dependencies] tokio = { version = "1.37", features = [ "rt-multi-thread", "macros" ] } env_logger = "0.11.3" rcore = { package = "railway-core", path = "../railway-core", features = [ "hyper-requester" ] } railway-provider-search-ch-0.1.0/README.md000064400000000000000000000003751046102023000162410ustar 00000000000000# Railway search.ch Provider Implementation of the search.ch client for Railway. This crate is part of [railway-backend](https://gitlab.com/schmiddi-on-mobile/railway-backend). Documentation can be found [here](https://search.ch/timetable/api/help). railway-provider-search-ch-0.1.0/src/error.rs000064400000000000000000000007011046102023000172410ustar 00000000000000use std::fmt::Display; #[derive(Debug)] pub enum Error { Json(serde_json::Error), RefreshJourneyNotFound, } impl Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { match &self { Self::Json(e) => write!(f, "json error: {}", e), Self::RefreshJourneyNotFound => write!(f, "refresh journey not found"), } } } impl std::error::Error for Error {} railway-provider-search-ch-0.1.0/src/lib.rs000064400000000000000000000215061046102023000166640ustar 00000000000000#![doc = include_str!("../README.md")] mod error; mod serialize; mod types; use error::Error; use types::*; use async_trait::async_trait; use rcore::{ Journey, JourneysOptions, Location, Mode, ProductsSelection, Provider, Requester, RequesterBuilder, }; use url::Url; use std::collections::{HashMap, HashSet}; pub const API_URL: &str = "https://search.ch/timetable/api"; #[derive(Clone)] pub struct SearchChClient { requester: R, url: Url, } impl SearchChClient { pub fn new>(requester: RB) -> Self { Self { requester: requester.build(), url: Url::parse(API_URL).expect("Failed to parse API_URL"), } } } fn products_to_api_type(selection: ProductsSelection) -> String { HashSet::::from(selection) .into_iter() .flat_map(mode_to_api_type) .fold(String::new(), |acc, m| format!("{},{}", acc, m)) } fn mode_to_api_type(mode: Mode) -> Option<&'static str> { match mode { Mode::HighSpeedTrain => Some("train"), Mode::RegionalTrain => Some("train"), Mode::SuburbanTrain => Some("train"), Mode::Subway => Some("tram"), Mode::Tram => Some("tram"), Mode::Bus => Some("bus"), Mode::Ferry => Some("ship"), Mode::Cablecar => Some("cableway"), Mode::OnDemand => None, Mode::Unknown => None, } } #[cfg_attr(feature = "rt-multi-thread", async_trait)] #[cfg_attr(not(feature = "rt-multi-thread"), async_trait(?Send))] impl Provider for SearchChClient { type Error = Error; async fn journeys( &self, from: rcore::Place, to: rcore::Place, opts: rcore::JourneysOptions, ) -> Result::Error, Self::Error>> { let place_to_id = |p| match p { rcore::Place::Station(s) => s.id, rcore::Place::Location(Location::Address { address, .. }) => address, // TODO: Error when not set rcore::Place::Location(Location::Point { id, name, .. }) => { id.or(name).unwrap_or_default() } }; let time = opts .earlier_than .as_ref() .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) .or_else(|| { opts.later_than .as_ref() .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) }) .or_else(|| opts.departure.as_ref().map(|t| t.fixed_offset())) .or_else(|| opts.arrival.as_ref().map(|t| t.fixed_offset())) .map(|t| t.with_timezone(&chrono_tz::Europe::Zurich)); let time_type = if opts.arrival.is_some() { "arrival" } else { "depart" }; let num = if opts.earlier_than.is_some() { 0 } else { opts.results }; let pre = if opts.earlier_than.is_some() { opts.results } else { 0 }; let transport_types = products_to_api_type(opts.products); let mut url = self.url.clone(); url.path_segments_mut() .expect("API URL cannot-be-a-base") .push("route.json"); url.query_pairs_mut() .append_pair("from", &place_to_id(from)) .append_pair("to", &place_to_id(to)) .append_pair("show_delays", "1") .append_pair("show_trackchanges", "1") .append_pair( "date", &time .as_ref() .map(|t| t.format("%d.%m.%Y").to_string()) .unwrap_or_else(|| "today".to_owned()), ) .append_pair( "time", &time .as_ref() .map(|t| t.format("%H:%M").to_string()) .unwrap_or_else(|| "now".to_owned()), ) .append_pair("time_type", time_type) .append_pair("num", &num.to_string()) .append_pair("pre", &pre.to_string()) .append_pair("transportation_types", &transport_types); let response = self .requester .get(&url, &[], HashMap::new()) .await .map_err(rcore::Error::Request)?; let response: SearchChJourneysResponse = serde_json::from_slice(&response) .map_err(|e| rcore::Error::Provider(Error::Json(e)))?; Ok(response.into()) } /// Note: search.ch does not support setting the search language or the number of results async fn locations( &self, opts: rcore::LocationsOptions, ) -> Result::Error, Self::Error>> { let mut url = self.url.clone(); url.path_segments_mut() .expect("API URL cannot-be-a-base") .push("completion.json"); url.query_pairs_mut() .append_pair("term", &opts.query) .append_pair("show_ids", "1") .append_pair("show_coordinates", "1"); let response = self .requester .get(&url, &[], HashMap::new()) .await .map_err(rcore::Error::Request)?; let response: SearchChLocationsResponse = serde_json::from_slice(&response) .map_err(|e| rcore::Error::Provider(Error::Json(e)))?; Ok(response.into_iter().map(Into::into).collect()) } // Note: search.ch does not support any of those options. // 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::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)) } } #[cfg(test)] mod test { use rcore::{ HyperRustlsRequesterBuilder, JourneysOptions, Location, LocationsOptions, Place, Station, }; use super::*; pub async fn check_search>( search: S, expected: S, ) -> Result<(), Box> { let client = SearchChClient::new(HyperRustlsRequesterBuilder::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 = SearchChClient::new(HyperRustlsRequesterBuilder::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_luzern() -> Result<(), Box> { check_search("Lu", "Luzern").await } #[tokio::test] async fn journey_winterthur_lausanne() -> Result<(), Box> { check_journey("8506000", "8501120").await } } railway-provider-search-ch-0.1.0/src/serialize.rs000064400000000000000000000032021046102023000200760ustar 00000000000000// From . pub(crate) mod time { use chrono::NaiveDateTime; use serde::{self, Deserialize, Deserializer, Serializer}; const FORMAT: &str = "%Y-%m-%d %H:%M:%S"; pub fn serialize(date: &NaiveDateTime, serializer: S) -> Result where S: Serializer, { let s = format!("{}", date.format(FORMAT)); serializer.serialize_str(&s) } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; let dt = NaiveDateTime::parse_from_str(&s, FORMAT).map_err(serde::de::Error::custom)?; Ok(dt) } } pub(crate) mod optional_time { use chrono::NaiveDateTime; use serde::{self, Deserialize, Deserializer, Serializer}; const FORMAT: &str = "%Y-%m-%d %H:%M:%S"; pub fn serialize(date: &Option, serializer: S) -> Result where S: Serializer, { if let Some(date) = date { let s = format!("{}", date.format(FORMAT)); serializer.serialize_str(&s) } else { serializer.serialize_none() } } pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let s: Option = Option::deserialize(deserializer)?; if let Some(s) = s { let dt = NaiveDateTime::parse_from_str(&s, FORMAT).map_err(serde::de::Error::custom)?; Ok(Some(dt)) } else { Ok(None) } } } railway-provider-search-ch-0.1.0/src/types.rs000064400000000000000000000361251046102023000172650ustar 00000000000000use crate::serialize; use rcore::{ IntermediateLocation, Journey, JourneysResponse, Leg, Line, Location, Mode, Operator, Place, Product, Remark, Station, Stop, }; use chrono::{DateTime, Duration, NaiveDateTime}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; fn convert_datetime(t: NaiveDateTime) -> Option> { // Note: Should in theory never return `None` as the returned time is in Europe/Zurich time. // This time could be ambiguous though (when switching between summer/winter time), not sure what we can do there as the API does not specify which is the correct one. t.and_local_timezone(chrono_tz::Europe::Zurich).earliest() } fn convert_datetime_with_delay>( t: NaiveDateTime, d: Option, ) -> Option> { let time = convert_datetime(t)?; if let Some(d) = d { let d = d.as_ref(); let delay = d .parse::() .map(Duration::minutes) .unwrap_or_else(|_| Duration::zero()); Some(time + delay) } else { Some(time) } } fn type_string_to_mode>(s: S) -> Mode { match s.as_ref() { "strain" => Mode::SuburbanTrain, "walk" => Mode::Unknown, "tram" => Mode::Tram, "express_train" => Mode::HighSpeedTrain, "bus" => Mode::Bus, // TODO _ => Mode::Unknown, } } pub type SearchChLocationsResponse = Vec; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SearchChLocationsResponseItem { label: String, id: Option, lon: Option, lat: Option, } impl From for Place { fn from(item: SearchChLocationsResponseItem) -> Place { if let Some(id) = item.id { Place::Station(Station { id: id.clone(), name: Some(item.label.clone()), location: Some(Location::Point { id: Some(id), name: Some(item.label), poi: None, latitude: item.lat.unwrap_or_default(), longitude: item.lon.unwrap_or_default(), }), products: vec![], }) } else { // Lat/Lon not given. Place::Location(Location::Address { address: item.label, latitude: item.lat.unwrap_or_default(), longitude: item.lon.unwrap_or_default(), }) } } } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SearchChJourneysResponse { connections: Vec, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SearchChConnection { from: String, #[serde(with = "serialize::time")] departure: chrono::NaiveDateTime, dep_delay: Option, to: String, #[serde(with = "serialize::time")] arrival: chrono::NaiveDateTime, arr_delay: Option, // Note: Sometimes int, sometimes float duration: f64, // is_main, disruptions legs: Vec, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SearchChLeg { #[serde(default)] #[serde(with = "serialize::optional_time")] departure: Option, tripid: Option, stopid: String, // x, y name: String, // sbb_name, line, terminal, fgcolor, bgcolor r#type: Option, #[serde(rename = "*G")] star_g: Option, #[serde(rename = "*L")] star_l: Option, operator: Option, stops: Option>, // contop_stop, runningtime exit: Option, // occupancy dep_delay: Option, track: Option, type_name: Option, lon: f32, lat: f32, cancelled: Option, // Note: This either is a map or an empty vec. disruptions: Option, // TODO: Attributes } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(untagged)] pub enum SearchChDisruptions { Map(HashMap), Vec(Vec), } impl From for Vec { fn from(disruptions: SearchChDisruptions) -> Self { match disruptions { SearchChDisruptions::Map(m) => m.into_values().map(Into::into).collect(), SearchChDisruptions::Vec(v) => v.into_iter().map(Into::into).collect(), } } } impl Default for SearchChDisruptions { fn default() -> Self { Self::Vec(Vec::new()) } } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SearchChDisruption { id: String, texts: SearchChDisruptionTexts, // A lot more fields which are not too interesting. } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SearchChDisruptionTexts { // Also has M and L which seem to be mostly the same to S. Not sure what is the difference. // public-transport-enabler seems to only use S: #[serde(rename = "S")] s: SearchChDisruptionText, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SearchChDisruptionText { summary: Option, reason: Option, duration: Option, consequence: Option, recommendation: Option, } impl From for Remark { fn from(disruption: SearchChDisruption) -> Remark { let texts = disruption.texts.s; Remark { code: disruption.id, text: vec![ texts.summary.clone(), texts.reason, texts.duration, texts.consequence, texts.recommendation, ] .into_iter() .flatten() .map(|t| t + ".") .collect::>() .join(" "), r#type: rcore::RemarkType::Status, association: rcore::RemarkAssociation::None, summary: texts.summary, trip_id: None, } } } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SearchChStop { #[serde(default)] #[serde(with = "serialize::optional_time")] arrival: Option, arr_delay: Option, #[serde(default)] #[serde(with = "serialize::optional_time")] departure: Option, dep_delay: Option, name: String, stopid: String, // x, y, lon: f32, lat: f32, } impl From for IntermediateLocation { fn from(stop: SearchChStop) -> IntermediateLocation { if stop.departure.is_some() { Self::Stop(Stop { place: Place::Station(Station { id: stop.stopid.clone(), name: Some(stop.name.clone()), location: Some(Location::Point { id: Some(stop.stopid), name: Some(stop.name), poi: None, latitude: stop.lat, longitude: stop.lon, }), products: vec![], }), departure: stop .departure .and_then(|d| convert_datetime_with_delay(d, stop.dep_delay.as_ref())), planned_departure: stop.departure.and_then(convert_datetime), arrival: stop .arrival .and_then(|d| convert_datetime_with_delay(d, stop.arr_delay.as_ref())), planned_arrival: stop.arrival.and_then(convert_datetime), // Note: The API does not provide track information for stopovers. arrival_platform: None, planned_arrival_platform: None, departure_platform: None, planned_departure_platform: None, // Note: The API does not provide cancellation or remark information for stopovers. cancelled: false, remarks: vec![], }) } else { Self::Railway(Place::Location(Location::Point { id: Some(stop.stopid), name: Some(stop.name), poi: None, latitude: stop.lat, longitude: stop.lon, })) } } } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SearchChExit { #[serde(with = "serialize::time")] arrival: chrono::NaiveDateTime, stopid: String, // x, y name: String, // sbb_name, waittime, track: Option, arr_delay: Option, lon: f32, lat: f32, } impl From for JourneysResponse { fn from(response: SearchChJourneysResponse) -> JourneysResponse { Self { earlier_ref: response .connections .first() .and_then(|c| convert_datetime(c.departure)) .map(|t| t.to_rfc3339()), later_ref: response .connections .last() .and_then(|c| convert_datetime(c.departure)) .map(|t| t.to_rfc3339()), journeys: response.connections.into_iter().map(Into::into).collect(), } } } impl From for Journey { fn from(connection: SearchChConnection) -> Self { let mut legs: Vec = connection.legs.into_iter().map(Into::into).collect(); // Note: Last "leg" is always destination. legs.remove(legs.len() - 1); Self { id: legs .iter() .flat_map(|l| l.trip_id.as_ref()) .map(|s| &s[..]) .collect(), legs, price: None, } } } impl From for Leg { fn from(leg: SearchChLeg) -> Leg { let origin = Place::Station(Station { id: leg.stopid.clone(), name: Some(leg.name.clone()), location: Some(Location::Point { id: Some(leg.stopid), name: Some(leg.name), poi: None, latitude: leg.lat, longitude: leg.lon, }), products: vec![], }); let destination = Place::Station(Station { id: leg .exit .as_ref() .map(|e| e.stopid.clone()) .unwrap_or_default(), name: leg.exit.as_ref().map(|e| e.name.clone()), location: Some(Location::Point { id: leg.exit.as_ref().map(|e| e.stopid.clone()), name: leg.exit.as_ref().map(|e| e.name.clone()), poi: None, latitude: leg.exit.as_ref().map(|e| e.lat).unwrap_or_default(), longitude: leg.exit.as_ref().map(|e| e.lon).unwrap_or_default(), }), products: vec![], }); let planned_departure = leg.departure.and_then(convert_datetime); let planned_arrival = leg.exit.as_ref().and_then(|e| convert_datetime(e.arrival)); let departure = leg .departure .and_then(|d| convert_datetime_with_delay(d, leg.dep_delay.as_ref())); let arrival = leg .exit .as_ref() .and_then(|e| convert_datetime_with_delay(e.arrival, e.arr_delay.as_ref())); let arrival_platform = leg.exit.as_ref().and_then(|e| e.track.clone()); let departure_platform = leg.track.clone(); let r#type = leg.r#type.unwrap_or_default(); let mode = type_string_to_mode(&r#type); let mut intermediate_locations = vec![IntermediateLocation::Stop(Stop { place: origin.clone(), departure, planned_departure, arrival: None, planned_arrival: None, arrival_platform: None, planned_arrival_platform: None, // TODO: Track changes // When tracks change, the API does not return which was the old track but only the new one with an exclamation-mark. departure_platform: departure_platform.clone(), planned_departure_platform: departure_platform.clone(), cancelled: false, remarks: vec![], })]; // Note: Should we consider filtering out "Bahn-2000-Strecke" and others. This is no real stopover. // Or should we change semantics of a "stopover" to also include that? // See also and . intermediate_locations.extend(leg.stops.unwrap_or_default().into_iter().map(Into::into)); intermediate_locations.push(IntermediateLocation::Stop(Stop { place: destination.clone(), departure: None, planned_departure: None, arrival, planned_arrival, // TODO: Track changes // When tracks change, the API does not return which was the old track but only the new one with an exclamation-mark. arrival_platform: arrival_platform.clone(), planned_arrival_platform: arrival_platform.clone(), departure_platform: None, planned_departure_platform: None, cancelled: false, remarks: vec![], })); Leg { origin, destination, departure, planned_departure, arrival, planned_arrival, reachable: true, trip_id: leg.tripid, line: if r#type != "walk" { Some(Line { name: leg.star_g.clone(), fahrt_nr: leg.star_l, mode: mode.clone(), product: Product { mode, name: leg.star_g.clone().unwrap_or_default().into(), short: leg.star_g.clone().unwrap_or_default().into(), }, operator: leg.operator.map(|o| Operator { id: o.clone(), name: o.clone(), }), product_name: leg.star_g, }) } else { None }, direction: None, // TODO: Track changes // When tracks change, the API does not return which was the old track but only the new one with an exclamation-mark. arrival_platform: arrival_platform.clone(), planned_arrival_platform: arrival_platform, departure_platform: departure_platform.clone(), planned_departure_platform: departure_platform, intermediate_locations, #[cfg(feature = "polylines")] polyline: None, walking: r#type == "walk", cancelled: leg.cancelled.unwrap_or_default(), remarks: leg.disruptions.unwrap_or_default().into(), // Information not provided. load_factor: None, transfer: false, distance: None, frequency: None, } } }