├── .github └── workflows │ ├── railboard-api.yml │ └── tests.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE.txt ├── README.md ├── dev.docker-compose.yml ├── flake.lock ├── flake.nix ├── iris-client ├── Cargo.toml ├── src │ ├── endpoints.rs │ ├── endpoints │ │ ├── station_board.rs │ │ └── station_board │ │ │ ├── response.rs │ │ │ ├── return.rs │ │ │ └── return │ │ │ ├── message.rs │ │ │ ├── message │ │ │ └── lookup.rs │ │ │ └── stop.rs │ ├── error.rs │ ├── helpers.rs │ └── lib.rs └── tests │ ├── date_parsing.rs │ └── station_board.rs ├── nix └── package.nix ├── railboard-api ├── Cargo.toml └── src │ ├── cache.rs │ ├── custom.rs │ ├── custom │ ├── station_board.rs │ └── station_board_v2.rs │ ├── error.rs │ ├── iris.rs │ ├── iris │ └── station_board.rs │ ├── main.rs │ ├── ris.rs │ ├── ris │ ├── journey_details.rs │ ├── journey_search.rs │ ├── station_board.rs │ ├── station_information.rs │ └── station_search_by_name.rs │ ├── vendo.rs │ └── vendo │ ├── journey_details.rs │ ├── location_search.rs │ └── station_board.rs ├── renovate.json ├── ris-client ├── Cargo.toml ├── src │ ├── endpoints.rs │ ├── endpoints │ │ ├── journey_details.rs │ │ ├── journey_details │ │ │ ├── response.rs │ │ │ └── transformed.rs │ │ ├── journey_search.rs │ │ ├── journey_search │ │ │ └── response.rs │ │ ├── station_board.rs │ │ ├── station_board │ │ │ ├── response.rs │ │ │ └── transformed.rs │ │ ├── station_information.rs │ │ ├── station_information │ │ │ ├── response.rs │ │ │ └── transformed.rs │ │ ├── station_search.rs │ │ └── station_search │ │ │ └── response.rs │ ├── error.rs │ ├── helpers.rs │ ├── lib.rs │ └── request.rs └── tests │ ├── journey_details.rs │ ├── journey_search.rs │ ├── station_board.rs │ ├── station_information.rs │ └── station_search.rs └── vendo-client ├── Cargo.toml ├── src ├── endpoints.rs ├── endpoints │ ├── journey_details.rs │ ├── journey_details │ │ ├── response.rs │ │ └── transformed.rs │ ├── location_search.rs │ ├── location_search │ │ ├── request.rs │ │ └── response.rs │ ├── station_board.rs │ └── station_board │ │ ├── request.rs │ │ ├── response.rs │ │ └── transformed.rs ├── error.rs ├── lib.rs └── shared.rs └── tests ├── journey_details.rs ├── location_search.rs └── station_board.rs /.github/workflows/railboard-api.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build & Release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | docker: 9 | name: Build Docker Image 10 | runs-on: ubuntu-22.04 11 | permissions: 12 | contents: read 13 | packages: write 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Setup QEMU 17 | uses: docker/setup-qemu-action@v2 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v2 20 | - name: Login to GitHub Container Registry 21 | uses: docker/login-action@v2 22 | with: 23 | registry: ghcr.io 24 | username: ${{ github.actor }} 25 | password: ${{ secrets.GITHUB_TOKEN }} 26 | - name: Build and push 27 | uses: docker/build-push-action@v4 28 | with: 29 | push: true 30 | tags: | 31 | ghcr.io/emmaboecker/railboard-api:${{ github.sha }} 32 | ghcr.io/emmaboecker/railboard-api:latest 33 | platforms: linux/amd64 34 | cache-from: type=gha 35 | cache-to: type=gha,mode=max 36 | 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Run Tests 8 | runs-on: ubuntu-latest 9 | if: "github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository" 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: dtolnay/rust-toolchain@stable 13 | - uses: Swatinem/rust-cache@v2 14 | - name: Run Tests 15 | run: cargo test 16 | env: 17 | RIS_API_KEY: ${{ secrets.RIS_API_KEY }} 18 | RIS_CLIENT_ID: ${{ secrets.RIS_CLIENT_ID }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .env 3 | 4 | out 5 | 6 | # when I print something long to stdout I pipe it into a file 7 | output.txt 8 | 9 | # Iris schema response files that are not needed 10 | iris-client/schema/* 11 | 12 | # Editor folders and files 13 | .idea 14 | .vscode 15 | 16 | mitm.pem -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | 'railboard-api', 4 | 'vendo-client', 5 | 'iris-client', 6 | 'ris-client', 7 | ] 8 | resolver = '2' 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM lukemathwalker/cargo-chef:latest-rust-alpine AS chef 2 | WORKDIR /usr/src 3 | 4 | FROM chef AS planner 5 | COPY . . 6 | RUN cargo chef prepare --recipe-path recipe.json 7 | 8 | FROM chef AS builder 9 | COPY --from=planner /usr/src/recipe.json recipe.json 10 | RUN cargo chef cook --release --recipe-path recipe.json 11 | COPY . . 12 | RUN cargo build --release --bin railboard-api 13 | 14 | FROM scratch AS runtime 15 | COPY --from=builder /usr/src/target/release/railboard-api /railboard-api 16 | ENTRYPOINT ["/railboard-api"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Railboard API 2 | 3 | **This Project is very WIP, unstable and far away from being completely functional, unexpected errors can occur** 4 | 5 | This project generally is going to be the backend for my other project [railboard](https://github.com/emmaboecker/railboard), but the goal is to make an API 6 | similar to the [bahn.expert API](https://github.com/marudor/bahn.expert) that marudor sadly shut down public support for and the documentation is not available any more. 7 | 8 | If you have any questions, I'd love to talk about it with you! Hit me up on Mastodon or Twitter and send me your Discord (I usually don't accept Friend Requests from people that I dont share a Server with) so we can chat! 9 | 10 | ## Goal of This Project 11 | 12 | The Goal of this project is to make the public transport API's of the German Train Service ([Deutsche Bahn](https://www.deutschebahn.com/)) more accessible 13 | to use in custom projects. The few open APIs the company offers are very limited and the effort to use the APIs the Mobile Apps use is immense (source: trust me). 14 | 15 | I think everyone that wants to build something cool should be able to. 16 | 17 | In addition to the REST API endpoints that I am building, the clients for the different APIs are completely usable without the rest of the Project. 18 | 19 | ## Documentation 20 | 21 | Documentation is available at [https://api.rail.boecker.dev/docs](https://api.rail.boecker.dev/docs) 22 | 23 | **But which API endpoint should I use?** \ 24 | The Iris endpoint is generally fast, only has data for the current day tho. If you want older/newer data with not as much data per train refer to the Vendo Endpoint, which can also give you journey details. \ 25 | The ris endpoints also provide generally good data, but don't have as much data from the past/future. 26 | 27 | ## Roadmap 28 | - [x] Major Vendo endpoints 29 | - [x] Iris Station-Board 30 | - [x] Ris API endpoints 31 | - [ ] Coach Sequence API 32 | - [ ] Hafas API endpoints 33 | - [ ] Custom endpoints with data from multiple sources 34 | 35 | If you have any feature request of an idea feel free to [open an issue](https://github.com/emmaboecker/railboard-api/issues/new) 36 | 37 | ## Contribution 38 | 39 | Contributions are always welcome, I'd prefer if you contact me before opening a PR for now tho 40 | 41 | _anyone that is willing to help with this project, feel free to hit me up on [Mastodon (@boecker@chaos.social)](https://chaos.social/@boecker)/[Twitter (@emmavdev)](https://x.com/emmavdev)/[Twitter (@EmmaBoecker)](https://x.com/EmmaBoecker) 42 | or send me an email at [emma@boecker.dev](mailto:emma@boecker.dev) 43 | -------------------------------------------------------------------------------- /dev.docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.1" 2 | 3 | services: 4 | redis: 5 | image: redis/redis-stack 6 | volumes: 7 | - redis-data:/data 8 | ports: 9 | - "6379:6379" 10 | volumes: 11 | redis-data: { } 12 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": "nixpkgs-lib" 6 | }, 7 | "locked": { 8 | "lastModified": 1688466019, 9 | "narHash": "sha256-VeM2akYrBYMsb4W/MmBo1zmaMfgbL4cH3Pu8PGyIwJ0=", 10 | "owner": "hercules-ci", 11 | "repo": "flake-parts", 12 | "rev": "8e8d955c22df93dbe24f19ea04f47a74adbdc5ec", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "id": "flake-parts", 17 | "type": "indirect" 18 | } 19 | }, 20 | "nixpkgs": { 21 | "locked": { 22 | "lastModified": 1690272529, 23 | "narHash": "sha256-MakzcKXEdv/I4qJUtq/k/eG+rVmyOZLnYNC2w1mB59Y=", 24 | "owner": "NixOS", 25 | "repo": "nixpkgs", 26 | "rev": "ef99fa5c5ed624460217c31ac4271cfb5cb2502c", 27 | "type": "github" 28 | }, 29 | "original": { 30 | "owner": "NixOS", 31 | "ref": "nixos-unstable", 32 | "repo": "nixpkgs", 33 | "type": "github" 34 | } 35 | }, 36 | "nixpkgs-lib": { 37 | "locked": { 38 | "dir": "lib", 39 | "lastModified": 1688049487, 40 | "narHash": "sha256-100g4iaKC9MalDjUW9iN6Jl/OocTDtXdeAj7pEGIRh4=", 41 | "owner": "NixOS", 42 | "repo": "nixpkgs", 43 | "rev": "4bc72cae107788bf3f24f30db2e2f685c9298dc9", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "dir": "lib", 48 | "owner": "NixOS", 49 | "ref": "nixos-unstable", 50 | "repo": "nixpkgs", 51 | "type": "github" 52 | } 53 | }, 54 | "root": { 55 | "inputs": { 56 | "flake-parts": "flake-parts", 57 | "nixpkgs": "nixpkgs" 58 | } 59 | } 60 | }, 61 | "root": "root", 62 | "version": 7 63 | } 64 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "API for german public transport data and backend for github.com/StckOverflw/railboard"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | }; 7 | 8 | outputs = inputs@{ flake-parts, ... }: 9 | flake-parts.lib.mkFlake { inherit inputs; } { 10 | systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ]; 11 | perSystem = { config, self', inputs', pkgs, system, ... }: { 12 | packages.default = pkgs.callPackage ./nix/package.nix {}; 13 | }; 14 | flake = {}; 15 | }; 16 | } -------------------------------------------------------------------------------- /iris-client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [dependencies] 2 | async-lock = '2.7.0' 3 | chrono-tz = '0.8.3' 4 | futures = '0.3.28' 5 | serde-xml-rs = '0.6.0' 6 | thiserror = '1.0.44' 7 | urlencoding = '2.1.3' 8 | wu-diff = '0.1.2' 9 | 10 | [dependencies.chrono] 11 | features = ['serde'] 12 | version = '0.4.26' 13 | 14 | [dependencies.reqwest] 15 | default-features = false 16 | features = ['rustls-tls'] 17 | version = '0.11.18' 18 | 19 | [dependencies.serde] 20 | features = ['derive'] 21 | version = '1.0.180' 22 | 23 | [dependencies.tokio] 24 | features = ['full'] 25 | version = '1.29.1' 26 | 27 | [dependencies.utoipa] 28 | features = ['chrono'] 29 | version = '3.4.3' 30 | [dev-dependencies.tokio] 31 | features = ['full'] 32 | version = '1.29.1' 33 | 34 | [package] 35 | edition = '2021' 36 | name = 'iris-client' 37 | version = '0.1.0' 38 | -------------------------------------------------------------------------------- /iris-client/src/endpoints.rs: -------------------------------------------------------------------------------- 1 | pub mod station_board; 2 | -------------------------------------------------------------------------------- /iris-client/src/endpoints/station_board.rs: -------------------------------------------------------------------------------- 1 | pub mod response; 2 | mod r#return; 3 | 4 | use chrono::{DateTime, Duration, TimeZone, Timelike}; 5 | use chrono_tz::{Europe::Berlin, Tz}; 6 | 7 | pub use r#return::*; 8 | 9 | use response::*; 10 | 11 | use crate::{IrisClient, IrisError, IrisOrRequestError}; 12 | 13 | impl IrisClient { 14 | /// Fetches all planned information IRIS has for a specific station at the specified time frame \ 15 | /// and the realtime information IRIS currently has for the specified station and combines them into a \ 16 | /// better format that is easier to work with. 17 | pub async fn station_board( 18 | &self, 19 | eva: &str, 20 | date: Option>, 21 | lookahead: Option, 22 | lookbehind: Option, 23 | ) -> Result { 24 | let date = 25 | date.unwrap_or_else(|| Berlin.from_utc_datetime(&chrono::Utc::now().naive_utc())); 26 | 27 | let lookbehind = lookbehind.unwrap_or(20); 28 | let lookahead = lookahead.unwrap_or(180); 29 | 30 | let lookbehind = date - chrono::Duration::minutes(lookbehind as i64); 31 | let lookahead = date + chrono::Duration::minutes(lookahead as i64); 32 | 33 | let mut dates = Vec::new(); 34 | 35 | for current_date in DateRange(lookbehind, lookahead) { 36 | dates.push(current_date); 37 | } 38 | 39 | let (realtime, timetables) = tokio::join!( 40 | self.realtime_station_board(eva), 41 | futures::future::join_all(dates.iter().map(|date| async move { 42 | self.planned_station_board( 43 | eva, 44 | &date.format("%y%m%d").to_string(), 45 | &date.format("%H").to_string(), 46 | ) 47 | .await 48 | })) 49 | ); 50 | 51 | let realtime = realtime?; 52 | let timetables = timetables 53 | .into_iter() 54 | .filter_map(|result| result.ok()) 55 | .collect::>(); 56 | 57 | let disruptions = realtime 58 | .disruptions 59 | .into_iter() 60 | .map(|message| message.into()) 61 | .collect::>(); 62 | 63 | let mut stops = Vec::new(); 64 | 65 | for timetable in timetables { 66 | for stop in timetable.stops { 67 | let realtime = realtime 68 | .stops 69 | .iter() 70 | .find(|realtime_stop| realtime_stop.id == stop.id); 71 | stops.push(from_iris_timetable( 72 | eva, 73 | &timetable.station_name, 74 | stop, 75 | realtime.map(|realtime| realtime.to_owned()), 76 | )); 77 | } 78 | } 79 | 80 | Ok(IrisStationBoard { 81 | station_name: realtime.station_name, 82 | station_eva: String::from(eva), 83 | disruptions, 84 | stops, 85 | }) 86 | } 87 | 88 | /// Get all realtime information IRIS currently has for a specific station. 89 | /// 90 | /// **Consider using [`station_board`](IrisClient::station_board) instead.** \ 91 | /// 92 | /// Takes the eva number of the station e.G. `8000105` for Frankfurt(Main)Hbf. 93 | pub async fn realtime_station_board(&self, eva: &str) -> Result { 94 | let _permit = self.semaphore.acquire().await; 95 | 96 | let response = self 97 | .client 98 | .get(format!("{}/iris-tts/timetable/fchg/{}", self.base_url, eva)) 99 | .send() 100 | .await?; 101 | 102 | if !response.status().is_success() { 103 | return Err(IrisOrRequestError::IrisError(IrisError)); 104 | } 105 | 106 | let response: String = response.text().await?; 107 | 108 | let response = serde_xml_rs::from_str(&response)?; 109 | 110 | Ok(response) 111 | } 112 | 113 | /// Get all planned information IRIS has for a specific station at the specified date + hour. 114 | /// 115 | /// From experience IRIS does not have more planned data than the current day + maybe a bit of the early hours of the next day. 116 | /// 117 | /// **Consider using [`station_board`](IrisClient::station_board) instead.** \ 118 | /// 119 | /// Takes the eva number of the station e.G. `8000105` for Frankfurt(Main)Hbf. \ 120 | /// the date in the format `YYMMDD` \ 121 | /// and the hour in the format `HH`. 122 | pub async fn planned_station_board( 123 | &self, 124 | eva: &str, 125 | date: &str, 126 | hour: &str, 127 | ) -> Result { 128 | let _permit = self.semaphore.acquire().await; 129 | 130 | let response = self 131 | .client 132 | .get(format!( 133 | "{}/iris-tts/timetable/plan/{}/{}/{}", 134 | self.base_url, eva, date, hour 135 | )) 136 | .send() 137 | .await?; 138 | 139 | if !response.status().is_success() { 140 | return Err(IrisOrRequestError::IrisError(IrisError)); 141 | } 142 | 143 | let response: String = response.text().await?; 144 | 145 | Ok(serde_xml_rs::from_str(&response)?) 146 | } 147 | } 148 | 149 | struct DateRange(DateTime, DateTime); 150 | 151 | impl Iterator for DateRange { 152 | type Item = DateTime; 153 | fn next(&mut self) -> Option { 154 | if self.0 <= self.1 || self.0.hour() == self.1.hour() { 155 | let next = self.0 + Duration::hours(1); 156 | Some(std::mem::replace(&mut self.0, next)) 157 | } else { 158 | None 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /iris-client/src/endpoints/station_board/return.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use chrono::{DateTime, FixedOffset, Offset}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | pub mod message; 7 | pub mod stop; 8 | 9 | pub use message::*; 10 | pub use stop::*; 11 | use utoipa::ToSchema; 12 | 13 | use crate::helpers::parse_iris_date; 14 | 15 | use super::response::{EventStatus, TimetableStop}; 16 | 17 | use wu_diff::*; 18 | 19 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, ToSchema)] 20 | #[serde(rename_all = "camelCase")] 21 | pub struct IrisStationBoard { 22 | pub station_name: String, 23 | pub station_eva: String, 24 | pub disruptions: Vec, 25 | pub stops: Vec, 26 | } 27 | 28 | pub fn from_iris_timetable( 29 | station_eva: &str, 30 | station_name: &str, 31 | stop: TimetableStop, 32 | realtime: Option, 33 | ) -> StationBoardStop { 34 | let mut messages: HashSet = HashSet::new(); 35 | 36 | if let Some(realtime) = &realtime { 37 | if let Some(msgs) = &realtime.messages { 38 | for message in msgs { 39 | messages.insert(message.to_owned().into()); 40 | } 41 | if let Some(departure) = &realtime.departure { 42 | for message in &departure.messages { 43 | messages.insert(message.to_owned().into()); 44 | } 45 | } 46 | if let Some(arrival) = &realtime.arrival { 47 | for message in &arrival.messages { 48 | messages.insert(message.to_owned().into()); 49 | } 50 | } 51 | } 52 | } 53 | 54 | let event_status = realtime 55 | .as_ref() 56 | .and_then(|stop| { 57 | let departure_arrival = stop.departure.as_ref().or(stop.arrival.as_ref()); 58 | 59 | departure_arrival.map(|dep_arr| dep_arr.real_event_status.to_owned()) 60 | }) 61 | .flatten() 62 | .or_else(|| { 63 | stop.departure 64 | .as_ref() 65 | .and_then(|f| f.planned_event_status.to_owned()) 66 | .or_else(|| { 67 | stop.arrival 68 | .as_ref() 69 | .and_then(|f| f.planned_event_status.to_owned()) 70 | }) 71 | }); 72 | 73 | let hidden = realtime 74 | .as_ref() 75 | .and_then(|stop| { 76 | let departure_arrival = &stop 77 | .departure 78 | .as_ref() 79 | .or_else(|| stop.departure.as_ref().or(stop.arrival.as_ref())); 80 | 81 | departure_arrival.map(|dep_arr| dep_arr.hidden == Some(1)) 82 | }) 83 | .or_else(|| { 84 | stop.departure 85 | .as_ref() 86 | .map(|f| f.hidden == Some(1)) 87 | .or_else(|| stop.arrival.as_ref().map(|f| f.hidden == Some(1))) 88 | }); 89 | 90 | let mut route = Vec::new(); 91 | 92 | if let Some(stops) = stop 93 | .arrival 94 | .as_ref() 95 | .map(|arr| arr.planned_path.as_ref().unwrap()) 96 | { 97 | let current_path: Option> = realtime 98 | .as_ref() 99 | .and_then(|realtime| { 100 | realtime 101 | .arrival 102 | .as_ref() 103 | .map(|real_dep| real_dep.changed_path.as_ref()) 104 | }) 105 | .flatten() 106 | .map(|path| path.split('|').collect()); 107 | 108 | if let Some(current_path) = current_path { 109 | let old = stops.split('|').collect::>(); 110 | 111 | for diff in wu_diff::diff(&old, ¤t_path) { 112 | match diff { 113 | DiffResult::Common(same) => { 114 | route.push(RouteStop { 115 | name: current_path[same.new_index.unwrap()].to_string(), 116 | cancelled: false, 117 | added: false, 118 | }); 119 | } 120 | DiffResult::Added(add) => { 121 | let new_stop = current_path[add.new_index.unwrap()]; 122 | if !new_stop.is_empty() { 123 | route.push(RouteStop { 124 | name: new_stop.to_string(), 125 | cancelled: false, 126 | added: true, 127 | }); 128 | } 129 | } 130 | DiffResult::Removed(rem) => { 131 | route.push(RouteStop { 132 | name: old[rem.old_index.unwrap()].to_string(), 133 | cancelled: true, 134 | added: false, 135 | }); 136 | } 137 | } 138 | } 139 | } else { 140 | for stop in stops.split('|') { 141 | route.push(RouteStop { 142 | name: stop.to_string(), 143 | cancelled: false, 144 | added: false, 145 | }); 146 | } 147 | } 148 | } 149 | 150 | let cancelled = event_status == Some(EventStatus::Cancelled); 151 | let added = event_status == Some(EventStatus::Added); 152 | 153 | route.push(RouteStop { 154 | name: String::from(station_name), 155 | cancelled, 156 | added, 157 | }); 158 | 159 | if let Some(stops) = stop 160 | .departure 161 | .as_ref() 162 | .map(|dep| dep.planned_path.as_ref().unwrap()) 163 | { 164 | let current_path: Option> = realtime 165 | .as_ref() 166 | .and_then(|realtime| { 167 | realtime 168 | .departure 169 | .as_ref() 170 | .map(|real_dep| real_dep.changed_path.as_ref()) 171 | }) 172 | .flatten() 173 | .map(|path| path.split('|').collect()); 174 | 175 | if let Some(current_path) = current_path { 176 | let old = stops.split('|').collect::>(); 177 | 178 | for diff in wu_diff::diff(&old, ¤t_path) { 179 | match diff { 180 | DiffResult::Common(same) => { 181 | route.push(RouteStop { 182 | name: current_path[same.new_index.unwrap()].to_string(), 183 | cancelled: false, 184 | added: false, 185 | }); 186 | } 187 | DiffResult::Added(add) => { 188 | let new_stop = current_path[add.new_index.unwrap()]; 189 | if !new_stop.is_empty() { 190 | route.push(RouteStop { 191 | name: new_stop.to_string(), 192 | cancelled: false, 193 | added: true, 194 | }); 195 | } 196 | } 197 | DiffResult::Removed(rem) => { 198 | route.push(RouteStop { 199 | name: old[rem.old_index.unwrap()].to_string(), 200 | cancelled: true, 201 | added: false, 202 | }); 203 | } 204 | } 205 | } 206 | } else { 207 | for stop in stops.split('|') { 208 | route.push(RouteStop { 209 | name: stop.to_string(), 210 | cancelled: false, 211 | added: false, 212 | }); 213 | } 214 | } 215 | } 216 | 217 | StationBoardStop { 218 | id: stop.id, 219 | station_name: String::from(station_name), 220 | station_eva: String::from(station_eva), 221 | messages: messages.into_iter().collect(), 222 | cancelled: event_status == Some(EventStatus::Cancelled), 223 | added: event_status == Some(EventStatus::Added), 224 | hidden: hidden.unwrap_or(false), 225 | replaces: realtime.as_ref().and_then(|realtime| { 226 | realtime.reference.as_ref().map(|reference| ReplacedTrain { 227 | category: reference.trip_label.category.to_owned(), 228 | number: reference.trip_label.train_number.to_owned(), 229 | }) 230 | }), 231 | arrival: stop.arrival.as_ref().map(|arrival| { 232 | let plan_date = parse_iris_date(arrival.planned_time.as_ref().unwrap()).unwrap(); 233 | let plan_offset = plan_date.offset().fix(); 234 | let real_date = realtime 235 | .as_ref() 236 | .and_then(|realtime| { 237 | realtime 238 | .arrival 239 | .as_ref() 240 | .map(|departure| departure.changed_time.as_ref()) 241 | }) 242 | .flatten() 243 | .map(|time| parse_iris_date(time).unwrap()); 244 | let real_offset = real_date 245 | .as_ref() 246 | .map(|date| date.offset().fix()) 247 | .unwrap_or_else(|| plan_offset); 248 | 249 | StationBoardStopArrival { 250 | planned_time: DateTime::::from_utc(plan_date.naive_utc(), plan_offset), 251 | real_time: real_date 252 | .map(|date| DateTime::::from_utc(date.naive_utc(), real_offset)), 253 | wings: arrival 254 | .wings 255 | .as_ref() 256 | .map(|wings| wings.split('|').map(|string| string.to_string()).collect()) 257 | .unwrap_or_default(), 258 | origin: route.first().unwrap().name.to_owned(), 259 | } 260 | }), 261 | departure: stop.departure.as_ref().map(|departure| { 262 | let plan_date = parse_iris_date(departure.planned_time.as_ref().unwrap()).unwrap(); 263 | let plan_offset = plan_date.offset().fix(); 264 | let real_date = realtime 265 | .as_ref() 266 | .and_then(|realtime| { 267 | realtime 268 | .departure 269 | .as_ref() 270 | .map(|departure| departure.changed_time.as_ref()) 271 | }) 272 | .flatten() 273 | .map(|time| parse_iris_date(time).unwrap()); 274 | let real_offset = real_date 275 | .as_ref() 276 | .map(|date| date.offset().fix()) 277 | .unwrap_or_else(|| plan_offset); 278 | 279 | StationBoardStopDeparture { 280 | planned_time: DateTime::::from_utc(plan_date.naive_utc(), plan_offset), 281 | real_time: real_date 282 | .map(|date| DateTime::::from_utc(date.naive_utc(), real_offset)), 283 | wings: departure 284 | .wings 285 | .as_ref() 286 | .map(|wings| wings.split('|').map(|string| string.to_string()).collect()) 287 | .unwrap_or_default(), 288 | direction: route.last().unwrap().name.to_owned(), 289 | } 290 | }), 291 | route, 292 | planned_platform: stop 293 | .departure 294 | .as_ref() 295 | .unwrap_or_else(|| stop.arrival.as_ref().unwrap()) 296 | .planned_platform 297 | .to_owned(), 298 | real_platform: realtime 299 | .as_ref() 300 | .and_then(|realtime| { 301 | realtime 302 | .departure 303 | .as_ref() 304 | .or(stop.arrival.as_ref()) 305 | .map(|dep_arr| dep_arr.planned_platform.to_owned()) 306 | }) 307 | .flatten(), 308 | line_indicator: stop 309 | .departure 310 | .unwrap_or_else(|| stop.arrival.unwrap()) 311 | .line_indicator 312 | .unwrap_or_else(|| stop.trip_label.as_ref().unwrap().train_number.to_owned()), 313 | train_type: stop 314 | .trip_label 315 | .as_ref() 316 | .map(|trip_label| trip_label.category.to_owned()) 317 | .unwrap(), 318 | train_number: stop 319 | .trip_label 320 | .as_ref() 321 | .map(|trip_label| trip_label.train_number.to_owned()) 322 | .unwrap(), 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /iris-client/src/endpoints/station_board/return/message.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use utoipa::ToSchema; 5 | 6 | use crate::helpers::parse_iris_date; 7 | 8 | use self::lookup::iris_message_lookup; 9 | 10 | mod lookup; 11 | 12 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Hash, Clone, ToSchema)] 13 | #[serde(rename_all = "camelCase")] 14 | pub struct Message { 15 | pub id: String, 16 | #[schema(value_type = String)] 17 | pub timestamp: NaiveDateTime, 18 | #[schema(nullable)] 19 | /// The message code (e.G. `59` for `Schnee und Eis`) 20 | pub code: Option, 21 | /// The matched text from the message code (e.G. `Schnee und Eis` when code is `95`) 22 | pub matched_text: Option, 23 | #[schema(nullable)] 24 | pub category: Option, 25 | #[schema(nullable, value_type = String)] 26 | pub valid_from: Option, 27 | #[schema(nullable, value_type = String)] 28 | pub valid_to: Option, 29 | pub message_status: MessageStatus, 30 | #[schema(nullable)] 31 | pub priority: Option, 32 | } 33 | 34 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone, ToSchema)] 35 | pub enum MessageStatus { 36 | /// A HIM message (generated through the Hafas Information Manager)c 37 | HafasInformationManager, 38 | /// A message about a quality change 39 | QualityChange, 40 | /// A free text message 41 | Free, 42 | /// A message about the cause of a delay 43 | CauseOfDelay, 44 | /// An IBIS message (generated from IRIS-AP) 45 | Ibis, 46 | /// An IBIS message (generated from IRIS-AP) not yet assigned to a train 47 | UnassignedIbis, 48 | /// A major disruption 49 | Disruption, 50 | /// A connection 51 | Connection, 52 | } 53 | 54 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone, ToSchema)] 55 | #[serde(rename_all = "lowercase")] 56 | pub enum MessagePriority { 57 | High, 58 | Medium, 59 | Low, 60 | Done, 61 | } 62 | 63 | impl From for Message { 64 | fn from(value: crate::station_board::response::Message) -> Self { 65 | Self { 66 | id: value.id, 67 | timestamp: parse_iris_date(&value.timestamp) 68 | .map(|timestamp| timestamp.naive_local()) 69 | .unwrap(), 70 | code: value.code, 71 | matched_text: value.code.as_ref().and_then(iris_message_lookup), 72 | category: value.category, 73 | valid_from: value.valid_from.and_then(|valid_from| { 74 | parse_iris_date(&valid_from).map(|valid_from| valid_from.naive_local()) 75 | }), 76 | valid_to: value.valid_to.and_then(|valid_to| { 77 | parse_iris_date(&valid_to).map(|valid_to| valid_to.naive_local()) 78 | }), 79 | message_status: value.message_status.into(), 80 | priority: value.priority.map(|priority| priority.into()), 81 | } 82 | } 83 | } 84 | 85 | impl From for MessageStatus { 86 | fn from(value: crate::station_board::response::MessageStatus) -> Self { 87 | match value { 88 | crate::station_board::response::MessageStatus::HafasInformationManager => { 89 | MessageStatus::HafasInformationManager 90 | } 91 | crate::station_board::response::MessageStatus::QualityChange => { 92 | MessageStatus::QualityChange 93 | } 94 | crate::station_board::response::MessageStatus::Free => MessageStatus::Free, 95 | crate::station_board::response::MessageStatus::CauseOfDelay => { 96 | MessageStatus::CauseOfDelay 97 | } 98 | crate::station_board::response::MessageStatus::Ibis => MessageStatus::Ibis, 99 | crate::station_board::response::MessageStatus::UnassignedIbis => { 100 | MessageStatus::UnassignedIbis 101 | } 102 | crate::station_board::response::MessageStatus::Disruption => MessageStatus::Disruption, 103 | crate::station_board::response::MessageStatus::Connection => MessageStatus::Connection, 104 | } 105 | } 106 | } 107 | 108 | impl From for MessagePriority { 109 | fn from(value: crate::station_board::response::MessagePriority) -> Self { 110 | match value { 111 | crate::station_board::response::MessagePriority::High => MessagePriority::High, 112 | crate::station_board::response::MessagePriority::Medium => MessagePriority::Medium, 113 | crate::station_board::response::MessagePriority::Low => MessagePriority::Low, 114 | crate::station_board::response::MessagePriority::Done => MessagePriority::Done, 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /iris-client/src/endpoints/station_board/return/message/lookup.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * This file was made possible by https://github.com/marudor/bahn.expert/blob/main/src/server/iris/messageLookup.ts 3 | */ 4 | 5 | pub fn iris_message_lookup(code: &i32) -> Option { 6 | let matched = match code { 7 | 1 => "Nähere Informationen in Kürze", 8 | 2 => "Polizeieinsatz", 9 | 3 => "Feuerwehreinsatz auf der Strecke", 10 | 4 => "Kurzfristiger Personalausfall", 11 | 5 => "Ärztliche Versorgung eines Fahrgastes", 12 | 6 => "Unbefugtes Ziehen der Notbremse", 13 | 7 => "Unbefugte Personen auf der Strecke", 14 | 8 => "Notarzteinsatz auf der Strecke", 15 | 9 => "Streikauswirkungen", 16 | 10 => "Tiere auf der Strecke", 17 | 11 => "Unwetter", 18 | 12 => "Warten auf ein verspätetes Schiff", 19 | 13 => "Pass- und Zollkontrolle", 20 | 14 => "Technischer Defekt am Bahnhof", 21 | 15 => "Beeinträchtigung durch Vandalismus", 22 | 16 => "Entschärfung einer Fliegerbombe", 23 | 17 => "Beschädigung einer Brücke", 24 | 18 => "Umgestürzter Baum auf der Strecke", 25 | 19 => "Unfall an einem Bahnübergang", 26 | 20 => "Tiere auf der Strecke", 27 | 21 => "Warten auf Anschlussreisende", 28 | 22 => "Witterungsbedingte Beeinträchtigungen", 29 | 23 => "Feuerwehreinsatz auf Bahngelände", 30 | 24 => "Verspätung aus dem Ausland", 31 | 25 => "Bereitstellung weiterer Wagen", 32 | 26 => "Abhängen von Wagen", 33 | 28 => "Gegenstände auf der Strecke", 34 | 29 => "Ersatzverkehr mit Bus ist eingerichtet", 35 | 31 => "Bauarbeiten", 36 | 32 => "Unterstützung beim Ein- und Ausstieg", 37 | 33 => "Reparatur an der Oberleitung", 38 | 34 => "Reparatur an einem Signal", 39 | 35 => "Streckensperrung", 40 | 36 => "Reparatur am Zug", 41 | 37 => "Reparatur am Wagen", 42 | 38 => "Reparatur an der Strecke", 43 | 39 => "Anhängen von zusätzlichen Wagen", 44 | 40 => "Defektes Stellwerk", 45 | 41 => "Technischer Defekt an einem Bahnübergang", 46 | 42 => "Vorübergehend verminderte Geschwindigkeit auf der Strecke", 47 | 43 => "Verspätung eines vorausfahrenden Zuges", 48 | 44 => "Warten auf einen entgegenkommenden Zug", 49 | 45 => "Überholung durch anderen Zug", 50 | 46 => "Warten auf freie Einfahrt", 51 | 47 => "Verspätete Bereitstellung", 52 | 48 => "Verspätung aus vorheriger Fahrt", 53 | 49 => "Kurzfristiger Personalausfall", 54 | 50 => "Kurzfristige Erkrankung von Personal", 55 | 51 => "Verspätetes Personal aus vorheriger Fahrt", 56 | 52 => "Streik", 57 | 53 => "Unwetterauswirkungen", 58 | 54 => "Verfügbarkeit der Gleise derzeit eingeschränkt", 59 | 55 => "Technische Störung an einem anderen Zug", 60 | 56 => "Warten auf Anschlussreisende", 61 | 57 => "Zusätzlicher Halt zum Ein- und Ausstieg", 62 | 58 => "Umleitung", 63 | 59 => "Schnee und Eis", 64 | 60 => "Witterungsbedingt verminderte Geschwindigkeit", 65 | 61 => "Defekte Tür", 66 | 62 => "Behobener technischer Defekt am Zug", 67 | 63 => "Technische Untersuchung am Zug", 68 | 64 => "Reparatur an der Weiche", 69 | 65 => "Erdrutsch", 70 | 66 => "Hochwasser", 71 | 67 => "Behördliche Maßnahme", 72 | 68 => "Hohes Fahrgastaufkommen verlängert Ein- und Ausstieg", 73 | 69 => "Zug verkehrt mit verminderter Geschwindigkeit", 74 | 70 => "WLAN nicht verfügbar", 75 | 71 => "WLAN in einem/mehreren Wagen nicht verfügbar", 76 | 72 => "Info-/Entertainment nicht verfügbar", 77 | 73 => "Mehrzweckabteil vorne", 78 | 74 => "Mehrzweckabteil hinten", 79 | 75 => "1. Klasse vorne", 80 | 76 => "1. Klasse hinten", 81 | 77 => "1. Klasse fehlt", 82 | 79 => "Mehrzweckabteil fehlt", 83 | 80 => "Abweichende Wagenreihung", 84 | 82 => "Mehrere Wagen fehlen", 85 | 83 => "Defekte fahrzeuggebundene Einstiegshilfe", 86 | 84 => "Zug verkehrt richtig gereiht", // r 80 82 83 85, 87 | 85 => "Ein Wagen fehlt", 88 | 86 => "Keine Reservierungsanzeige", 89 | 87 => "Einzelne Wagen ohne Reservierungsanzeige", 90 | 88 => "Keine Qualitätsmängel", // r 80 82 83 85 86 87 90 91 92 93 96 97 98, 91 | 89 => "Reservierungen sind wieder vorhanden", 92 | 90 => "Kein Bordrestaurant/Bordbistro", 93 | 91 => "Fahrradmitnahme nicht möglich", 94 | 92 => "Eingeschränkte Fahrradbeförderung", 95 | 93 => "Behindertengerechte Einrichtung fehlt", 96 | 94 => "Ersatzbewirtschaftung", 97 | 95 => "Universal-WC fehlt", 98 | 96 => "Der Zug ist stark überbesetzt", // r 97, 99 | 97 => "Der Zug ist überbesetzt", // r 96, 100 | 98 => "Sonstige Qualitätsmängel", 101 | 99 => "Verzögerungen im Betriebsablauf", 102 | 900 => "Anschlussbus wartet (?)", 103 | 1000 => "Kundentext", 104 | 1001 => "Keine Zusatzhinweise", 105 | _ => "", 106 | }; 107 | 108 | if !matched.is_empty() { 109 | Some(String::from(matched)) 110 | } else { 111 | None 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /iris-client/src/endpoints/station_board/return/stop.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, FixedOffset}; 2 | use serde::{Deserialize, Serialize}; 3 | use utoipa::ToSchema; 4 | 5 | use super::Message; 6 | 7 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, ToSchema)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct StationBoardStop { 10 | pub id: String, 11 | pub station_eva: String, 12 | pub station_name: String, 13 | pub messages: Vec, 14 | #[schema(nullable)] 15 | pub departure: Option, 16 | #[schema(nullable)] 17 | pub arrival: Option, 18 | #[schema(nullable)] 19 | pub planned_platform: Option, 20 | #[schema(nullable)] 21 | pub real_platform: Option, 22 | pub cancelled: bool, 23 | pub added: bool, 24 | pub hidden: bool, 25 | pub train_type: String, 26 | pub train_number: String, 27 | pub line_indicator: String, 28 | pub route: Vec, 29 | #[schema(nullable)] 30 | pub replaces: Option, 31 | } 32 | 33 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, ToSchema)] 34 | pub struct ReplacedTrain { 35 | pub category: String, 36 | pub number: String, 37 | } 38 | 39 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, ToSchema)] 40 | pub struct RouteStop { 41 | pub name: String, 42 | pub cancelled: bool, 43 | pub added: bool, 44 | } 45 | 46 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, ToSchema)] 47 | #[serde(rename_all = "camelCase")] 48 | pub struct StationBoardStopArrival { 49 | pub planned_time: DateTime, 50 | #[schema(nullable)] 51 | pub real_time: Option>, 52 | pub wings: Vec, 53 | pub origin: String, 54 | } 55 | 56 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, ToSchema)] 57 | #[serde(rename_all = "camelCase")] 58 | pub struct StationBoardStopDeparture { 59 | pub planned_time: DateTime, 60 | #[schema(nullable)] 61 | pub real_time: Option>, 62 | pub wings: Vec, 63 | pub direction: String, 64 | } 65 | -------------------------------------------------------------------------------- /iris-client/src/error.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use thiserror::Error; 3 | 4 | #[derive(Serialize, Deserialize, Debug, Error)] 5 | #[error("Iris returned an error.")] 6 | pub struct IrisError; 7 | 8 | #[derive(Error, Debug)] 9 | pub enum IrisOrRequestError { 10 | #[error("Iris returned an error.")] 11 | IrisError(#[from] IrisError), 12 | #[error("Iris returned invalid/unrecognized XML: {0}")] 13 | InvalidXML(#[from] serde_xml_rs::Error), 14 | #[error(transparent)] 15 | FailedRequest(#[from] reqwest::Error), 16 | } 17 | -------------------------------------------------------------------------------- /iris-client/src/helpers.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, NaiveDateTime, TimeZone}; 2 | use chrono_tz::{Europe::Berlin, Tz}; 3 | 4 | pub fn parse_iris_date(date_string: &str) -> Option> { 5 | let date = NaiveDateTime::parse_from_str(date_string, "%y%m%d%H%M").ok(); 6 | 7 | date?; 8 | 9 | let date = date.unwrap(); 10 | 11 | Berlin.from_local_datetime(&date).single() 12 | } 13 | -------------------------------------------------------------------------------- /iris-client/src/lib.rs: -------------------------------------------------------------------------------- 1 | use async_lock::Semaphore; 2 | 3 | mod error; 4 | pub use error::*; 5 | 6 | mod endpoints; 7 | pub use endpoints::*; 8 | 9 | pub mod helpers; 10 | 11 | pub struct IrisClient { 12 | client: reqwest::Client, 13 | base_url: String, 14 | semaphore: Semaphore, 15 | } 16 | 17 | impl Default for IrisClient { 18 | fn default() -> Self { 19 | Self { 20 | client: reqwest::Client::new(), 21 | base_url: String::from("https://iris.noncd.db.de"), 22 | semaphore: Semaphore::new(100), 23 | } 24 | } 25 | } 26 | 27 | impl IrisClient { 28 | pub fn new( 29 | client: Option, 30 | base_url: Option, 31 | concurrent_requests: Option, 32 | ) -> Self { 33 | Self { 34 | client: client.unwrap_or_default(), 35 | base_url: base_url.unwrap_or_else(|| String::from("https://iris.noncd.db.de")), 36 | semaphore: Semaphore::new(concurrent_requests.unwrap_or(100)), 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /iris-client/tests/date_parsing.rs: -------------------------------------------------------------------------------- 1 | use chrono::{Datelike, Timelike}; 2 | use iris_client::helpers; 3 | 4 | #[tokio::test] 5 | async fn date_parsing() { 6 | let date_string = "1404011437"; 7 | 8 | let parsed_date = helpers::parse_iris_date(date_string); 9 | 10 | assert!(parsed_date.is_some()); 11 | 12 | let parsed_date = parsed_date.unwrap(); 13 | 14 | println!("{parsed_date}"); 15 | 16 | assert_eq!(parsed_date.year(), 2014); 17 | assert_eq!(parsed_date.month(), 4); 18 | assert_eq!(parsed_date.day(), 1); 19 | assert_eq!(parsed_date.hour(), 14); 20 | assert_eq!(parsed_date.minute(), 37); 21 | } 22 | -------------------------------------------------------------------------------- /iris-client/tests/station_board.rs: -------------------------------------------------------------------------------- 1 | #[tokio::test] 2 | async fn station_board() { 3 | let iris_client = iris_client::IrisClient::default(); 4 | 5 | let response = iris_client 6 | .station_board("8000105", None, Some(120), Some(30)) 7 | .await; 8 | 9 | assert!(response.is_ok(), "Response is not ok: {response:?}"); 10 | 11 | let response = response.unwrap(); 12 | 13 | println!("{response:#?}") 14 | } 15 | -------------------------------------------------------------------------------- /nix/package.nix: -------------------------------------------------------------------------------- 1 | { rustPlatform, openssl, pkg-config, lib, config, ... }: 2 | rustPlatform.buildRustPackage { 3 | pname = "railboard-api"; 4 | version = (builtins.fromTOML (builtins.readFile ../railboard-api/Cargo.toml)).package.version; 5 | src = ../.; 6 | cargoLock.lockFile = ../Cargo.lock; 7 | meta = with lib; { 8 | description = config.description; 9 | homepage = "https://github.com/StckOverflw/railboard-api"; 10 | license = licenses.gpl3; 11 | }; 12 | 13 | doCheck = false; 14 | 15 | buildInputs = [ 16 | openssl 17 | ]; 18 | nativeBuildInputs = [ 19 | pkg-config 20 | ]; 21 | } -------------------------------------------------------------------------------- /railboard-api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [dependencies] 2 | async-lock = '2.7.0' 3 | async-trait = '0.1.72' 4 | axum = '0.6.19' 5 | chrono-tz = '0.8.3' 6 | dotenvy = '0.15.7' 7 | erased-serde = '0.3.28' 8 | futures = '0.3.28' 9 | serde_json = '1.0.104' 10 | thiserror = '1.0.44' 11 | tracing = '0.1.37' 12 | 13 | [dependencies.chrono] 14 | features = ['serde'] 15 | version = '0.4.26' 16 | 17 | [dependencies.iris-client] 18 | path = '../iris-client' 19 | 20 | [dependencies.redis] 21 | features = [ 22 | 'tokio-comp', 23 | 'json', 24 | 'connection-manager', 25 | ] 26 | version = '0.23.1' 27 | 28 | [dependencies.reqwest] 29 | default-features = false 30 | features = [ 31 | 'json', 32 | 'rustls-tls', 33 | ] 34 | version = '0.11.18' 35 | 36 | [dependencies.ris-client] 37 | path = '../ris-client' 38 | 39 | [dependencies.serde] 40 | features = ['derive'] 41 | version = '1.0.180' 42 | 43 | [dependencies.tokio] 44 | features = ['full'] 45 | version = '1.29.1' 46 | 47 | [dependencies.tracing-subscriber] 48 | features = ['env-filter'] 49 | version = '0.3.17' 50 | 51 | [dependencies.utoipa] 52 | features = [ 53 | 'axum_extras', 54 | 'chrono', 55 | ] 56 | version = '3.4.3' 57 | 58 | [dependencies.utoipa-swagger-ui] 59 | features = ['axum'] 60 | version = '3.1.4' 61 | 62 | [dependencies.vendo-client] 63 | path = '../vendo-client' 64 | 65 | [package] 66 | edition = '2021' 67 | name = 'railboard-api' 68 | publish = false 69 | version = '0.1.0' 70 | -------------------------------------------------------------------------------- /railboard-api/src/cache.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::vendo::location_search::LocationSearchCache; 4 | use chrono::TimeZone; 5 | use chrono_tz::Europe::Berlin; 6 | use iris_client::station_board::response::TimeTable; 7 | use redis::JsonAsyncCommands; 8 | use ris_client::journey_details::RisJourneyDetails; 9 | use ris_client::station_board::RisStationBoard; 10 | use ris_client::station_information::RisStationInformation; 11 | use ris_client::{ 12 | journey_search::RisJourneySearchResponse, station_search::RisStationSearchElement, 13 | }; 14 | use serde::{de::DeserializeOwned, Serialize}; 15 | use thiserror::Error; 16 | use vendo_client::journey_details::VendoJourneyDetails; 17 | use vendo_client::station_board::VendoStationBoard; 18 | 19 | #[async_trait::async_trait] 20 | pub trait Cache: Sync + Send { 21 | async fn get_from_id(&self, id: &str) -> Option 22 | where 23 | Rt: DeserializeOwned + Sync + Send; 24 | 25 | async fn insert_to_cache( 26 | &self, 27 | key: String, 28 | object: &Rt, 29 | expiration: usize, 30 | ) -> Result<(), CacheInsertError> 31 | where 32 | Rt: Serialize + Sync + Send; 33 | } 34 | 35 | #[derive(Debug, Error)] 36 | pub enum CacheInsertError { 37 | #[error("Failed to insert object into Redis: {0}")] 38 | RedisError(#[from] redis::RedisError), 39 | } 40 | 41 | #[derive(Clone)] 42 | pub struct RedisCache { 43 | pub redis_client: Arc, 44 | } 45 | 46 | impl RedisCache { 47 | pub fn new(redis_client: Arc) -> Self { 48 | Self { redis_client } 49 | } 50 | } 51 | 52 | #[async_trait::async_trait] 53 | impl Cache for RedisCache { 54 | async fn get_from_id(&self, id: &str) -> Option 55 | where 56 | Rt: DeserializeOwned, 57 | { 58 | let conn = self.redis_client.get_async_connection().await; 59 | let mut conn = match conn { 60 | Ok(conn) => { 61 | tracing::debug!("Connection to Cache"); 62 | conn 63 | } 64 | Err(err) => { 65 | tracing::error!("Error while getting connection to cache: {}", err); 66 | return None; 67 | } 68 | }; 69 | let result: Result, redis::RedisError> = conn.json_get(id, "$").await; 70 | 71 | match result { 72 | Ok(result) => match result { 73 | Some(result) => { 74 | let result: Result, serde_json::Error> = serde_json::from_str(&result); 75 | match result { 76 | Ok(result) => { 77 | tracing::debug!("Got result from cache"); 78 | tracing::debug!("Response cached"); 79 | result.into_iter().next() 80 | } 81 | Err(err) => { 82 | tracing::error!("Error while parsing result from cache: {}", err); 83 | None 84 | } 85 | } 86 | } 87 | None => { 88 | tracing::debug!("No results in cache"); 89 | None 90 | } 91 | }, 92 | Err(err) => { 93 | tracing::error!("Error while getting from cache: {}", err); 94 | None 95 | } 96 | } 97 | } 98 | async fn insert_to_cache( 99 | &self, 100 | key: String, 101 | object: &Rt, 102 | expiration: usize, 103 | ) -> Result<(), CacheInsertError> 104 | where 105 | Rt: Serialize + Send + Sync, 106 | { 107 | let mut connection = self.redis_client.get_async_connection().await?; 108 | 109 | redis::pipe() 110 | .atomic() 111 | .json_set(&key, "$", object)? 112 | .ignore() 113 | .expire(&key, expiration) 114 | .ignore() 115 | .query_async(&mut connection) 116 | .await?; 117 | Ok(()) 118 | } 119 | } 120 | 121 | #[async_trait::async_trait] 122 | pub trait CachableObject { 123 | async fn insert_to_cache( 124 | &self, 125 | cache: &C, 126 | information: Option<&str>, 127 | ) -> Result<(), CacheInsertError>; 128 | } 129 | 130 | #[async_trait::async_trait] 131 | impl CachableObject for VendoStationBoard { 132 | async fn insert_to_cache( 133 | &self, 134 | cache: &C, 135 | _information: Option<&str>, 136 | ) -> Result<(), CacheInsertError> { 137 | let key = format!("vendo.station-board.{}.{}.{}", self.id, self.day, self.time); 138 | 139 | cache.insert_to_cache(key, self, 90).await 140 | } 141 | } 142 | 143 | #[async_trait::async_trait] 144 | impl CachableObject for LocationSearchCache { 145 | async fn insert_to_cache( 146 | &self, 147 | cache: &C, 148 | _information: Option<&str>, 149 | ) -> Result<(), CacheInsertError> { 150 | let key = format!("vendo.location-search.{}", self.query); 151 | 152 | cache.insert_to_cache(key, self, 60 * 60 * 24 * 7).await 153 | } 154 | } 155 | 156 | #[async_trait::async_trait] 157 | impl CachableObject for VendoJourneyDetails { 158 | async fn insert_to_cache( 159 | &self, 160 | cache: &C, 161 | _information: Option<&str>, 162 | ) -> Result<(), CacheInsertError> { 163 | let key = format!("vendo.journey-details.{}", self.journey_id); 164 | 165 | cache.insert_to_cache(key, self, 90).await 166 | } 167 | } 168 | 169 | #[async_trait::async_trait] 170 | impl CachableObject for (TimeTable, String, String, String) { 171 | async fn insert_to_cache( 172 | &self, 173 | cache: &C, 174 | _information: Option<&str>, 175 | ) -> Result<(), CacheInsertError> { 176 | let key = format!("iris.station-board.plan.{}.{}.{}", self.1, self.2, self.3); 177 | 178 | cache.insert_to_cache(key, &self.0, 180).await 179 | } 180 | } 181 | 182 | #[async_trait::async_trait] 183 | impl CachableObject for (TimeTable, String) { 184 | async fn insert_to_cache( 185 | &self, 186 | cache: &C, 187 | _information: Option<&str>, 188 | ) -> Result<(), CacheInsertError> { 189 | let key = format!("iris.station-board.realtime.{}", self.1); 190 | 191 | cache.insert_to_cache(key, &self.0, 30).await 192 | } 193 | } 194 | 195 | #[async_trait::async_trait] 196 | impl CachableObject for (String, String, RisJourneySearchResponse) { 197 | async fn insert_to_cache( 198 | &self, 199 | cache: &C, 200 | _information: Option<&str>, 201 | ) -> Result<(), CacheInsertError> { 202 | let key = format!( 203 | "ris.journey-search.{}.{}.{}", 204 | self.0, 205 | self.1, 206 | self.2 207 | .journeys 208 | .first() 209 | .map(|first| first.date.clone()) 210 | .unwrap_or_else(|| Berlin 211 | .from_utc_datetime(&chrono::Utc::now().naive_utc()) 212 | .format("%Y-%m-%d") 213 | .to_string()) 214 | ); 215 | 216 | cache.insert_to_cache(key, &self.2.journeys, 600).await 217 | } 218 | } 219 | 220 | #[async_trait::async_trait] 221 | impl CachableObject for RisJourneyDetails { 222 | async fn insert_to_cache( 223 | &self, 224 | cache: &C, 225 | _information: Option<&str>, 226 | ) -> Result<(), CacheInsertError> { 227 | let key = format!("ris.journey-details.{}", self.id); 228 | 229 | cache.insert_to_cache(key, &self, 90).await 230 | } 231 | } 232 | 233 | #[async_trait::async_trait] 234 | impl CachableObject for RisStationBoard { 235 | async fn insert_to_cache( 236 | &self, 237 | cache: &C, 238 | _information: Option<&str>, 239 | ) -> Result<(), CacheInsertError> { 240 | let key = format!( 241 | "ris.station-board.{}.{}.{}", 242 | self.eva, 243 | self.time_start.naive_utc().format("%Y-%m-%dT%H:%M"), 244 | self.time_end.naive_utc().format("%Y-%m-%dT%H:%M") 245 | ); 246 | 247 | cache.insert_to_cache(key, &self, 180).await 248 | } 249 | } 250 | 251 | #[async_trait::async_trait] 252 | impl CachableObject for RisStationInformation { 253 | async fn insert_to_cache( 254 | &self, 255 | cache: &C, 256 | _information: Option<&str>, 257 | ) -> Result<(), CacheInsertError> { 258 | let key = format!("ris.station-information.{}", self.eva); 259 | 260 | cache.insert_to_cache(key, &self, 180).await 261 | } 262 | } 263 | 264 | #[async_trait::async_trait] 265 | impl CachableObject for Vec { 266 | async fn insert_to_cache( 267 | &self, 268 | cache: &C, 269 | information: Option<&str>, 270 | ) -> Result<(), CacheInsertError> { 271 | let key = format!("ris.station-search-by-name.{}", information.unwrap_or("")); 272 | 273 | cache.insert_to_cache(key, &self, 60 * 60).await 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /railboard-api/src/custom.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{routing::get, Router}; 4 | 5 | use crate::SharedState; 6 | 7 | pub mod station_board; 8 | pub mod station_board_v2; 9 | 10 | pub fn router_v1() -> Router> { 11 | Router::new().route("/station_board/:id", get(station_board::station_board)) 12 | } 13 | 14 | pub fn router_v2() -> Router> { 15 | Router::new().route( 16 | "/station_board/:id", 17 | get(station_board_v2::station_board_v2), 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /railboard-api/src/custom/station_board.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, Query, State}, 5 | Json, 6 | }; 7 | use chrono::{DateTime, Datelike, FixedOffset, TimeZone, Utc}; 8 | use chrono_tz::Europe::Berlin; 9 | use serde::{Deserialize, Serialize}; 10 | 11 | use iris_client::station_board::{message::Message, IrisStationBoard, RouteStop}; 12 | use utoipa::ToSchema; 13 | 14 | use crate::{error::RailboardResult, iris::station_board::iris_station_board, SharedState}; 15 | 16 | #[derive(Deserialize)] 17 | #[serde(rename_all = "camelCase")] 18 | pub struct StationBoardQuery { 19 | pub time_start: Option>, 20 | pub time_end: Option>, 21 | } 22 | 23 | #[utoipa::path( 24 | get, 25 | path = "/v1/station_board/{eva}", 26 | params( 27 | ("eva" = String, Path, description = "The Eva Number of the Station you are requesting"), 28 | ("timeStart" = Option < DateTime < FixedOffset >>, Query, description = "The Start Time of the Time Range you are requesting"), 29 | ("timeEnd" = Option < DateTime < FixedOffset >>, Query, description = "The End Time of the Time Range you are requesting") 30 | ), 31 | tag = "Custom", 32 | responses( 33 | (status = 200, description = "The requested Station Board", body = StationBoard), 34 | (status = 400, description = "The Error returned by the Ris or Iris, will be Variant 2 or Variant 5", body = RailboardApiError), 35 | (status = 500, description = "The Error returned if the request or deserialization fails, will be domain Request", body = RailboardApiError) 36 | ) 37 | )] 38 | pub async fn station_board( 39 | Path(eva): Path, 40 | Query(query): Query, 41 | State(state): State>, 42 | ) -> RailboardResult> { 43 | let time_start = if let Some(time_start) = query.time_start { 44 | Berlin.from_utc_datetime(&time_start.naive_utc()) 45 | } else { 46 | Berlin.from_utc_datetime(&Utc::now().naive_utc()) 47 | }; 48 | 49 | let time_end = if let Some(time_end) = query.time_end { 50 | Berlin.from_utc_datetime(&time_end.naive_utc()) 51 | } else { 52 | Berlin.from_utc_datetime(&(Utc::now().naive_utc() + chrono::Duration::minutes(30))) 53 | }; 54 | 55 | let (ris_station_board, iris_station_board) = tokio::join!( 56 | state 57 | .ris_client 58 | .station_board(&eva, Some(time_start), Some(time_end),), 59 | iris_station_board( 60 | &eva, 61 | time_end, 62 | time_start, 63 | state.iris_client.clone(), 64 | &state.cache 65 | ) 66 | ); 67 | 68 | let ris_station_board = ris_station_board?; 69 | let iris_station_board = iris_station_board.unwrap_or(IrisStationBoard { 70 | station_name: String::new(), 71 | station_eva: String::new(), 72 | stops: vec![], 73 | disruptions: vec![], 74 | }); 75 | 76 | let items = ris_station_board.items; 77 | 78 | let mut items: Vec = 79 | items 80 | .into_iter() 81 | .map(|item| { 82 | let iris_item = 83 | iris_station_board.stops.iter().find(|iris_item| { 84 | iris_item.train_number == item.train_number.to_string() 85 | && iris_item.train_type == item.category 86 | && (iris_item 87 | .arrival 88 | .clone() 89 | .map(|arrival| arrival.planned_time.naive_utc().date().day()) 90 | == item 91 | .arrival 92 | .clone() 93 | .map(|arrival| arrival.time_scheduled.naive_utc().date().day()) 94 | || iris_item.departure.clone().map(|departure| { 95 | departure.planned_time.naive_utc().date().day() 96 | }) == item.departure.clone().map(|departure| { 97 | departure.time_scheduled.naive_utc().date().day() 98 | })) 99 | }); 100 | 101 | let iris_item = iris_item.cloned(); 102 | 103 | StationBoardItem { 104 | ris_id: Some(item.journey_id), 105 | iris_id: iris_item.as_ref().map(|iris| iris.id.clone()), 106 | 107 | station_eva: item.station_eva, 108 | station_name: item.station_name, 109 | 110 | category: item.category, 111 | train_type: item.train_type, 112 | train_number: item.train_number, 113 | line_indicator: item.line_indicator, 114 | 115 | cancelled: item.cancelled, 116 | 117 | arrival: item.arrival.map(|arrival| DepartureArrival { 118 | time_scheduled: arrival.time_scheduled, 119 | time_realtime: arrival.time_realtime, 120 | time_type: Some(arrival.time_type), 121 | wings: iris_item 122 | .clone() 123 | .and_then(|iris| iris.arrival.map(|arrival| arrival.wings)) 124 | .unwrap_or_default(), 125 | }), 126 | departure: item.departure.map(|departure| DepartureArrival { 127 | time_scheduled: departure.time_scheduled, 128 | time_realtime: departure.time_realtime, 129 | time_type: Some(departure.time_type), 130 | wings: iris_item 131 | .clone() 132 | .and_then(|iris| iris.departure.map(|departure| departure.wings)) 133 | .unwrap_or_default(), 134 | }), 135 | 136 | platform_scheduled: item.platform_scheduled, 137 | platform_realtime: item.platform_realtime, 138 | 139 | origin_eva: Some(item.origin_eva), 140 | origin_name: item.origin_name, 141 | destination_eva: Some(item.destination_eva), 142 | destination_name: item.destination_name, 143 | 144 | administation: Some(StationBoardItemAdministration { 145 | id: item.administation.id, 146 | operator_code: item.administation.operator_code, 147 | operator_name: item.administation.operator_name, 148 | ris_operator_name: item.administation.ris_operator_name, 149 | }), 150 | 151 | additional_info: iris_item.map(|iris| IrisInformation { 152 | replaces: iris 153 | .replaces 154 | .map(|replaces| format!("{} {}", replaces.category, replaces.number)), 155 | route: iris.route, 156 | messages: iris.messages, 157 | }), 158 | } 159 | }) 160 | .collect(); 161 | 162 | for stop in iris_station_board.stops.into_iter().filter(|stop| { 163 | stop.arrival 164 | .as_ref() 165 | .map(|arrival| { 166 | arrival.planned_time.naive_utc() >= time_start.naive_utc() 167 | && arrival.planned_time.naive_utc() <= time_end.naive_utc() 168 | }) 169 | .unwrap_or(false) 170 | || stop 171 | .departure 172 | .as_ref() 173 | .map(|departure| { 174 | departure.planned_time.naive_utc() >= time_start.naive_utc() 175 | && departure.planned_time.naive_utc() <= time_end.naive_utc() 176 | }) 177 | .unwrap_or(false) 178 | }) { 179 | if !items 180 | .iter() 181 | .any(|item| item.iris_id == Some(stop.id.clone())) 182 | { 183 | items.push(StationBoardItem { 184 | ris_id: None, 185 | iris_id: Some(stop.id), 186 | station_eva: stop.station_eva, 187 | station_name: stop.station_name, 188 | category: stop.train_type.clone(), 189 | train_type: stop.train_type, 190 | train_number: stop.train_number.parse().unwrap_or(0), 191 | line_indicator: stop.line_indicator, 192 | cancelled: stop.cancelled, 193 | arrival: stop.arrival.map(|arrival| DepartureArrival { 194 | time_scheduled: arrival.planned_time, 195 | time_realtime: arrival.real_time.unwrap_or(arrival.planned_time), 196 | time_type: None, 197 | wings: arrival.wings, 198 | }), 199 | departure: stop.departure.map(|departure| DepartureArrival { 200 | time_scheduled: departure.planned_time, 201 | time_realtime: departure.real_time.unwrap_or(departure.planned_time), 202 | time_type: None, 203 | wings: departure.wings, 204 | }), 205 | platform_scheduled: stop.planned_platform, 206 | platform_realtime: stop.real_platform, 207 | origin_eva: None, 208 | origin_name: stop.route.first().unwrap().name.clone(), 209 | destination_eva: None, 210 | destination_name: stop.route.last().unwrap().name.clone(), 211 | administation: None, 212 | additional_info: Some(IrisInformation { 213 | replaces: stop 214 | .replaces 215 | .map(|replaces| format!("{} {}", replaces.category, replaces.number)), 216 | route: stop.route, 217 | messages: stop.messages, 218 | }), 219 | }); 220 | } 221 | } 222 | 223 | items.sort_by(|a, b| { 224 | a.arrival 225 | .as_ref() 226 | .unwrap_or_else(|| a.departure.as_ref().unwrap()) 227 | .time_scheduled 228 | .cmp( 229 | &b.arrival 230 | .as_ref() 231 | .unwrap_or_else(|| b.departure.as_ref().unwrap()) 232 | .time_scheduled, 233 | ) 234 | }); 235 | 236 | let station_board = StationBoard { 237 | eva: ris_station_board.eva, 238 | name: ris_station_board.name, 239 | time_start: ris_station_board.time_start, 240 | time_end: ris_station_board.time_end, 241 | items, 242 | }; 243 | 244 | Ok(Json(station_board)) 245 | } 246 | 247 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 248 | #[serde(rename_all = "camelCase")] 249 | pub struct StationBoard { 250 | pub eva: String, 251 | pub name: String, 252 | pub time_start: DateTime, 253 | pub time_end: DateTime, 254 | pub items: Vec, 255 | } 256 | 257 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 258 | #[serde(rename_all = "camelCase")] 259 | pub struct StationBoardItem { 260 | #[schema(nullable)] 261 | pub ris_id: Option, 262 | #[schema(nullable)] 263 | pub iris_id: Option, 264 | 265 | pub station_eva: String, 266 | pub station_name: String, 267 | 268 | pub category: String, 269 | pub train_type: String, 270 | pub train_number: u32, 271 | pub line_indicator: String, 272 | 273 | pub cancelled: bool, 274 | 275 | #[schema(nullable)] 276 | pub arrival: Option, 277 | #[schema(nullable)] 278 | pub departure: Option, 279 | 280 | #[schema(nullable)] 281 | pub platform_scheduled: Option, 282 | #[schema(nullable)] 283 | pub platform_realtime: Option, 284 | 285 | #[schema(nullable)] 286 | pub origin_eva: Option, 287 | pub origin_name: String, 288 | #[schema(nullable)] 289 | pub destination_eva: Option, 290 | pub destination_name: String, 291 | 292 | #[schema(nullable)] 293 | pub administation: Option, 294 | 295 | #[schema(nullable)] 296 | pub additional_info: Option, 297 | } 298 | 299 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 300 | #[serde(rename_all = "camelCase")] 301 | pub struct IrisInformation { 302 | #[schema(nullable)] 303 | pub replaces: Option, 304 | pub route: Vec, 305 | pub messages: Vec, 306 | } 307 | 308 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 309 | #[serde(rename_all = "camelCase")] 310 | pub struct StationBoardItemAdministration { 311 | pub id: String, 312 | pub operator_code: String, 313 | pub operator_name: String, 314 | pub ris_operator_name: String, 315 | } 316 | 317 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 318 | #[serde(rename_all = "camelCase")] 319 | pub struct DepartureArrival { 320 | pub time_scheduled: DateTime, 321 | pub time_realtime: DateTime, 322 | #[schema(nullable)] 323 | pub time_type: Option, 324 | 325 | pub wings: Vec, 326 | } 327 | -------------------------------------------------------------------------------- /railboard-api/src/custom/station_board_v2.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, Query, State}, 5 | Json, 6 | }; 7 | use chrono::{DateTime, Datelike, FixedOffset, TimeZone, Utc}; 8 | use chrono_tz::Europe::Berlin; 9 | use serde::{Deserialize, Serialize}; 10 | 11 | use iris_client::station_board::{message::Message, IrisStationBoard, RouteStop}; 12 | use utoipa::ToSchema; 13 | 14 | use crate::{error::RailboardResult, iris::station_board::iris_station_board, SharedState}; 15 | 16 | #[derive(Deserialize)] 17 | #[serde(rename_all = "camelCase")] 18 | pub struct StationBoardQuery { 19 | pub time_start: Option>, 20 | } 21 | 22 | #[utoipa::path( 23 | get, 24 | path = "/v2/station_board/{eva}", 25 | params( 26 | ("eva" = String, Path, description = "The Eva Number of the Station you are requesting, the main difference between v1 and v2 are the datasources, v1 uses the Ris and Iris, v2 uses the Vendo and Iris"), 27 | ("timeStart" = Option < DateTime < FixedOffset >>, Query, description = "The Start Time of the Time Range you are requesting"), 28 | ), 29 | tag = "Custom", 30 | responses( 31 | (status = 200, description = "The requested Station Board", body = StationBoard), 32 | (status = 400, description = "The Error returned by the Ris or Iris, will be Variant 2 or Variant 5", body = RailboardApiError), 33 | (status = 500, description = "The Error returned if the request or deserialization fails, will be domain Request", body = RailboardApiError) 34 | ) 35 | )] 36 | pub async fn station_board_v2( 37 | Path(eva): Path, 38 | Query(query): Query, 39 | State(state): State>, 40 | ) -> RailboardResult> { 41 | let time_start = if let Some(time_start) = query.time_start { 42 | Berlin.from_utc_datetime(&time_start.naive_utc()) 43 | } else { 44 | Berlin.from_utc_datetime(&Utc::now().naive_utc()) 45 | }; 46 | 47 | let time_end = Berlin.from_utc_datetime(&(time_start.naive_utc() + chrono::Duration::hours(1))); 48 | 49 | let (vendo_station_board, iris_station_board) = tokio::join!( 50 | state.vendo_client.station_board(&eva, time_start), 51 | iris_station_board( 52 | &eva, 53 | time_end, 54 | time_start, 55 | state.iris_client.clone(), 56 | &state.cache 57 | ) 58 | ); 59 | 60 | let vendo_station_board = vendo_station_board?; 61 | let iris_station_board = iris_station_board.unwrap_or(IrisStationBoard { 62 | station_name: String::new(), 63 | station_eva: String::new(), 64 | stops: vec![], 65 | disruptions: vec![], 66 | }); 67 | 68 | let items = vendo_station_board.station_board; 69 | 70 | let mut items: Vec = 71 | items 72 | .into_iter() 73 | .map(|item| { 74 | let iris_item = 75 | iris_station_board.stops.iter().find(|iris_item| { 76 | item.name.replace(" ", "") 77 | == format!("{}{}", iris_item.train_type, iris_item.line_indicator) 78 | .replace(" ", "") 79 | && (iris_item 80 | .arrival 81 | .clone() 82 | .map(|arrival| arrival.planned_time.naive_utc().date().day()) 83 | == item 84 | .arrival 85 | .clone() 86 | .map(|arrival| arrival.time.scheduled.naive_utc().date().day()) 87 | || iris_item.departure.clone().map(|departure| { 88 | departure.planned_time.naive_utc().date().day() 89 | }) == item.departure.clone().map(|departure| { 90 | departure.time.scheduled.naive_utc().date().day() 91 | })) 92 | }); 93 | 94 | let iris_item = iris_item.cloned(); 95 | 96 | StationBoardItem { 97 | vendo_id: Some(item.journey_id), 98 | iris_id: iris_item.as_ref().map(|iris| iris.id.clone()), 99 | 100 | station_eva: item.request_station.eva.clone(), 101 | station_name: item.request_station.name.clone(), 102 | 103 | name: item.name.clone(), 104 | short_name: item.short_name.clone(), 105 | category: item.product_type, 106 | train_type: iris_item.clone().map(|iris| iris.train_type).unwrap_or( 107 | item.name 108 | .chars() 109 | .take_while(|c| c.is_ascii_alphabetic()) 110 | .collect(), 111 | ), 112 | train_number: iris_item 113 | .clone() 114 | .map(|iris| iris.train_number.parse().unwrap_or(0)), 115 | line_indicator: iris_item.clone().map(|iris| iris.line_indicator).unwrap_or( 116 | item.name 117 | .split_whitespace() 118 | .last() 119 | .unwrap_or_default() 120 | .to_owned(), 121 | ), 122 | 123 | cancelled: item.notes.iter().any(|note| note == "Halt entfällt"), 124 | 125 | arrival: item.arrival.as_ref().map(|arrival| DepartureArrival { 126 | time_scheduled: arrival.time.scheduled, 127 | time_realtime: arrival.time.realtime, 128 | wings: iris_item 129 | .clone() 130 | .and_then(|iris| iris.arrival.map(|arrival| arrival.wings)) 131 | .unwrap_or_default(), 132 | }), 133 | departure: item.departure.as_ref().map(|departure| DepartureArrival { 134 | time_scheduled: departure.time.scheduled, 135 | time_realtime: departure.time.realtime, 136 | wings: iris_item 137 | .clone() 138 | .and_then(|iris| iris.departure.map(|departure| departure.wings)) 139 | .unwrap_or_default(), 140 | }), 141 | 142 | platform_scheduled: item.scheduled_platform, 143 | platform_realtime: item.realtime_platform, 144 | 145 | origin_eva: item 146 | .arrival 147 | .as_ref() 148 | .map(|_| None) 149 | .unwrap_or(Some(item.request_station.eva.clone())), 150 | origin_name: item 151 | .arrival 152 | .as_ref() 153 | .map(|arrival| arrival.origin.clone()) 154 | .unwrap_or(item.request_station.name.clone()), 155 | destination_eva: item 156 | .departure 157 | .as_ref() 158 | .map(|_| None) 159 | .unwrap_or(Some(item.request_station.eva)), 160 | destination_name: item 161 | .departure 162 | .as_ref() 163 | .map(|departure| departure.destination.clone()) 164 | .unwrap_or(item.request_station.name), 165 | 166 | additional_info: iris_item.map(|iris| IrisInformation { 167 | replaces: iris 168 | .replaces 169 | .map(|replaces| format!("{} {}", replaces.category, replaces.number)), 170 | route: iris.route, 171 | messages: iris.messages, 172 | }), 173 | } 174 | }) 175 | .collect(); 176 | 177 | for stop in iris_station_board.stops.into_iter().filter(|stop| { 178 | stop.arrival 179 | .as_ref() 180 | .map(|arrival| { 181 | arrival.planned_time.naive_utc() >= time_start.naive_utc() 182 | && arrival.planned_time.naive_utc() <= time_end.naive_utc() 183 | }) 184 | .unwrap_or(false) 185 | || stop 186 | .departure 187 | .as_ref() 188 | .map(|departure| { 189 | departure.planned_time.naive_utc() >= time_start.naive_utc() 190 | && departure.planned_time.naive_utc() <= time_end.naive_utc() 191 | }) 192 | .unwrap_or(false) 193 | }) { 194 | if !items 195 | .iter() 196 | .any(|item| item.iris_id == Some(stop.id.clone())) 197 | { 198 | items.push(StationBoardItem { 199 | vendo_id: None, 200 | iris_id: Some(stop.id), 201 | station_eva: stop.station_eva, 202 | station_name: stop.station_name, 203 | name: format!("{} {}", stop.train_type, stop.line_indicator), 204 | short_name: stop.train_type.clone(), 205 | category: stop.train_type.clone(), 206 | train_type: stop.train_type, 207 | train_number: stop.train_number.parse().map(Some).unwrap_or(None), 208 | line_indicator: stop.line_indicator, 209 | cancelled: stop.cancelled, 210 | arrival: stop.arrival.map(|arrival| DepartureArrival { 211 | time_scheduled: arrival.planned_time, 212 | time_realtime: arrival.real_time, 213 | wings: arrival.wings, 214 | }), 215 | departure: stop.departure.map(|departure| DepartureArrival { 216 | time_scheduled: departure.planned_time, 217 | time_realtime: departure.real_time, 218 | wings: departure.wings, 219 | }), 220 | platform_scheduled: stop.planned_platform, 221 | platform_realtime: stop.real_platform, 222 | origin_eva: None, 223 | origin_name: stop.route.first().unwrap().name.clone(), 224 | destination_eva: None, 225 | destination_name: stop.route.last().unwrap().name.clone(), 226 | additional_info: Some(IrisInformation { 227 | replaces: stop 228 | .replaces 229 | .map(|replaces| format!("{} {}", replaces.category, replaces.number)), 230 | route: stop.route, 231 | messages: stop.messages, 232 | }), 233 | }); 234 | } 235 | } 236 | 237 | items.sort_by(|a, b| { 238 | a.arrival 239 | .as_ref() 240 | .unwrap_or_else(|| a.departure.as_ref().unwrap()) 241 | .time_scheduled 242 | .cmp( 243 | &b.arrival 244 | .as_ref() 245 | .unwrap_or_else(|| b.departure.as_ref().unwrap()) 246 | .time_scheduled, 247 | ) 248 | }); 249 | 250 | let station_board = StationBoard { 251 | eva, 252 | time_start: time_start.fixed_offset(), 253 | time_end: time_end.fixed_offset(), 254 | items, 255 | }; 256 | 257 | Ok(Json(station_board)) 258 | } 259 | 260 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 261 | #[serde(rename_all = "camelCase")] 262 | pub struct StationBoard { 263 | pub eva: String, 264 | pub time_start: DateTime, 265 | pub time_end: DateTime, 266 | pub items: Vec, 267 | } 268 | 269 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 270 | #[serde(rename_all = "camelCase")] 271 | pub struct StationBoardItem { 272 | #[schema(nullable)] 273 | pub vendo_id: Option, 274 | #[schema(nullable)] 275 | pub iris_id: Option, 276 | 277 | pub station_eva: String, 278 | pub station_name: String, 279 | 280 | pub name: String, 281 | pub short_name: String, 282 | pub category: String, 283 | pub train_type: String, 284 | #[schema(nullable)] 285 | pub train_number: Option, 286 | pub line_indicator: String, 287 | 288 | pub cancelled: bool, 289 | 290 | #[schema(nullable)] 291 | pub arrival: Option, 292 | #[schema(nullable)] 293 | pub departure: Option, 294 | 295 | #[schema(nullable)] 296 | pub platform_scheduled: Option, 297 | #[schema(nullable)] 298 | pub platform_realtime: Option, 299 | 300 | #[schema(nullable)] 301 | pub origin_eva: Option, 302 | pub origin_name: String, 303 | #[schema(nullable)] 304 | pub destination_eva: Option, 305 | pub destination_name: String, 306 | 307 | #[schema(nullable)] 308 | pub additional_info: Option, 309 | } 310 | 311 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 312 | #[serde(rename_all = "camelCase")] 313 | pub struct IrisInformation { 314 | #[schema(nullable)] 315 | pub replaces: Option, 316 | pub route: Vec, 317 | pub messages: Vec, 318 | } 319 | 320 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 321 | #[serde(rename_all = "camelCase")] 322 | pub struct DepartureArrival { 323 | pub time_scheduled: DateTime, 324 | pub time_realtime: Option>, 325 | 326 | pub wings: Vec, 327 | } 328 | -------------------------------------------------------------------------------- /railboard-api/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::num::ParseIntError; 2 | 3 | use axum::{response::IntoResponse, Json}; 4 | use iris_client::IrisOrRequestError; 5 | use reqwest::StatusCode; 6 | use ris_client::{RisError, RisOrRequestError, RisUnauthorizedError, ZugportalError}; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | use utoipa::ToSchema; 10 | use vendo_client::{VendoError, VendoOrRequestError}; 11 | 12 | #[derive(Debug, Serialize, Deserialize, ToSchema)] 13 | pub struct RailboardApiError { 14 | pub domain: ErrorDomain, 15 | pub message: String, 16 | #[schema(nullable)] 17 | pub error: Option, 18 | } 19 | 20 | #[derive(Debug, Serialize, Deserialize, ToSchema)] 21 | #[serde(rename = "lowercase")] 22 | pub enum ErrorDomain { 23 | Vendo, 24 | Iris, 25 | Ris, 26 | Input, 27 | Request, 28 | } 29 | 30 | #[derive(Debug, Serialize, Deserialize, ToSchema)] 31 | #[serde(tag = "errorType")] 32 | pub enum UnderlyingApiError { 33 | #[serde(rename = "vendo")] 34 | Vendo(VendoError), 35 | #[serde(rename = "iris")] 36 | Iris, 37 | #[serde(rename = "ris-error")] 38 | RisError(RisError), 39 | #[serde(rename = "ris-unauthorized")] 40 | RisUnauthorizedError(RisUnauthorizedError), 41 | #[serde(rename = "zugportal-error")] 42 | ZugportalError(ZugportalError), 43 | } 44 | 45 | pub type RailboardResult = std::result::Result; 46 | 47 | impl IntoResponse for RailboardApiError { 48 | fn into_response(self) -> axum::response::Response { 49 | let code = match self.domain { 50 | ErrorDomain::Vendo => StatusCode::BAD_REQUEST, 51 | ErrorDomain::Iris => StatusCode::BAD_REQUEST, 52 | ErrorDomain::Ris => StatusCode::BAD_REQUEST, 53 | ErrorDomain::Input => StatusCode::BAD_REQUEST, 54 | ErrorDomain::Request => StatusCode::INTERNAL_SERVER_ERROR, 55 | }; 56 | (code, Json(self)).into_response() 57 | } 58 | } 59 | 60 | impl From for RailboardApiError { 61 | fn from(value: ParseIntError) -> Self { 62 | RailboardApiError { 63 | domain: ErrorDomain::Input, 64 | message: format!("Required Integer but found: {value}"), 65 | error: None, 66 | } 67 | } 68 | } 69 | 70 | impl From for RailboardApiError { 71 | fn from(value: VendoOrRequestError) -> Self { 72 | match value { 73 | VendoOrRequestError::FailedRequest(err) => RailboardApiError { 74 | domain: ErrorDomain::Request, 75 | message: format!("Failed to get from Vendo: {err}"), 76 | error: None, 77 | }, 78 | VendoOrRequestError::VendoError(err) => RailboardApiError { 79 | domain: ErrorDomain::Vendo, 80 | message: format!("Failed to get from Vendo: {err}"), 81 | error: Some(UnderlyingApiError::Vendo(err)), 82 | }, 83 | } 84 | } 85 | } 86 | 87 | impl From for RailboardApiError { 88 | fn from(value: IrisOrRequestError) -> Self { 89 | match value { 90 | IrisOrRequestError::FailedRequest(err) => RailboardApiError { 91 | domain: ErrorDomain::Request, 92 | message: format!("Failed to get from Iris: {err}"), 93 | error: None, 94 | }, 95 | IrisOrRequestError::IrisError(err) => RailboardApiError { 96 | domain: ErrorDomain::Iris, 97 | message: format!("Failed to get from Iris: {err}"), 98 | error: Some(UnderlyingApiError::Iris), 99 | }, 100 | IrisOrRequestError::InvalidXML(err) => RailboardApiError { 101 | domain: ErrorDomain::Iris, 102 | message: format!("Got invalid/unrecognized xml from Iris: {err}"), 103 | error: None, 104 | }, 105 | } 106 | } 107 | } 108 | 109 | impl From for RailboardApiError { 110 | fn from(value: RisOrRequestError) -> Self { 111 | match value { 112 | RisOrRequestError::FailedRequest(err) => RailboardApiError { 113 | domain: ErrorDomain::Request, 114 | message: format!("Failed to get from Ris: {err}"), 115 | error: None, 116 | }, 117 | RisOrRequestError::RisError(err) => RailboardApiError { 118 | domain: ErrorDomain::Ris, 119 | message: format!("Failed to get from Ris: {err}"), 120 | error: Some(UnderlyingApiError::RisError(err)), 121 | }, 122 | RisOrRequestError::RisUnauthorizedError(err) => RailboardApiError { 123 | domain: ErrorDomain::Ris, 124 | message: format!("The underlying request to ris was unauthorized: {err}"), 125 | error: Some(UnderlyingApiError::RisUnauthorizedError(err)), 126 | }, 127 | RisOrRequestError::ZugportalError(err) => RailboardApiError { 128 | domain: ErrorDomain::Ris, 129 | message: format!("Failed to get from Ris (through Zugportal): {err}"), 130 | error: Some(UnderlyingApiError::ZugportalError(err)), 131 | }, 132 | RisOrRequestError::NotFoundError => RailboardApiError { 133 | domain: ErrorDomain::Input, 134 | message: "There was nothing found with these parameters".to_string(), 135 | error: None, 136 | }, 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /railboard-api/src/iris.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{routing::get, Router}; 4 | 5 | use crate::SharedState; 6 | 7 | pub mod station_board; 8 | 9 | pub fn router() -> Router> { 10 | Router::new().route("/station_board/:id", get(station_board::station_board)) 11 | } 12 | -------------------------------------------------------------------------------- /railboard-api/src/iris/station_board.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, Query, State}, 5 | Json, 6 | }; 7 | use chrono::{DateTime, Duration, FixedOffset, TimeZone, Timelike}; 8 | use chrono_tz::{Europe::Berlin, Tz}; 9 | use serde::Deserialize; 10 | use utoipa::IntoParams; 11 | 12 | use iris_client::{ 13 | station_board::{from_iris_timetable, response::TimeTable, IrisStationBoard}, 14 | IrisClient, IrisOrRequestError, 15 | }; 16 | 17 | use crate::{ 18 | cache::{self, CachableObject, Cache}, 19 | error::RailboardResult, 20 | SharedState, 21 | }; 22 | 23 | #[derive(Deserialize, IntoParams)] 24 | pub struct IrisStationBoardQuery { 25 | /// The date to request the station board for. If not provided, the current date is used. 26 | pub date: Option>, 27 | /// The time to request data for in the past 28 | pub lookbehind: Option, 29 | /// The time to request data for in the future 30 | pub lookahead: Option, 31 | } 32 | 33 | #[utoipa::path( 34 | get, 35 | path = "/iris/v1/station_board/{eva}", 36 | params( 37 | ("eva" = String, Path, description = "The eva number of the Station you are requesting"), 38 | IrisStationBoardQuery 39 | ), 40 | tag = "Iris", 41 | responses( 42 | (status = 200, description = "The requested Station Board", body = IrisStationBoard), 43 | (status = 400, description = "The Error returned by Iris, will be the Iris Domain with UnderlyingApiError Variant 2, which has no Information because Iris doesn't return errors", body = RailboardApiError), 44 | (status = 500, description = "The Error returned if the request or deserialization fails", body = RailboardApiError) 45 | ) 46 | )] 47 | pub async fn station_board( 48 | Path(eva): Path, 49 | Query(params): Query, 50 | State(state): State>, 51 | ) -> RailboardResult> { 52 | let lookbehind = params.lookbehind.unwrap_or(20); 53 | let lookahead = params.lookahead.unwrap_or(180); 54 | 55 | let date = if let Some(date) = params.date { 56 | Berlin.from_utc_datetime(&date.naive_utc()) 57 | } else { 58 | Berlin.from_utc_datetime(&chrono::Utc::now().naive_utc()) 59 | }; 60 | 61 | let lookbehind = date - chrono::Duration::minutes(lookbehind as i64); 62 | let lookahead = date + chrono::Duration::minutes(lookahead as i64); 63 | 64 | let station_board = iris_station_board( 65 | &eva, 66 | lookahead, 67 | lookbehind, 68 | state.iris_client.clone(), 69 | &state.cache, 70 | ) 71 | .await?; 72 | 73 | Ok(Json(station_board)) 74 | } 75 | 76 | pub async fn iris_station_board( 77 | eva: &str, 78 | lookahead: DateTime, 79 | lookbehind: DateTime, 80 | iris_client: Arc, 81 | cache: &cache::RedisCache, 82 | ) -> RailboardResult { 83 | let mut dates = Vec::new(); 84 | 85 | for current_date in DateRange(lookbehind, lookahead) { 86 | dates.push(current_date); 87 | } 88 | 89 | let (realtime, timetables) = tokio::join!( 90 | get_realtime(iris_client.clone(), cache, eva), 91 | futures::future::join_all(dates.iter().map(|date| async { 92 | if let Some(cached) = cache 93 | .get_from_id::(&format!( 94 | "iris.station-board.plan.{}.{}.{}", 95 | eva, 96 | date.format("%Y-%m-%d"), 97 | date.format("%H") 98 | )) 99 | .await 100 | { 101 | return Ok(cached); 102 | } 103 | let timetable = iris_client 104 | .as_ref() 105 | .planned_station_board( 106 | eva, 107 | &date.format("%y%m%d").to_string(), 108 | &date.format("%H").to_string(), 109 | ) 110 | .await; 111 | match timetable { 112 | Ok(timetable) => { 113 | let cache_timetable = ( 114 | timetable.clone(), 115 | eva.to_string(), 116 | date.format("%Y-%m-%d").to_string(), 117 | date.format("%H").to_string(), 118 | ); 119 | let cache = cache.clone(); 120 | tokio::spawn( 121 | async move { cache_timetable.insert_to_cache(&cache, None).await }, 122 | ); 123 | Ok(timetable) 124 | } 125 | Err(err) => Err(err), 126 | } 127 | })) 128 | ); 129 | 130 | let realtime = realtime?; 131 | let timetables = timetables 132 | .into_iter() 133 | .filter_map(|result| result.ok()) 134 | .collect::>(); 135 | 136 | let disruptions = realtime 137 | .disruptions 138 | .into_iter() 139 | .map(|message| message.into()) 140 | .collect::>(); 141 | 142 | let mut stops = Vec::new(); 143 | 144 | // TODO: find additional stops in realtime that are not in planned 145 | // realtime.stops.iter().filter(|stop| todo!()); 146 | 147 | for timetable in timetables { 148 | for stop in timetable.stops { 149 | let realtime = realtime 150 | .stops 151 | .iter() 152 | .find(|realtime_stop| realtime_stop.id == stop.id); 153 | stops.push(from_iris_timetable( 154 | eva, 155 | &timetable.station_name, 156 | stop, 157 | realtime.map(|realtime| realtime.to_owned()), 158 | )); 159 | } 160 | } 161 | 162 | let station_board = IrisStationBoard { 163 | station_name: realtime.station_name, 164 | station_eva: String::from(eva), 165 | disruptions, 166 | stops, 167 | }; 168 | 169 | Ok(station_board) 170 | } 171 | 172 | struct DateRange(DateTime, DateTime); 173 | 174 | impl Iterator for DateRange { 175 | type Item = DateTime; 176 | fn next(&mut self) -> Option { 177 | if self.0 <= self.1 || self.0.hour() == self.1.hour() { 178 | let next = self.0 + Duration::hours(1); 179 | Some(std::mem::replace(&mut self.0, next)) 180 | } else { 181 | None 182 | } 183 | } 184 | } 185 | 186 | async fn get_realtime( 187 | iris_client: Arc, 188 | cache: &cache::RedisCache, 189 | id: &str, 190 | ) -> Result { 191 | if let Some(cached) = &cache 192 | .get_from_id::(&format!("iris.station-board.realtime.{}", id.to_owned())) 193 | .await 194 | { 195 | return Ok(cached.to_owned()); 196 | } 197 | let realtime = iris_client.as_ref().realtime_station_board(id).await; 198 | 199 | match realtime { 200 | Ok(realtime) => { 201 | let cache_realtime = (realtime.clone(), id.to_owned()); 202 | let cache = cache.clone(); 203 | tokio::spawn(async move { cache_realtime.insert_to_cache(&cache, None).await }); 204 | Ok(realtime) 205 | } 206 | Err(err) => Err(err), 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /railboard-api/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{Router, Server}; 4 | use dotenvy::dotenv; 5 | use reqwest::Client; 6 | #[cfg(unix)] 7 | use tokio::signal::unix::SignalKind; 8 | use tracing::metadata::LevelFilter; 9 | use tracing_subscriber::{fmt, prelude::*, EnvFilter}; 10 | use utoipa::OpenApi; 11 | use utoipa_swagger_ui::SwaggerUi; 12 | 13 | use iris_client::IrisClient; 14 | use ris_client::RisClient; 15 | use vendo_client::VendoClient; 16 | 17 | use crate::cache::RedisCache; 18 | 19 | pub mod cache; 20 | pub mod error; 21 | 22 | pub mod custom; 23 | pub mod iris; 24 | pub mod ris; 25 | pub mod vendo; 26 | 27 | #[derive(OpenApi)] 28 | #[openapi( 29 | paths( 30 | vendo::station_board::station_board, 31 | vendo::location_search::location_search, 32 | vendo::journey_details::journey_details, 33 | iris::station_board::station_board, 34 | ris::journey_search::journey_search, 35 | ris::journey_details::journey_details, 36 | ris::station_board::station_board, 37 | ris::station_information::station_information, 38 | ris::station_search_by_name::station_search_by_name, 39 | custom::station_board::station_board, 40 | custom::station_board_v2::station_board_v2, 41 | ), 42 | components(schemas( 43 | error::RailboardApiError, 44 | error::ErrorDomain, 45 | error::UnderlyingApiError, 46 | // Vendo stuff 47 | vendo_client::VendoError, 48 | vendo_client::shared::Time, 49 | vendo_client::shared::Notice, 50 | vendo_client::shared::HimNotice, 51 | vendo_client::shared::Attribute, 52 | vendo_client::station_board::VendoStationBoard, 53 | vendo_client::station_board::StationBoardElement, 54 | vendo_client::station_board::StationBoardArrival, 55 | vendo_client::station_board::StationBoardDeparture, 56 | vendo_client::location_search::VendoLocationSearchResult, 57 | vendo_client::location_search::VendoLocationSearchCoordinates, 58 | vendo_client::journey_details::VendoJourneyDetails, 59 | vendo_client::journey_details::VendoTrainSchedule, 60 | vendo_client::journey_details::VendoStop, 61 | // Iris stuff 62 | iris_client::station_board::IrisStationBoard, 63 | iris_client::station_board::StationBoardStop, 64 | iris_client::station_board::StationBoardStopArrival, 65 | iris_client::station_board::StationBoardStopDeparture, 66 | iris_client::station_board::RouteStop, 67 | iris_client::station_board::ReplacedTrain, 68 | iris_client::station_board::message::Message, 69 | iris_client::station_board::message::MessageStatus, 70 | iris_client::station_board::message::MessagePriority, 71 | // Ris stuff 72 | ris_client::RisError, 73 | ris_client::RisUnauthorizedError, 74 | ris_client::ZugportalError, 75 | ris_client::journey_search::RisJourneySearchElement, 76 | ris_client::journey_search::RisJourneySearchSchedule, 77 | ris_client::journey_search::RisJourneySearchTransport, 78 | ris_client::journey_details::RisJourneyDetails, 79 | ris_client::journey_details::RisJourneyStop, 80 | ris_client::journey_details::RisJourneyStopEvent, 81 | ris_client::journey_details::RisJourneyStopAdministration, 82 | ris_client::journey_details::RisJourneyStopDisruption, 83 | ris_client::journey_details::RisTransport, 84 | ris_client::journey_details::RisReplacementTransport, 85 | ris_client::journey_details::RisJourneyDetailsMessage, 86 | ris_client::station_board::RisStationBoard, 87 | ris_client::station_board::RisStationBoardItem, 88 | ris_client::station_board::RisStationBoardItemAdministration, 89 | ris_client::station_board::DepartureArrival, 90 | ris_client::station_information::RisStationInformation, 91 | ris_client::station_information::RisStationNameContent, 92 | ris_client::station_information::RisPosition, 93 | ris_client::station_search::RisStationSearchResponse, 94 | ris_client::station_search::RisStationSearchElement, 95 | ris_client::station_search::RisStationSearchTranslatable, 96 | ris_client::station_search::RisStationSearchNameContent, 97 | // Custom stuff 98 | custom::station_board::StationBoard, 99 | custom::station_board::StationBoardItem, 100 | custom::station_board::StationBoardItemAdministration, 101 | custom::station_board::DepartureArrival, 102 | custom::station_board::IrisInformation, 103 | // custom v2 104 | // Custom stuff 105 | custom::station_board_v2::StationBoard, 106 | custom::station_board_v2::StationBoardItem, 107 | custom::station_board_v2::DepartureArrival, 108 | custom::station_board_v2::IrisInformation, 109 | )), 110 | tags( 111 | (name = "Iris", description = "API using the Iris API as Backend"), 112 | (name = "Ris", description = "API using the Ris API as Backend"), 113 | (name = "Custom", description = "API not using a single API as Backend, but rather a combination of multiple sources"), 114 | (name = "Vendo", description = "API using the Vendo API as Backend"), 115 | ) 116 | )] 117 | struct ApiDoc; 118 | 119 | #[tokio::main] 120 | async fn main() { 121 | dotenv().ok(); 122 | 123 | tracing_subscriber::registry() 124 | .with(fmt::layer()) 125 | .with( 126 | EnvFilter::builder() 127 | .with_default_directive(LevelFilter::DEBUG.into()) 128 | .from_env_lossy(), 129 | ) 130 | .init(); 131 | 132 | let redis_client = { 133 | let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| { 134 | tracing::warn!( 135 | "REDIS_URL env variable is not set. Using default \"redis://127.0.0.1/\"" 136 | ); 137 | String::from("redis://127.0.0.1/") 138 | }); 139 | redis::Client::open(redis_url).expect("Failed create redis client, check redis url") 140 | }; 141 | 142 | let redis_client = Arc::new(redis_client); 143 | 144 | let ris_api_key = std::env::var("RIS_API_KEY").expect("RIS_API_KEY env variable is not set"); 145 | let ris_client_id = 146 | std::env::var("RIS_CLIENT_ID").expect("RIS_CLIENT_ID env variable is not set"); 147 | 148 | let http_client = Client::builder() 149 | // .add_root_certificate(Certificate::from_pem(include_bytes!("../../mitm.pem")).unwrap()) 150 | // .proxy(Proxy::all("http://localhost:8080").unwrap()) 151 | .build() 152 | .unwrap(); 153 | 154 | let ris_client = Arc::new(RisClient::new( 155 | Some(http_client.clone()), 156 | None, 157 | None, 158 | &ris_client_id, 159 | &ris_api_key, 160 | )); 161 | 162 | let iris_client = Arc::new(IrisClient::new(Some(http_client.clone()), None, None)); 163 | 164 | let vendo_client = Arc::new(VendoClient::new(Some(http_client.clone()), None, None)); 165 | 166 | let app = Router::new() 167 | .merge(SwaggerUi::new("/docs").url("/openapi.json", ApiDoc::openapi())) 168 | .nest("/vendo/v1", vendo::router()) 169 | .nest("/iris/v1", iris::router()) 170 | .nest("/ris/v1", ris::router()) 171 | .nest("/v1", custom::router_v1()) 172 | .nest("/v2", custom::router_v2()) 173 | .with_state(Arc::new(SharedState { 174 | vendo_client, 175 | ris_client, 176 | iris_client, 177 | cache: RedisCache::new(redis_client), 178 | })) 179 | .fallback(|| async { "Nothing here :/" }); 180 | 181 | let bind_addr = std::env::var("API_URL").unwrap_or_else(|_| String::from("0.0.0.0:8069")); 182 | 183 | let server = Server::bind(&bind_addr.parse().unwrap()) 184 | .serve(app.into_make_service()) 185 | .with_graceful_shutdown(shutdown_hook()); 186 | tracing::info!("Listening on {}", bind_addr); 187 | server.await.unwrap(); 188 | } 189 | 190 | pub struct SharedState { 191 | vendo_client: Arc, 192 | ris_client: Arc, 193 | iris_client: Arc, 194 | cache: RedisCache, 195 | } 196 | 197 | async fn shutdown_hook() { 198 | #[cfg(unix)] 199 | tokio::select! { 200 | _ = async { 201 | let mut signal = ::tokio::signal::unix::signal(SignalKind::interrupt()).unwrap(); 202 | signal.recv().await; 203 | } => { 204 | tracing::info!("Received SIGINT. Shutting down."); 205 | }, 206 | _ = async { 207 | let mut signal = ::tokio::signal::unix::signal(SignalKind::terminate()).unwrap(); 208 | signal.recv().await; 209 | } => { 210 | tracing::info!("Received SIGTERM. Shutting down."); 211 | }, 212 | } 213 | #[cfg(not(unix))] 214 | tokio::signal::ctrl_c().await.unwrap() 215 | } 216 | -------------------------------------------------------------------------------- /railboard-api/src/ris.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{routing::get, Router}; 4 | 5 | use crate::SharedState; 6 | 7 | pub mod journey_details; 8 | pub mod journey_search; 9 | pub mod station_board; 10 | pub mod station_information; 11 | pub mod station_search_by_name; 12 | 13 | #[allow(deprecated)] 14 | pub fn router() -> Router> { 15 | Router::new() 16 | .route( 17 | "/journey_search/:category/:number", 18 | get(journey_search::journey_search), 19 | ) 20 | .route( 21 | "/journey_details/:id", 22 | get(journey_details::journey_details), 23 | ) 24 | .route("/station_board/:eva", get(station_board::station_board)) 25 | .route( 26 | "/station/:eva", 27 | get(station_information::station_information), 28 | ) 29 | .route( 30 | "/station_search/:query", 31 | get(station_search_by_name::station_search_by_name), 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /railboard-api/src/ris/journey_details.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, State}, 5 | Json, 6 | }; 7 | 8 | use ris_client::journey_details::RisJourneyDetails; 9 | 10 | use crate::{ 11 | cache::{CachableObject, Cache}, 12 | error::RailboardResult, 13 | SharedState, 14 | }; 15 | 16 | #[utoipa::path( 17 | get, 18 | path = "/ris/v1/journey_details/{id}", 19 | params( 20 | ("id" = String, Path, description = "The id of this journey (can be optained e.G. through the journey search endpoint)") 21 | ), 22 | tag = "Ris", 23 | responses( 24 | (status = 200, description = "The requested Journey Details", body = RisJourneyDetails), 25 | (status = 400, description = "The Error returned by Ris, will be the Ris Domain with UnderlyingApiError Variant 3 or 4", body = RailboardApiError), 26 | (status = 500, description = "The Error returned if the request or deserialization fails, will be domain Request", body = RailboardApiError) 27 | ), 28 | )] 29 | #[allow(deprecated)] 30 | #[deprecated(note = "the endpoint is not being maintained anymore, see ris-client")] 31 | pub async fn journey_details( 32 | Path(id): Path, 33 | state: State>, 34 | ) -> RailboardResult> { 35 | if let Some(cached) = state 36 | .cache 37 | .get_from_id(&format!("ris.journey-details.{}", &id)) 38 | .await 39 | { 40 | return Ok(Json(cached)); 41 | } 42 | 43 | let response = state.ris_client.journey_details(&id).await?; 44 | 45 | { 46 | let response = response.clone(); 47 | tokio::spawn(async move { response.insert_to_cache(&state.cache, None).await }); 48 | } 49 | 50 | Ok(Json(response)) 51 | } 52 | -------------------------------------------------------------------------------- /railboard-api/src/ris/journey_search.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, Query, State}, 5 | Json, 6 | }; 7 | use chrono::{NaiveDate, TimeZone}; 8 | use chrono_tz::Europe::Berlin; 9 | use serde::Deserialize; 10 | 11 | use ris_client::journey_search::RisJourneySearchElement; 12 | 13 | use crate::{ 14 | cache::{CachableObject, Cache}, 15 | error::RailboardResult, 16 | SharedState, 17 | }; 18 | 19 | #[derive(Deserialize)] 20 | pub struct JounreySearchPath { 21 | pub number: String, 22 | pub category: String, 23 | } 24 | 25 | #[derive(Deserialize)] 26 | pub struct JounreySearchQuery { 27 | pub date: Option, 28 | } 29 | 30 | #[utoipa::path( 31 | get, 32 | path = "/ris/v1/journey_search/{category}/{number}", 33 | params( 34 | ("category" = String, Path, description = "The category of this Train (e.g. ICE, IC, RE, VIA, ...)"), 35 | ("number" = String, Path, description = "The number of this Train (e.g. for ICE 2929 it would be 2929 and for RE 1 it could be 4570)"), 36 | ("date" = Option, Query, description = "The date this train is running and should be searched for (e.g. 2023-01-25)") 37 | ), 38 | tag = "Ris", 39 | responses( 40 | (status = 200, description = "The requested Journey Details", body = [RisJourneySearchElement]), 41 | (status = 400, description = "The Error returned by Ris, will be the Ris Domain with UnderlyingApiError Variant 3 or 4", body = RailboardApiError), 42 | (status = 500, description = "The Error returned if the request or deserialization fails, will be domain Request", body = RailboardApiError) 43 | ) 44 | )] 45 | #[allow(deprecated)] 46 | #[deprecated(note = "the endpoint is not being maintained anymore, see ris-client")] 47 | pub async fn journey_search( 48 | Path(path): Path, 49 | Query(query): Query, 50 | State(state): State>, 51 | ) -> RailboardResult>> { 52 | let category = path.category; 53 | let number = path.number; 54 | let date = query.date; 55 | 56 | if let Some(cached) = state 57 | .cache 58 | .get_from_id(&format!( 59 | "ris.journey-search.{}.{}.{}", 60 | &category, 61 | &number, 62 | &date 63 | .map(|date| date.format("%Y-%m-%d").to_string()) 64 | .unwrap_or_else(|| Berlin 65 | .from_utc_datetime(&chrono::Utc::now().naive_utc()) 66 | .format("%Y-%m-%d") 67 | .to_string()) 68 | )) 69 | .await 70 | { 71 | return Ok(Json(cached)); 72 | } 73 | 74 | let response = state 75 | .ris_client 76 | .journey_search(&category, &number, date) 77 | .await?; 78 | 79 | { 80 | let response = response.clone(); 81 | tokio::spawn(async move { 82 | let cache = state.cache.clone(); 83 | let category = category.clone(); 84 | let number = number.clone(); 85 | (category, number, response) 86 | .insert_to_cache(&cache, None) 87 | .await 88 | }); 89 | } 90 | 91 | Ok(Json(response.journeys)) 92 | } 93 | -------------------------------------------------------------------------------- /railboard-api/src/ris/station_board.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, Query, State}, 5 | Json, 6 | }; 7 | use chrono::{DateTime, FixedOffset, TimeZone}; 8 | use chrono_tz::Europe::Berlin; 9 | use serde::Deserialize; 10 | 11 | use ris_client::station_board::RisStationBoard; 12 | 13 | use crate::{ 14 | cache::{CachableObject, Cache}, 15 | error::RailboardResult, 16 | SharedState, 17 | }; 18 | 19 | #[derive(Deserialize)] 20 | #[serde(rename_all = "camelCase")] 21 | pub struct StationBoardQuery { 22 | pub time_start: Option>, 23 | pub time_end: Option>, 24 | } 25 | 26 | #[utoipa::path( 27 | get, 28 | path = "/ris/v1/station_board/{eva}", 29 | params( 30 | ("eva" = String, Path, description = "The Eva Number of the Station you are requesting"), 31 | ("timeStart" = Option < String >, Query, description = "The Start Time of the Time Range you are requesting"), 32 | ("timeEnd" = Option < String >, Query, description = "The End Time of the Time Range you are requesting") 33 | ), 34 | tag = "Ris", 35 | responses( 36 | (status = 200, description = "The requested Station Board", body = RisStationBoard), 37 | (status = 400, description = "The Error returned by the Zugportal API (Ris), will be the Ris Domain with UnderlyingApiError Variant 5", body = RailboardApiError), 38 | (status = 500, description = "The Error returned if the request or deserialization fails, will be domain Request", body = RailboardApiError) 39 | ) 40 | )] 41 | pub async fn station_board( 42 | Path(eva): Path, 43 | Query(query): Query, 44 | State(state): State>, 45 | ) -> RailboardResult> { 46 | let time_start = query 47 | .time_start 48 | .map(|time_start| Berlin.from_utc_datetime(&time_start.naive_utc())); 49 | 50 | let time_end = query 51 | .time_end 52 | .map(|time_end| Berlin.from_utc_datetime(&time_end.naive_utc())); 53 | 54 | if let (Some(time_start), Some(time_end)) = (time_start, time_end) { 55 | if let Some(cached) = state 56 | .cache 57 | .get_from_id(&format!( 58 | "ris.station-board.{}.{}.{}", 59 | eva, 60 | time_start.naive_utc().format("%Y-%m-%dT%H:%M"), 61 | time_end.naive_utc().format("%Y-%m-%dT%H:%M") 62 | )) 63 | .await 64 | { 65 | return Ok(Json(cached)); 66 | } 67 | } 68 | 69 | let station_board = state 70 | .ris_client 71 | .station_board(&eva, time_start, time_end) 72 | .await?; 73 | 74 | { 75 | let station_board = station_board.clone(); 76 | tokio::spawn(async move { 77 | let _ = station_board.insert_to_cache(&state.cache, None).await; 78 | }); 79 | } 80 | 81 | Ok(Json(station_board)) 82 | } 83 | -------------------------------------------------------------------------------- /railboard-api/src/ris/station_information.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, State}, 5 | Json, 6 | }; 7 | 8 | use ris_client::station_information::RisStationInformation; 9 | 10 | use crate::{ 11 | cache::{CachableObject, Cache}, 12 | error::{RailboardApiError, RailboardResult}, 13 | SharedState, 14 | }; 15 | 16 | #[utoipa::path( 17 | get, 18 | path = "/ris/v1/station/{eva}", 19 | params( 20 | ("eva" = String, Path, description = "The Eva Number of the Station you are requesting"), 21 | ), 22 | tag = "Ris", 23 | responses( 24 | (status = 200, description = "The requested Station Information", body = RisStationInformation), 25 | (status = 400, description = "The Error returned by the Ris, will be the Ris Domain with UnderlyingApiError Variant 3, 4 or none if there was no Station found", body = RailboardApiError), 26 | (status = 500, description = "The Error returned if the request or deserialization fails, will be domain Request", body = RailboardApiError) 27 | ) 28 | )] 29 | #[allow(deprecated)] 30 | #[deprecated(note = "the endpoint is not being maintained anymore, see ris-client")] 31 | pub async fn station_information( 32 | Path(eva): Path, 33 | State(state): State>, 34 | ) -> RailboardResult> { 35 | if let Some(cached) = state 36 | .cache 37 | .get_from_id(&format!("ris.station-information.{}", &eva)) 38 | .await 39 | { 40 | return Ok(Json(cached)); 41 | } 42 | 43 | let response = state.ris_client.station_information(&eva).await?; 44 | 45 | if response.is_none() { 46 | return Err(RailboardApiError { 47 | domain: crate::error::ErrorDomain::Ris, 48 | message: "No Station found".to_string(), 49 | error: None, 50 | }); 51 | } 52 | 53 | let response = response.unwrap(); 54 | 55 | { 56 | let response = response.clone(); 57 | tokio::spawn(async move { 58 | let _ = response.insert_to_cache(&state.cache, None).await; 59 | }); 60 | } 61 | 62 | Ok(Json(response)) 63 | } 64 | -------------------------------------------------------------------------------- /railboard-api/src/ris/station_search_by_name.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, Query, State}, 5 | Json, 6 | }; 7 | use serde::Deserialize; 8 | 9 | use ris_client::station_search::RisStationSearchElement; 10 | 11 | use crate::{ 12 | cache::{CachableObject, Cache}, 13 | error::RailboardResult, 14 | SharedState, 15 | }; 16 | 17 | #[derive(Deserialize)] 18 | pub struct RisStationSearchQuery { 19 | pub limit: Option, 20 | } 21 | 22 | #[utoipa::path( 23 | get, 24 | path = "/ris/v1/station_search/{query}", 25 | params( 26 | ("query" = String, Path, description = "The Query for the station (for example: \"Leipzig\")"), 27 | ("limit" = Option, Query, description = "The maximum amount of results to return (default: 25)") 28 | ), 29 | tag = "Ris", 30 | responses( 31 | (status = 200, description = "The requested Station Search Information", body = [RisStationSearchElement]), 32 | (status = 400, description = "The Error returned by Ris, will be the Ris Domain with UnderlyingApiError Variant 3 or 4", body = RailboardApiError), 33 | (status = 500, description = "The Error returned if the request or deserialization fails, will be domain Request", body = RailboardApiError) 34 | ) 35 | )] 36 | #[allow(deprecated)] 37 | #[deprecated(note = "the endpoint is not being maintained anymore, see ris-client")] 38 | pub async fn station_search_by_name( 39 | Path(query): Path, 40 | Query(query_params): Query, 41 | state: State>, 42 | ) -> RailboardResult>> { 43 | if let Some(cached) = state 44 | .cache 45 | .get_from_id(&format!("ris.journey-details.{}", &query)) 46 | .await 47 | { 48 | return Ok(Json(cached)); 49 | } 50 | 51 | let limit = query_params.limit; 52 | 53 | let response = state 54 | .ris_client 55 | .station_search_by_name(&query, limit) 56 | .await?; 57 | 58 | { 59 | let response = response.clone(); 60 | 61 | let limit = limit.unwrap_or(25); 62 | 63 | tokio::spawn(async move { 64 | response 65 | .insert_to_cache(&state.cache, Some(&format!("{}.{}", query, limit))) 66 | .await 67 | }); 68 | } 69 | 70 | Ok(Json(response)) 71 | } 72 | -------------------------------------------------------------------------------- /railboard-api/src/vendo.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{routing::get, Router}; 4 | 5 | use crate::SharedState; 6 | 7 | pub mod journey_details; 8 | pub mod location_search; 9 | pub mod station_board; 10 | 11 | pub fn router() -> Router> { 12 | Router::new() 13 | .route("/station_board/:id", get(station_board::station_board)) 14 | .route( 15 | "/journey_details/:id", 16 | get(journey_details::journey_details), 17 | ) 18 | .route( 19 | "/location_search/:query", 20 | get(location_search::location_search), 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /railboard-api/src/vendo/journey_details.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, State}, 5 | Json, 6 | }; 7 | 8 | use vendo_client::journey_details::VendoJourneyDetails; 9 | 10 | use crate::{ 11 | cache::{CachableObject, Cache}, 12 | error::RailboardResult, 13 | SharedState, 14 | }; 15 | 16 | #[utoipa::path( 17 | get, 18 | path = "/vendo/v1/journey_details/{id}", 19 | params(("id" = String, Path, description = "The Vendo-ID of the Journey you want to get details for")), 20 | tag = "Vendo", 21 | responses( 22 | (status = 200, description = "The requested Journey Details", body = VendoJourneyDetails), 23 | (status = 400, description = "The Error returned by Vendo", body = RailboardApiError), 24 | (status = 500, description = "The Error returned if the request or deserialization fails", body = RailboardApiError) 25 | ) 26 | )] 27 | pub async fn journey_details( 28 | Path(id): Path, 29 | State(state): State>, 30 | ) -> RailboardResult> { 31 | if let Some(cached) = state 32 | .cache 33 | .get_from_id(&format!("vendo.journey-details.{}", &id)) 34 | .await 35 | { 36 | return Ok(Json(cached)); 37 | } 38 | 39 | let journey_details = state.vendo_client.journey_details(&id).await?; 40 | 41 | { 42 | let cached = journey_details.clone(); 43 | tokio::spawn(async move { cached.insert_to_cache(&state.cache, None).await }); 44 | } 45 | 46 | Ok(Json(journey_details)) 47 | } 48 | -------------------------------------------------------------------------------- /railboard-api/src/vendo/location_search.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, State}, 5 | Json, 6 | }; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | use vendo_client::location_search::VendoLocationSearchResult; 10 | 11 | use crate::{ 12 | cache::{CachableObject, Cache}, 13 | error::RailboardResult, 14 | SharedState, 15 | }; 16 | 17 | #[utoipa::path( 18 | get, 19 | path = "/vendo/v1/location_search/{query}", 20 | params(("query" = String, Path, description = "The query you want to search for")), 21 | tag = "Vendo", 22 | responses( 23 | (status = 200, description = "The requested Location Search Results", body = [VendoLocationSearchResult]), 24 | (status = 400, description = "The Error returned by Vendo, will be the Vendo Domain with UnderlyingApiError Variant 1", body = RailboardApiError), 25 | (status = 500, description = "The Error returned if the request or deserialization fails", body = RailboardApiError) 26 | ) 27 | )] 28 | pub async fn location_search( 29 | Path(query): Path, 30 | State(state): State>, 31 | ) -> RailboardResult>> { 32 | if let Some(cached) = state 33 | .cache 34 | .get_from_id::(&format!("vendo.location-search.{query}")) 35 | .await 36 | { 37 | return Ok(Json(cached.results)); 38 | } 39 | 40 | let result: Vec = state 41 | .vendo_client 42 | .location_search(query.clone(), None) 43 | .await? 44 | .into_iter() 45 | .collect(); 46 | 47 | let location_search = LocationSearchCache { 48 | query, 49 | results: result.clone(), 50 | }; 51 | 52 | tokio::spawn(async move { location_search.insert_to_cache(&state.cache, None).await }); 53 | 54 | Ok(Json(result)) 55 | } 56 | 57 | #[derive(Debug, Serialize, Deserialize, Clone)] 58 | pub struct LocationSearchCache { 59 | pub query: String, 60 | pub results: Vec, 61 | } 62 | -------------------------------------------------------------------------------- /railboard-api/src/vendo/station_board.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, Query, State}, 5 | Json, 6 | }; 7 | use chrono::TimeZone; 8 | use chrono_tz::Europe::Berlin; 9 | use serde::Deserialize; 10 | use utoipa::IntoParams; 11 | 12 | use vendo_client::station_board::VendoStationBoard; 13 | 14 | use crate::{ 15 | cache::{CachableObject, Cache}, 16 | error::{ErrorDomain, RailboardApiError, RailboardResult}, 17 | SharedState, 18 | }; 19 | 20 | #[derive(Deserialize, IntoParams)] 21 | pub struct StationBoardQuery { 22 | /// The date (Unix Timestamp) to request the station board for. If not provided, the current date is used. 23 | pub date: Option, 24 | } 25 | 26 | #[utoipa::path( 27 | get, 28 | path = "/vendo/v1/station_board/{id}", 29 | params( 30 | ("id" = String, Path, description = "The eva number or location id of the Station you are requesting"), 31 | StationBoardQuery 32 | ), 33 | tag = "Vendo", 34 | responses( 35 | (status = 200, description = "The requested Station Board", body = VendoStationBoard), 36 | (status = 400, description = "The Error returned by Vendo", body = RailboardApiError), 37 | (status = 500, description = "The Error returned if the request or deserialization fails", body = RailboardApiError) 38 | ) 39 | )] 40 | pub async fn station_board( 41 | Path(id): Path, 42 | Query(params): Query, 43 | State(state): State>, 44 | ) -> RailboardResult> { 45 | let date = if let Some(date) = params.date { 46 | Berlin.from_utc_datetime(&chrono::NaiveDateTime::from_timestamp_opt(date, 0).ok_or( 47 | RailboardApiError { 48 | domain: ErrorDomain::Input, 49 | message: "Invalid date".to_string(), 50 | error: None, 51 | }, 52 | )?) 53 | } else { 54 | Berlin.from_utc_datetime(&chrono::Utc::now().naive_utc()) 55 | }; 56 | 57 | if let Some(cached) = state 58 | .cache 59 | .get_from_id(&format!( 60 | "vendo.station-board.{}.{}.{}", 61 | id, 62 | date.format("%Y-%m-%d"), 63 | date.format("%H:%M") 64 | )) 65 | .await 66 | { 67 | return Ok(Json(cached)); 68 | } 69 | 70 | let station_board = state.vendo_client.station_board(&id, date).await?; 71 | 72 | { 73 | let station_board = station_board.clone(); 74 | tokio::spawn(async move { station_board.insert_to_cache(&state.cache, None).await }); 75 | } 76 | 77 | Ok(Json(station_board)) 78 | } 79 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /ris-client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [dependencies] 2 | async-lock = '2.7.0' 3 | chrono-tz = '0.8.3' 4 | serde_json = '1.0.104' 5 | thiserror = '1.0.44' 6 | urlencoding = '2.1.3' 7 | 8 | [dependencies.chrono] 9 | features = ['serde'] 10 | version = '0.4.26' 11 | 12 | [dependencies.reqwest] 13 | default-features = false 14 | features = [ 15 | 'json', 16 | 'rustls-tls', 17 | ] 18 | version = '0.11.18' 19 | 20 | [dependencies.serde] 21 | features = ['derive'] 22 | version = '1.0.180' 23 | 24 | [dependencies.tokio] 25 | features = ['full'] 26 | version = '1.29.1' 27 | 28 | [dependencies.utoipa] 29 | features = ['chrono'] 30 | version = '3.4.3' 31 | 32 | [dev-dependencies] 33 | dotenvy = '0.15.7' 34 | 35 | [dev-dependencies.iris-client] 36 | path = '../iris-client' 37 | 38 | [dev-dependencies.tokio] 39 | features = ['full'] 40 | version = '1.29.1' 41 | 42 | [package] 43 | edition = '2021' 44 | name = 'ris-client' 45 | version = '0.2.0' 46 | -------------------------------------------------------------------------------- /ris-client/src/endpoints.rs: -------------------------------------------------------------------------------- 1 | pub mod journey_details; 2 | pub mod journey_search; 3 | pub mod station_board; 4 | pub mod station_information; 5 | pub mod station_search; 6 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/journey_details.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use serde::Deserialize; 4 | 5 | pub use transformed::*; 6 | 7 | use crate::journey_details::response::{EventType, JourneyDetailsEvent, JourneyDetailsResponse}; 8 | use crate::{RisClient, RisError, RisOrRequestError, RisUnauthorizedError}; 9 | 10 | mod response; 11 | mod transformed; 12 | 13 | impl RisClient { 14 | #[deprecated( 15 | note = "the only known api key was revoked, so i cannot maintain this endpoint anymore" 16 | )] 17 | pub async fn journey_details(&self, id: &str) -> Result { 18 | let _permit = self.semaphore.acquire().await; 19 | 20 | let url = format!( 21 | "{}/db/apis/ris-journeys/v1/eventbased/{}", 22 | self.base_url, id, 23 | ); 24 | 25 | let response: JourneyDetailsResponse = self 26 | .client 27 | .get(&url) 28 | .header("db-api-key", self.db_api_key.clone()) 29 | .header("db-client-id", self.db_client_id.clone()) 30 | .send() 31 | .await? 32 | .json() 33 | .await?; 34 | 35 | let mut stops: Vec<(Option, Option)> = Vec::new(); 36 | 37 | 'outer: for event in response.events { 38 | match event.r#type { 39 | EventType::Arrival => { 40 | for stop in stops.iter_mut() { 41 | if stop 42 | .1 43 | .as_ref() 44 | .map(|departure| { 45 | stop.0.is_none() 46 | && departure.station.eva_number == event.station.eva_number 47 | && departure.time_schedule >= event.time_schedule 48 | }) 49 | .unwrap_or(false) 50 | { 51 | stop.0 = Some(event); 52 | continue 'outer; 53 | } 54 | } 55 | stops.push((Some(event), None)) 56 | } 57 | EventType::Departure => { 58 | for stop in stops.iter_mut() { 59 | if stop 60 | .0 61 | .as_ref() 62 | .map(|arrival| { 63 | stop.1.is_none() 64 | && arrival.station.eva_number == event.station.eva_number 65 | && arrival.time_schedule <= event.time_schedule 66 | }) 67 | .unwrap_or(false) 68 | { 69 | stop.1 = Some(event); 70 | continue 'outer; 71 | } 72 | } 73 | stops.push((None, Some(event))) 74 | } 75 | } 76 | } 77 | 78 | let stops = stops 79 | .into_iter() 80 | .map(|stop| { 81 | let arrival = stop.0; 82 | let departure = stop.1; 83 | 84 | let departure_arrival = departure 85 | .clone() 86 | .unwrap_or_else(|| arrival.clone().unwrap()); 87 | 88 | let mut messages: HashSet = HashSet::new(); 89 | 90 | if let Some(arrival) = arrival.clone() { 91 | for message in arrival.messages { 92 | messages.insert(message.into()); 93 | } 94 | } 95 | 96 | if let Some(departure) = departure.clone() { 97 | for message in departure.messages { 98 | messages.insert(message.into()); 99 | } 100 | } 101 | 102 | let custom_operator_name = 103 | match departure_arrival.administration.administration_id.as_str() { 104 | "80" => "DB Fernverkehr AG", 105 | "82" => "CFL", 106 | "87" => "SNCF", 107 | "88" => "SNCB", 108 | _ => &departure_arrival.administration.operator_name, 109 | }; 110 | 111 | RisJourneyStop { 112 | stop_id: departure_arrival.station.eva_number, 113 | stop_name: departure_arrival.station.name, 114 | arrival: arrival.map(|arrival| RisJourneyStopEvent { 115 | cancelled: arrival.canceled, 116 | additional: arrival.additional, 117 | on_demand: arrival.on_demand, 118 | scheduled: arrival.time_schedule, 119 | realtime: arrival.time, 120 | time_type: arrival.time_type, 121 | }), 122 | departure: departure.map(|departure| RisJourneyStopEvent { 123 | cancelled: departure.canceled, 124 | additional: departure.additional, 125 | on_demand: departure.on_demand, 126 | scheduled: departure.time_schedule, 127 | realtime: departure.time, 128 | time_type: departure.time_type, 129 | }), 130 | transport: departure_arrival.transport.into(), 131 | messages: messages.into_iter().collect(), 132 | disruptions: departure_arrival 133 | .disruptions 134 | .into_iter() 135 | .map(|disruption| RisJourneyStopDisruption { 136 | id: disruption.disruption_id, 137 | communication_id: disruption.disruption_communication_id, 138 | text: disruption.descriptions.de.text, 139 | text_short: disruption.descriptions.de.text_short, 140 | priority: disruption.display_priority, 141 | }) 142 | .collect(), 143 | scheduled_platform: departure_arrival.platform_schedule, 144 | real_platform: departure_arrival.platform, 145 | administration: RisJourneyStopAdministration { 146 | id: departure_arrival.administration.administration_id, 147 | name: custom_operator_name.to_string(), 148 | operator_code: departure_arrival.administration.operator_code, 149 | ris_name: departure_arrival.administration.operator_name, 150 | }, 151 | } 152 | }) 153 | .collect(); 154 | 155 | let response = RisJourneyDetails { 156 | id: response.journey_id, 157 | destination_id: response.destination_schedule.eva_number, 158 | destination_name: response.destination_schedule.name, 159 | origin_id: response.origin_schedule.eva_number, 160 | origin_name: response.origin_schedule.name, 161 | journey_type: response.r#type, 162 | cancelled: response.journey_canceled, 163 | stops, 164 | }; 165 | 166 | Ok(response) 167 | 168 | // match response { 169 | // RisJourneyDetailsOrErrorResponse::Response(response) => Ok(*response), 170 | // RisJourneyDetailsOrErrorResponse::Error(error) => { 171 | // Err(RisOrRequestError::RisError(error)) 172 | // } 173 | // RisJourneyDetailsOrErrorResponse::UnauthorizedError(error) => { 174 | // Err(RisOrRequestError::RisUnauthorizedError(error)) 175 | // } 176 | // } 177 | } 178 | } 179 | 180 | #[allow(dead_code)] 181 | #[derive(Deserialize, Debug)] 182 | #[serde(untagged)] 183 | enum RisJourneyDetailsOrErrorResponse { 184 | Response(Box), 185 | Error(RisError), 186 | UnauthorizedError(RisUnauthorizedError), 187 | } 188 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/journey_details/response.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, FixedOffset}; 2 | use serde::{Deserialize, Serialize}; 3 | use utoipa::ToSchema; 4 | 5 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct JourneyDetailsResponse { 8 | #[serde(rename = "journeyID")] 9 | pub journey_id: String, 10 | pub origin_schedule: JourneyDetailsStation, 11 | pub destination_schedule: JourneyDetailsStation, 12 | pub r#type: String, 13 | pub journey_canceled: bool, 14 | #[serde(default)] 15 | pub events: Vec, 16 | } 17 | 18 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)] 19 | #[serde(rename_all = "camelCase")] 20 | pub struct JourneyDetailsStation { 21 | pub eva_number: String, 22 | pub name: String, 23 | } 24 | 25 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)] 26 | #[serde(rename_all = "camelCase")] 27 | pub struct JourneyDetailsEvent { 28 | pub station: JourneyDetailsStation, 29 | pub passenger_change: bool, 30 | pub on_demand: bool, 31 | pub time_schedule: DateTime, 32 | pub time_type: String, 33 | pub time: Option>, 34 | pub platform_schedule: Option, 35 | pub platform: Option, 36 | pub messages: Vec, 37 | pub disruptions: Vec, 38 | pub r#type: EventType, 39 | #[serde(rename = "arrivalOrDepartureID")] 40 | pub arrival_or_departure_id: String, 41 | pub additional: bool, 42 | pub canceled: bool, 43 | pub administration: Administration, 44 | pub transport: Transport, 45 | } 46 | 47 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)] 48 | #[serde(rename_all = "UPPERCASE")] 49 | pub enum EventType { 50 | Arrival, 51 | Departure, 52 | } 53 | 54 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, ToSchema)] 55 | #[serde(rename_all = "camelCase")] 56 | pub struct JourneyDetailsMessage { 57 | pub code: Option, 58 | pub r#type: String, 59 | pub display_priority: Option, 60 | pub category: Option, 61 | pub text: String, 62 | pub text_short: Option, 63 | } 64 | 65 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, ToSchema)] 66 | #[serde(rename_all = "camelCase")] 67 | pub struct JourneyDetailsDisruption { 68 | #[serde(rename = "disruptionID")] 69 | pub disruption_id: String, 70 | #[serde(rename = "disruptionCommunicationID")] 71 | pub disruption_communication_id: Option, 72 | pub display_priority: i32, 73 | pub descriptions: JourneyDetailsDisruptionDescriptions, 74 | } 75 | 76 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, ToSchema)] 77 | #[serde(rename_all = "UPPERCASE")] 78 | pub struct JourneyDetailsDisruptionDescriptions { 79 | pub de: JourneyDetailsDisruptionDescription, 80 | } 81 | 82 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, ToSchema)] 83 | #[serde(rename_all = "camelCase")] 84 | pub struct JourneyDetailsDisruptionDescription { 85 | pub text: String, 86 | pub text_short: Option, 87 | } 88 | 89 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)] 90 | #[serde(rename_all = "camelCase")] 91 | pub struct Administration { 92 | #[serde(rename = "administrationID")] 93 | pub administration_id: String, 94 | pub operator_code: String, 95 | pub operator_name: String, 96 | } 97 | 98 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, ToSchema)] 99 | #[serde(rename_all = "camelCase")] 100 | pub struct Transport { 101 | pub r#type: String, 102 | pub category: String, 103 | pub number: i32, 104 | pub line: Option, 105 | pub label: Option, 106 | pub replacement_transport: Option, 107 | } 108 | 109 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, ToSchema)] 110 | #[serde(rename_all = "camelCase")] 111 | pub struct ReplacementTransport { 112 | pub real_type: String, 113 | } 114 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/journey_details/transformed.rs: -------------------------------------------------------------------------------- 1 | use crate::journey_details::response::{JourneyDetailsMessage, ReplacementTransport, Transport}; 2 | use chrono::{DateTime, FixedOffset}; 3 | use serde::{Deserialize, Serialize}; 4 | use utoipa::ToSchema; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] 7 | #[serde(rename_all = "camelCase")] 8 | pub struct RisJourneyDetails { 9 | pub id: String, 10 | pub journey_type: String, 11 | pub origin_name: String, 12 | pub origin_id: String, 13 | pub destination_name: String, 14 | pub destination_id: String, 15 | pub cancelled: bool, 16 | pub stops: Vec, 17 | } 18 | 19 | #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] 20 | #[serde(rename_all = "camelCase")] 21 | pub struct RisJourneyStop { 22 | pub stop_id: String, 23 | pub stop_name: String, 24 | #[schema(nullable)] 25 | pub arrival: Option, 26 | #[schema(nullable)] 27 | pub departure: Option, 28 | pub messages: Vec, 29 | pub disruptions: Vec, 30 | pub transport: RisTransport, 31 | #[schema(nullable)] 32 | pub scheduled_platform: Option, 33 | #[schema(nullable)] 34 | pub real_platform: Option, 35 | pub administration: RisJourneyStopAdministration, 36 | } 37 | 38 | #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] 39 | #[serde(rename_all = "camelCase")] 40 | pub struct RisJourneyStopEvent { 41 | pub cancelled: bool, 42 | pub additional: bool, 43 | pub on_demand: bool, 44 | pub scheduled: DateTime, 45 | #[schema(nullable)] 46 | pub realtime: Option>, 47 | pub time_type: String, 48 | } 49 | 50 | #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] 51 | #[serde(rename_all = "camelCase")] 52 | pub struct RisJourneyStopAdministration { 53 | pub id: String, 54 | pub name: String, 55 | pub operator_code: String, 56 | pub ris_name: String, 57 | } 58 | 59 | #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] 60 | #[serde(rename_all = "camelCase")] 61 | pub struct RisJourneyStopDisruption { 62 | pub id: String, 63 | pub communication_id: Option, 64 | pub priority: i32, 65 | pub text: String, 66 | #[schema(nullable)] 67 | pub text_short: Option, 68 | } 69 | 70 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, ToSchema)] 71 | #[serde(rename_all = "camelCase")] 72 | pub struct RisJourneyDetailsMessage { 73 | pub code: Option, 74 | pub r#type: String, 75 | pub display_priority: Option, 76 | pub category: Option, 77 | pub text: String, 78 | pub text_short: Option, 79 | } 80 | 81 | impl From for RisJourneyDetailsMessage { 82 | fn from(message: JourneyDetailsMessage) -> Self { 83 | RisJourneyDetailsMessage { 84 | code: message.code, 85 | r#type: message.r#type, 86 | display_priority: message.display_priority, 87 | category: message.category, 88 | text: message.text, 89 | text_short: message.text_short, 90 | } 91 | } 92 | } 93 | 94 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, ToSchema)] 95 | #[serde(rename_all = "camelCase")] 96 | pub struct RisTransport { 97 | pub r#type: String, 98 | pub category: String, 99 | pub number: i32, 100 | pub line: Option, 101 | pub label: Option, 102 | pub replacement_transport: Option, 103 | } 104 | 105 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, ToSchema)] 106 | #[serde(rename_all = "camelCase")] 107 | pub struct RisReplacementTransport { 108 | pub real_type: String, 109 | } 110 | 111 | impl From for RisTransport { 112 | fn from(transport: Transport) -> Self { 113 | RisTransport { 114 | r#type: transport.r#type, 115 | category: transport.category, 116 | number: transport.number, 117 | line: transport.line, 118 | label: transport.label, 119 | replacement_transport: transport 120 | .replacement_transport 121 | .map(RisReplacementTransport::from), 122 | } 123 | } 124 | } 125 | 126 | impl From for RisReplacementTransport { 127 | fn from(replacement_transport: ReplacementTransport) -> Self { 128 | RisReplacementTransport { 129 | real_type: replacement_transport.real_type, 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/journey_search.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDate; 2 | 3 | pub use response::*; 4 | 5 | use crate::request::ResponseOrRisError; 6 | use crate::{RisClient, RisOrRequestError}; 7 | 8 | mod response; 9 | 10 | impl RisClient { 11 | #[deprecated( 12 | note = "the only known api key was revoked, so i cannot maintain this endpoint anymore" 13 | )] 14 | pub async fn journey_search( 15 | &self, 16 | category: &str, 17 | number: &str, 18 | date: Option, 19 | ) -> Result { 20 | let _permit = self.semaphore.acquire().await; 21 | 22 | let url = format!("{}/db/apis/ris-journeys/v1/byrelation", self.base_url); 23 | 24 | let number = urlencoding::encode(number); 25 | 26 | let mut query = vec![ 27 | ("category", category.to_owned()), 28 | ("number", number.into_owned()), 29 | ]; 30 | 31 | if let Some(date) = date { 32 | let date = date.format("%Y-%m-%d").to_string(); 33 | query.push(("date", date)); 34 | } 35 | 36 | let response: ResponseOrRisError = self 37 | .client 38 | .get(&url) 39 | .query(&query) 40 | .header("db-api-key", self.db_api_key.clone()) 41 | .header("db-client-id", self.db_client_id.clone()) 42 | .send() 43 | .await? 44 | .json() 45 | .await?; 46 | 47 | match response { 48 | ResponseOrRisError::Response(response) => Ok(*response), 49 | ResponseOrRisError::Error(error) => Err(RisOrRequestError::RisError(error)), 50 | ResponseOrRisError::UnauthorizedError(error) => { 51 | Err(RisOrRequestError::RisUnauthorizedError(error)) 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/journey_search/response.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use utoipa::ToSchema; 3 | 4 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)] 5 | pub struct RisJourneySearchResponse { 6 | pub journeys: Vec, 7 | } 8 | 9 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)] 10 | #[serde(rename_all = "camelCase")] 11 | pub struct RisJourneySearchElement { 12 | #[serde(rename = "journeyID")] 13 | pub journey_id: String, 14 | pub date: String, 15 | #[serde(rename = "administrationID")] 16 | pub administration_id: String, 17 | pub origin_schedule: RisJourneySearchSchedule, 18 | pub destination_schedule: RisJourneySearchSchedule, 19 | pub transport: RisJourneySearchTransport, 20 | } 21 | 22 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)] 23 | #[serde(rename_all = "camelCase")] 24 | pub struct RisJourneySearchSchedule { 25 | pub eva_number: String, 26 | pub name: String, 27 | } 28 | 29 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)] 30 | #[serde(rename_all = "camelCase")] 31 | pub struct RisJourneySearchTransport { 32 | pub r#type: String, 33 | pub category: String, 34 | pub number: i32, 35 | #[schema(nullable)] 36 | pub line: Option, 37 | #[schema(nullable)] 38 | pub label: Option, 39 | #[schema(nullable)] 40 | pub replacement_transport: Option, 41 | } 42 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/station_board.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use chrono::DateTime; 4 | use chrono_tz::Tz; 5 | use serde::Deserialize; 6 | 7 | pub use transformed::*; 8 | 9 | use crate::helpers::name_from_administation_code; 10 | use crate::station_board::response::{StationBoardItem, StationBoardResponse}; 11 | use crate::{RisClient, RisOrRequestError, ZugportalError}; 12 | 13 | mod response; 14 | mod transformed; 15 | 16 | // This endpoint uses the "zugportal" api that the "Zugportal" App uses, and its basically ris so I didnt feel like making another package for it 17 | // Update 01/2025: The only known ris api key has been invalidated, so it will stay with Zugportal 18 | 19 | impl RisClient { 20 | pub async fn station_board( 21 | &self, 22 | eva: &str, 23 | time_start: Option>, 24 | time_end: Option>, 25 | ) -> Result { 26 | let (arrivals, departures) = tokio::join!( 27 | self.station_board_arrivals(eva, time_start, time_end), 28 | self.station_board_departures(eva, time_start, time_end) 29 | ); 30 | 31 | let arrivals = arrivals?; 32 | let departures = departures?; 33 | 34 | let mut trains: BTreeMap, Option)> = 35 | BTreeMap::new(); 36 | 37 | for train in arrivals.items { 38 | let id = train.train.journey_id.to_owned(); 39 | trains.entry(id).or_insert_with(|| (None, None)).0 = Some(train); 40 | } 41 | 42 | for train in departures.items { 43 | let id = train.train.journey_id.to_owned(); 44 | trains.entry(id).or_insert_with(|| (None, None)).1 = Some(train); 45 | } 46 | 47 | if departures.station_name.is_none() { 48 | return Err(RisOrRequestError::NotFoundError); 49 | } 50 | 51 | Ok(RisStationBoard { 52 | eva: departures.eva_no.unwrap_or(eva.to_string()), 53 | name: departures.station_name.unwrap_or_default(), 54 | time_start: departures.time_start, 55 | time_end: departures.time_end, 56 | items: trains 57 | .into_iter() 58 | .map(|(id, (arrival, departure))| { 59 | let departure_arrival = departure 60 | .clone() 61 | .unwrap_or_else(|| arrival.clone().unwrap()); 62 | 63 | let scheduled_platform = if departure_arrival.platform.is_empty() { 64 | None 65 | } else { 66 | Some(departure_arrival.platform) 67 | }; 68 | let realtime_platform = if departure_arrival.platform_predicted.is_empty() { 69 | None 70 | } else { 71 | Some(departure_arrival.platform_predicted) 72 | }; 73 | 74 | RisStationBoardItem { 75 | journey_id: id, 76 | station_eva: departure_arrival.station.eva_no.clone(), 77 | station_name: departure_arrival.station.name.clone(), 78 | cancelled: departure_arrival.canceled || departure_arrival.station.canceled, 79 | category: departure_arrival.train.category, 80 | train_type: departure_arrival.train.r#type, 81 | train_number: departure_arrival.train.no, 82 | line_indicator: departure_arrival.train.line_name, 83 | departure: departure.as_ref().map(|departure| DepartureArrival { 84 | delay: departure.diff, 85 | time_realtime: departure.time_predicted, 86 | time_scheduled: departure.time, 87 | time_type: departure.time_type.clone(), 88 | }), 89 | arrival: arrival.as_ref().map(|arrival| DepartureArrival { 90 | delay: arrival.diff, 91 | time_realtime: arrival.time_predicted, 92 | time_scheduled: arrival.time, 93 | time_type: arrival.time_type.clone(), 94 | }), 95 | destination_eva: departure 96 | .as_ref() 97 | .and_then(|departure| { 98 | departure 99 | .destination 100 | .as_ref() 101 | .map(|destination| destination.eva_no.clone()) 102 | }) 103 | .unwrap_or(departure_arrival.station.eva_no.clone()), 104 | destination_name: departure 105 | .and_then(|departure| { 106 | departure.destination.map(|destination| destination.name) 107 | }) 108 | .unwrap_or(departure_arrival.station.name.clone()), 109 | origin_eva: arrival 110 | .as_ref() 111 | .and_then(|arrival| { 112 | arrival.origin.as_ref().map(|origin| origin.eva_no.clone()) 113 | }) 114 | .unwrap_or(departure_arrival.station.eva_no), 115 | origin_name: arrival 116 | .as_ref() 117 | .and_then(|arrival| { 118 | arrival.origin.as_ref().map(|origin| origin.name.clone()) 119 | }) 120 | .unwrap_or(departure_arrival.station.name), 121 | platform_scheduled: scheduled_platform, 122 | platform_realtime: realtime_platform, 123 | administation: RisStationBoardItemAdministration { 124 | id: departure_arrival.administration.id, 125 | operator_code: departure_arrival.administration.operator_code, 126 | operator_name: String::from( 127 | name_from_administation_code( 128 | &departure_arrival.administration.operator_name, 129 | ) 130 | .unwrap_or(&departure_arrival.administration.operator_name), 131 | ), 132 | ris_operator_name: departure_arrival.administration.operator_name, 133 | }, 134 | } 135 | }) 136 | .collect(), 137 | }) 138 | } 139 | 140 | pub async fn station_board_departures( 141 | &self, 142 | eva: &str, 143 | time_start: Option>, 144 | time_end: Option>, 145 | ) -> Result { 146 | let _permit = self.semaphore.acquire().await; 147 | 148 | let url = format!( 149 | "https://zugportal.de/@prd/zupo-travel-information/api/public/ri/board/departure/{eva}" 150 | ); 151 | 152 | station_board(self, url, time_start, time_end).await 153 | } 154 | 155 | pub async fn station_board_arrivals( 156 | &self, 157 | eva: &str, 158 | time_start: Option>, 159 | time_end: Option>, 160 | ) -> Result { 161 | let _permit = self.semaphore.acquire().await; 162 | 163 | let url = format!( 164 | "https://zugportal.de/@prd/zupo-travel-information/api/public/ri/board/arrival/{eva}" 165 | ); 166 | 167 | station_board(self, url, time_start, time_end).await 168 | } 169 | } 170 | 171 | async fn station_board( 172 | client: &RisClient, 173 | url: String, 174 | time_start: Option>, 175 | time_end: Option>, 176 | ) -> Result { 177 | let mut query = vec![("expandTimeFrame", "TIME_START".to_owned())]; // todo: find out what this means because I have no idea 178 | 179 | if let Some(time_start) = time_start { 180 | query.push(("timeStart", time_start.to_rfc3339())) 181 | } 182 | 183 | if let Some(time_end) = time_end { 184 | query.push(("timeEnd", time_end.to_rfc3339())) 185 | } 186 | 187 | let response: RisStationBoardOrErrorResponse = client 188 | .client 189 | .get(&url) 190 | .query(&query) 191 | .send() 192 | .await? 193 | .json() 194 | .await?; 195 | 196 | match response { 197 | RisStationBoardOrErrorResponse::Response(response) => Ok(*response), 198 | RisStationBoardOrErrorResponse::Error(error) => { 199 | Err(RisOrRequestError::ZugportalError(error)) 200 | } 201 | } 202 | } 203 | 204 | #[derive(Deserialize, Debug)] 205 | #[serde(untagged)] 206 | enum RisStationBoardOrErrorResponse { 207 | Response(Box), 208 | Error(ZugportalError), 209 | } 210 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/station_board/response.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, FixedOffset}; 2 | use serde::{Deserialize, Serialize}; 3 | use utoipa::ToSchema; 4 | 5 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct StationBoardResponse { 8 | pub is_arrival: bool, 9 | pub eva_no: Option, 10 | pub station_name: Option, 11 | pub time_start: DateTime, 12 | pub time_end: DateTime, 13 | pub items: Vec, 14 | } 15 | 16 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 17 | #[serde(rename_all = "camelCase")] 18 | pub struct StationBoardItem { 19 | pub station: StationBoardItemStation, 20 | pub train: StationBoardItemVehicle, 21 | pub category: String, 22 | pub platform: String, 23 | pub platform_predicted: String, 24 | pub time_predicted: DateTime, 25 | pub time: DateTime, 26 | pub time_type: String, 27 | pub canceled: bool, 28 | pub diff: i32, 29 | pub origin: Option, 30 | pub destination: Option, 31 | pub administration: StationBoardItemAdministration, 32 | } 33 | 34 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 35 | #[serde(rename_all = "camelCase")] 36 | pub struct StationBoardItemStation { 37 | pub eva_no: String, 38 | pub name: String, 39 | pub canceled: bool, 40 | } 41 | 42 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 43 | #[serde(rename_all = "camelCase")] 44 | pub struct StationBoardItemVehicle { 45 | pub journey_id: String, 46 | pub line_name: String, 47 | pub no: u32, 48 | pub category: String, 49 | pub r#type: String, 50 | } 51 | 52 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)] 53 | #[serde(rename_all = "camelCase")] 54 | pub struct StationBoardItemAdministration { 55 | pub id: String, 56 | pub operator_code: String, 57 | pub operator_name: String, 58 | } 59 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/station_board/transformed.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, FixedOffset}; 2 | use serde::{Deserialize, Serialize}; 3 | use utoipa::ToSchema; 4 | 5 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct RisStationBoard { 8 | pub eva: String, 9 | pub name: String, 10 | pub time_start: DateTime, 11 | pub time_end: DateTime, 12 | pub items: Vec, 13 | } 14 | 15 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 16 | #[serde(rename_all = "camelCase")] 17 | pub struct RisStationBoardItem { 18 | pub journey_id: String, 19 | 20 | pub station_eva: String, 21 | pub station_name: String, 22 | 23 | pub category: String, 24 | pub train_type: String, 25 | pub train_number: u32, 26 | pub line_indicator: String, 27 | 28 | pub cancelled: bool, 29 | 30 | pub arrival: Option, 31 | pub departure: Option, 32 | 33 | #[schema(nullable)] 34 | pub platform_scheduled: Option, 35 | #[schema(nullable)] 36 | pub platform_realtime: Option, 37 | 38 | pub origin_eva: String, 39 | pub origin_name: String, 40 | pub destination_eva: String, 41 | pub destination_name: String, 42 | 43 | pub administation: RisStationBoardItemAdministration, 44 | } 45 | 46 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 47 | #[serde(rename_all = "camelCase")] 48 | pub struct RisStationBoardItemAdministration { 49 | pub id: String, 50 | pub operator_code: String, 51 | pub operator_name: String, 52 | pub ris_operator_name: String, 53 | } 54 | 55 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, ToSchema, Clone)] 56 | #[serde(rename_all = "camelCase")] 57 | pub struct DepartureArrival { 58 | /// Since ris returns dates with seconds it also rounds up this number if the seconds are 50 for example 59 | pub delay: i32, 60 | pub time_scheduled: DateTime, 61 | pub time_realtime: DateTime, 62 | pub time_type: String, 63 | } 64 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/station_information.rs: -------------------------------------------------------------------------------- 1 | pub use transformed::*; 2 | 3 | use crate::request::ResponseOrRisError; 4 | use crate::station_information::response::StationInformationResponse; 5 | use crate::{RisClient, RisOrRequestError}; 6 | 7 | pub(crate) mod response; 8 | mod transformed; 9 | 10 | impl RisClient { 11 | #[deprecated( 12 | note = "the only known api key was revoked, so i cannot maintain this endpoint anymore" 13 | )] 14 | pub async fn station_information( 15 | &self, 16 | eva: &str, 17 | ) -> Result, RisOrRequestError> { 18 | let _permit = self.semaphore.acquire().await; 19 | 20 | let url = format!( 21 | "{}/db/apis/ris-stations/v1/stop-places/{eva}", 22 | self.base_url 23 | ); 24 | 25 | let response: ResponseOrRisError = self 26 | .client 27 | .get(&url) 28 | .header("db-api-key", &self.db_api_key) 29 | .header("db-client-id", &self.db_client_id) 30 | .send() 31 | .await? 32 | .json() 33 | .await?; 34 | 35 | match response { 36 | ResponseOrRisError::Response(response) => { 37 | let station = response.stations.into_iter().next().map(|i| i.into()); 38 | 39 | Ok(station) 40 | } 41 | ResponseOrRisError::Error(error) => Err(RisOrRequestError::RisError(error)), 42 | ResponseOrRisError::UnauthorizedError(error) => { 43 | Err(RisOrRequestError::RisUnauthorizedError(error)) 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/station_information/response.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use utoipa::ToSchema; 3 | 4 | #[derive(Serialize, Deserialize, Debug)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct StationInformationResponse { 7 | #[serde(rename = "stopPlaces")] 8 | pub stations: Vec, 9 | } 10 | 11 | #[derive(Serialize, Deserialize, Debug, Clone)] 12 | #[serde(rename_all = "camelCase")] 13 | pub struct StationInformation { 14 | pub eva_number: String, 15 | #[serde(rename = "stationID")] 16 | pub station_id: Option, 17 | pub names: Translatable, 18 | pub metropolis: Option>, 19 | pub available_transports: Vec, 20 | pub transport_associations: Vec, 21 | pub country_code: String, 22 | pub state: String, 23 | pub municipality_key: String, 24 | pub time_zone: String, 25 | pub position: Position, 26 | } 27 | 28 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone, ToSchema)] 29 | #[serde(rename_all = "UPPERCASE")] 30 | pub struct Translatable { 31 | pub de: T, 32 | } 33 | 34 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone, ToSchema)] 35 | #[serde(rename_all = "camelCase")] 36 | pub struct StationNameContent { 37 | pub name_long: String, 38 | #[schema(nullable)] 39 | pub speech_long: Option, 40 | #[schema(nullable)] 41 | pub speech_short: Option, 42 | #[schema(nullable)] 43 | pub symbol: Option, 44 | } 45 | 46 | #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] 47 | #[serde(rename_all = "camelCase")] 48 | pub struct Position { 49 | pub longitude: f64, 50 | pub latitude: f64, 51 | } 52 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/station_information/transformed.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use utoipa::ToSchema; 3 | 4 | impl From for RisStationInformation { 5 | fn from(value: crate::station_information::response::StationInformation) -> Self { 6 | Self { 7 | eva: value.eva_number, 8 | names: RisStationNameContent { 9 | name_long: value.names.de.name_long, 10 | speech_long: value.names.de.speech_long, 11 | speech_short: value.names.de.speech_short, 12 | }, 13 | station_id: value.station_id, 14 | available_transports: value.available_transports, 15 | transport_associations: value.transport_associations, 16 | country_code: value.country_code, 17 | state: value.state, 18 | municipality_key: value.municipality_key, 19 | time_zone: value.time_zone, 20 | metropolis: value.metropolis.map(|m| m.de), 21 | position: RisPosition { 22 | longitude: value.position.longitude, 23 | latitude: value.position.latitude, 24 | }, 25 | } 26 | } 27 | } 28 | 29 | #[derive(Serialize, Deserialize, Debug, ToSchema, Clone)] 30 | #[serde(rename_all = "camelCase")] 31 | pub struct RisStationInformation { 32 | pub eva: String, 33 | #[schema(nullable)] 34 | pub station_id: Option, 35 | pub names: RisStationNameContent, 36 | pub metropolis: Option, 37 | pub available_transports: Vec, 38 | pub transport_associations: Vec, 39 | pub country_code: String, 40 | pub state: String, 41 | pub municipality_key: String, 42 | pub time_zone: String, 43 | pub position: RisPosition, 44 | } 45 | 46 | #[derive(Serialize, Deserialize, Debug, ToSchema, PartialEq, Clone)] 47 | #[serde(rename_all = "camelCase")] 48 | pub struct RisStationNameContent { 49 | pub name_long: String, 50 | #[schema(nullable)] 51 | pub speech_long: Option, 52 | #[schema(nullable)] 53 | pub speech_short: Option, 54 | } 55 | 56 | #[derive(Serialize, Deserialize, Debug, ToSchema, Clone, PartialEq)] 57 | #[serde(rename_all = "camelCase")] 58 | pub struct RisPosition { 59 | pub longitude: f64, 60 | pub latitude: f64, 61 | } 62 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/station_search.rs: -------------------------------------------------------------------------------- 1 | pub use response::*; 2 | 3 | use crate::request::ResponseOrRisError; 4 | use crate::{RisClient, RisOrRequestError}; 5 | 6 | mod response; 7 | 8 | impl RisClient { 9 | #[deprecated( 10 | note = "the only known api key was revoked, so i cannot maintain this endpoint anymore" 11 | )] 12 | pub async fn station_search_by_name( 13 | &self, 14 | query: &str, 15 | limit: Option, 16 | ) -> Result, RisOrRequestError> { 17 | let _permit = self.semaphore.acquire().await; 18 | 19 | let limit = limit.unwrap_or(25); 20 | 21 | let query = urlencoding::encode(query); 22 | 23 | let url = format!( 24 | "{}/db/apis/ris-stations/v1/stop-places/by-name/{query}", 25 | self.base_url 26 | ); 27 | 28 | let response: ResponseOrRisError = self 29 | .client 30 | .get(&url) 31 | .query(&[("limit", format!("{}", limit))]) 32 | .header("db-api-key", &self.db_api_key) 33 | .header("db-client-id", &self.db_client_id) 34 | .send() 35 | .await? 36 | .json() 37 | .await?; 38 | 39 | match response { 40 | ResponseOrRisError::Response(response) => Ok(response.stop_places), 41 | ResponseOrRisError::Error(error) => Err(RisOrRequestError::RisError(error)), 42 | ResponseOrRisError::UnauthorizedError(error) => { 43 | Err(RisOrRequestError::RisUnauthorizedError(error)) 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ris-client/src/endpoints/station_search/response.rs: -------------------------------------------------------------------------------- 1 | use crate::station_information::RisPosition; 2 | use serde::{Deserialize, Serialize}; 3 | use utoipa::ToSchema; 4 | 5 | #[derive(Deserialize, Debug, Serialize, PartialEq, Clone, ToSchema)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct RisStationSearchResponse { 8 | pub stop_places: Vec, 9 | } 10 | 11 | #[derive(Deserialize, Debug, Serialize, PartialEq, Clone, ToSchema)] 12 | #[serde(rename_all = "camelCase")] 13 | pub struct RisStationSearchElement { 14 | pub eva_number: String, 15 | #[serde(rename = "stationID")] 16 | #[schema(nullable)] 17 | pub station_id: Option, 18 | pub group_members: Vec, 19 | pub names: RisStationSearchTranslatable, 20 | pub available_transports: Vec, 21 | pub position: RisPosition, 22 | } 23 | 24 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone, ToSchema)] 25 | #[serde(rename_all = "UPPERCASE")] 26 | pub struct RisStationSearchTranslatable { 27 | pub de: RisStationSearchNameContent, 28 | } 29 | 30 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone, ToSchema)] 31 | #[serde(rename_all = "camelCase")] 32 | pub struct RisStationSearchNameContent { 33 | pub name_long: String, 34 | #[schema(nullable)] 35 | pub speech_long: Option, 36 | #[schema(nullable)] 37 | pub speech_short: Option, 38 | #[schema(nullable)] 39 | pub symbol: Option, 40 | } 41 | -------------------------------------------------------------------------------- /ris-client/src/error.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use thiserror::Error; 3 | use utoipa::ToSchema; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Error, ToSchema)] 6 | #[error("Ris returned an error.")] 7 | #[serde(rename_all = "camelCase")] 8 | pub struct RisError { 9 | pub error_code: i32, 10 | pub title: String, 11 | pub detail: String, 12 | #[schema(nullable)] 13 | pub status: Option, 14 | #[schema(nullable)] 15 | pub instance_id: Option, 16 | #[schema(nullable)] 17 | pub trace_id: Option, 18 | #[schema(nullable)] 19 | pub span_id: Option, 20 | #[schema(nullable)] 21 | pub errors: Option, 22 | } 23 | 24 | #[derive(Serialize, Deserialize, Debug, Error, ToSchema)] 25 | #[error("Ris request was unauthorized.")] 26 | #[serde(rename_all = "camelCase")] 27 | pub struct RisUnauthorizedError { 28 | pub http_code: String, 29 | pub http_message: String, 30 | pub more_information: String, 31 | } 32 | 33 | #[derive(Serialize, Deserialize, Debug, Error, ToSchema)] 34 | #[error("Ris request was unauthorized.")] 35 | #[serde(rename_all = "camelCase")] 36 | pub struct ZugportalError { 37 | pub status_code: u32, 38 | pub message: String, 39 | } 40 | 41 | #[derive(Error, Debug)] 42 | pub enum RisOrRequestError { 43 | #[error("Ris returned an error.")] 44 | RisError(#[from] RisError), 45 | #[error("The Ris request was unauthorized.")] 46 | RisUnauthorizedError(#[from] RisUnauthorizedError), 47 | #[error("The Ris request through Zugportal returned an error.")] 48 | ZugportalError(#[from] ZugportalError), 49 | #[error("There was nothing found with these parameters")] 50 | NotFoundError, 51 | #[error(transparent)] 52 | FailedRequest(#[from] reqwest::Error), 53 | } 54 | -------------------------------------------------------------------------------- /ris-client/src/helpers.rs: -------------------------------------------------------------------------------- 1 | pub fn name_from_administation_code(code: &str) -> Option<&str> { 2 | match code { 3 | "80" => Some("DB Fernverkehr AG"), 4 | "82" => Some("CFL"), 5 | "87" => Some("SNCF"), 6 | "88" => Some("SNCB"), 7 | _ => None, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ris-client/src/lib.rs: -------------------------------------------------------------------------------- 1 | use async_lock::Semaphore; 2 | 3 | mod error; 4 | mod helpers; 5 | pub use error::*; 6 | 7 | mod endpoints; 8 | mod request; 9 | 10 | pub use endpoints::*; 11 | 12 | pub struct RisClient { 13 | client: reqwest::Client, 14 | base_url: String, 15 | semaphore: Semaphore, 16 | db_client_id: String, 17 | db_api_key: String, 18 | } 19 | 20 | impl RisClient { 21 | pub fn new( 22 | client: Option, 23 | base_url: Option, 24 | concurrent_requests: Option, 25 | db_client_id: &str, 26 | db_api_key: &str, 27 | ) -> Self { 28 | Self { 29 | client: client.unwrap_or_default(), 30 | base_url: base_url.unwrap_or_else(|| String::from("https://apis.deutschebahn.com")), 31 | semaphore: Semaphore::new(concurrent_requests.unwrap_or(100)), 32 | db_client_id: db_client_id.to_string(), 33 | db_api_key: db_api_key.to_string(), 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ris-client/src/request.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use crate::{RisError, RisUnauthorizedError}; 4 | 5 | #[derive(Deserialize, Debug)] 6 | #[serde(untagged)] 7 | pub enum ResponseOrRisError { 8 | Response(Box), 9 | Error(RisError), 10 | UnauthorizedError(RisUnauthorizedError), 11 | } 12 | -------------------------------------------------------------------------------- /ris-client/tests/journey_details.rs: -------------------------------------------------------------------------------- 1 | use chrono::{TimeZone, Utc}; 2 | use chrono_tz::Europe::Berlin; 3 | use dotenvy::dotenv; 4 | use ris_client::RisClient; 5 | 6 | #[tokio::test] 7 | async fn journey_details() { 8 | dotenv().ok(); 9 | 10 | let api_key = std::env::var("RIS_API_KEY").expect("RIS_API_KEY not set"); 11 | let client_id = std::env::var("RIS_CLIENT_ID").expect("RIS_CLIENT_ID not set"); 12 | 13 | let ris_client = RisClient::new(None, None, None, &client_id, &api_key); 14 | 15 | let current = Berlin.from_utc_datetime(&Utc::now().naive_utc()); 16 | 17 | let station_board = ris_client 18 | .station_board_departures( 19 | "8000105", 20 | Some(current), 21 | Some(current + chrono::Duration::hours(1)), 22 | ) 23 | .await; 24 | 25 | let station_board = station_board.expect("Failed to get station board"); 26 | 27 | let first = station_board 28 | .items 29 | .into_iter() 30 | .find(|item| item.train.category == "ICE") 31 | .expect("No ICE in departure board of Frankfurt. Is it night?"); 32 | 33 | let journey_details = ris_client 34 | .journey_details(&first.train.journey_id) 35 | .await 36 | .unwrap_or_else(|e| { 37 | panic!( 38 | "Failed to get journey details for train: {:#?} \n Error: {:#?}", 39 | first.train, e 40 | ) 41 | }); 42 | 43 | let event = journey_details 44 | .stops 45 | .into_iter() 46 | .find(|train| train.stop_id == "8000105") 47 | .expect("Failed to get right station"); 48 | 49 | assert_eq!(first.train.category, event.transport.category); 50 | 51 | assert_eq!( 52 | first.train.line_name, 53 | event 54 | .transport 55 | .line 56 | .unwrap_or(event.transport.number.to_string()) 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /ris-client/tests/journey_search.rs: -------------------------------------------------------------------------------- 1 | use dotenvy::dotenv; 2 | use iris_client::IrisClient; 3 | use ris_client::RisClient; 4 | 5 | #[tokio::test] 6 | pub async fn journey_search() { 7 | dotenv().ok(); 8 | 9 | let api_key = std::env::var("RIS_API_KEY").expect("RIS_API_KEY not set"); 10 | let client_id = std::env::var("RIS_CLIENT_ID").expect("RIS_CLIENT_ID not set"); 11 | 12 | let iris_client = IrisClient::default(); 13 | let ris_client = RisClient::new(None, None, None, &client_id, &api_key); 14 | 15 | let iris = iris_client 16 | .station_board("8000105", None, None, None) 17 | .await 18 | .expect("Failed to get station board from iris"); 19 | 20 | let re_train = iris 21 | .stops 22 | .iter() 23 | .find(|stop| stop.train_type == "RE") 24 | .expect("Didn't find a RE train"); 25 | 26 | let ris_re_train = ris_client 27 | .journey_search(&re_train.train_type, &re_train.train_number, None) 28 | .await 29 | .expect("Failed to get journey search from ris"); 30 | 31 | let ris_re_train = ris_re_train 32 | .journeys 33 | .first() 34 | .expect("Didn't find any journeys"); 35 | 36 | assert_eq!( 37 | ris_re_train.origin_schedule.name, 38 | re_train.route.first().unwrap().name, 39 | "Origin station name doesn't match" 40 | ); 41 | assert_eq!( 42 | ris_re_train.destination_schedule.name, 43 | re_train.route.last().unwrap().name, 44 | "Destination station name doesn't match" 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /ris-client/tests/station_board.rs: -------------------------------------------------------------------------------- 1 | use chrono::{Datelike, Duration, TimeZone, Timelike}; 2 | use chrono_tz::Europe::Berlin; 3 | use dotenvy::dotenv; 4 | use ris_client::RisClient; 5 | 6 | #[tokio::test] 7 | async fn station_board() { 8 | dotenv().ok(); 9 | 10 | let api_key = std::env::var("RIS_API_KEY").expect("RIS_API_KEY not set"); 11 | let client_id = std::env::var("RIS_CLIENT_ID").expect("RIS_CLIENT_ID not set"); 12 | 13 | let ris_client = RisClient::new(None, None, None, &client_id, &api_key); 14 | 15 | let result = ris_client 16 | .station_board_departures("8000105", None, None) 17 | .await; 18 | 19 | assert!(result.is_ok()); 20 | } 21 | 22 | #[tokio::test] 23 | async fn station_board_time_range() { 24 | dotenv().ok(); 25 | 26 | let api_key = std::env::var("RIS_API_KEY").expect("RIS_API_KEY not set"); 27 | let client_id = std::env::var("RIS_CLIENT_ID").expect("RIS_CLIENT_ID not set"); 28 | 29 | let ris_client = RisClient::new(None, None, None, &client_id, &api_key); 30 | 31 | let current_time = Berlin.from_utc_datetime(&chrono::Utc::now().naive_utc()); 32 | 33 | let time_start = current_time - Duration::minutes(20); 34 | let time_end = current_time + Duration::minutes(60); 35 | 36 | let result = ris_client 37 | .station_board_departures("8000105", Some(time_start), Some(time_end)) 38 | .await; 39 | 40 | let result = result.expect("Failed to get Station Board"); 41 | 42 | println!("{time_start} == {}", result.time_start); 43 | 44 | assert_eq!( 45 | result.time_start.naive_utc().day(), 46 | time_start.naive_utc().day(), 47 | "Wrong start time (day)" 48 | ); 49 | assert_eq!( 50 | result.time_start.naive_utc().hour(), 51 | time_start.naive_utc().hour(), 52 | "Wrong start time (hour)" 53 | ); 54 | assert_eq!( 55 | result.time_start.naive_utc().minute(), 56 | time_start.naive_utc().minute(), 57 | "Wrong start time (minute)" 58 | ); 59 | assert_eq!( 60 | result.time_end.naive_utc().day(), 61 | time_end.naive_utc().day(), 62 | "Wrong end time (day)" 63 | ); 64 | assert_eq!( 65 | result.time_end.naive_utc().hour(), 66 | time_end.naive_utc().hour(), 67 | "Wrong end time (hour)" 68 | ); 69 | assert_eq!( 70 | result.time_end.naive_utc().minute(), 71 | time_end.naive_utc().minute(), 72 | "Wrong end time (minute)" 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /ris-client/tests/station_information.rs: -------------------------------------------------------------------------------- 1 | use dotenvy::dotenv; 2 | use ris_client::RisClient; 3 | 4 | #[tokio::test] 5 | async fn station_information() { 6 | dotenv().ok(); 7 | 8 | let api_key = std::env::var("RIS_API_KEY").expect("RIS_API_KEY not set"); 9 | let client_id = std::env::var("RIS_CLIENT_ID").expect("RIS_CLIENT_ID not set"); 10 | 11 | let ris_client = RisClient::new(None, None, None, &client_id, &api_key); 12 | 13 | let result = ris_client.station_information("8000105").await; 14 | 15 | let result = result.expect("Failed to get Station Information"); 16 | 17 | let first = result; 18 | assert!(first.is_some(), "No stations in response"); 19 | 20 | let first = first.unwrap(); 21 | 22 | assert_eq!(first.eva, "8000105", "Wrong EVA number"); 23 | assert_eq!(first.names.name_long, "Frankfurt(Main)Hbf", "Wrong Name"); 24 | } 25 | -------------------------------------------------------------------------------- /ris-client/tests/station_search.rs: -------------------------------------------------------------------------------- 1 | use dotenvy::dotenv; 2 | use ris_client::RisClient; 3 | 4 | #[tokio::test] 5 | async fn station_search() { 6 | dotenv().ok(); 7 | 8 | let api_key = std::env::var("RIS_API_KEY").expect("RIS_API_KEY not set"); 9 | let client_id = std::env::var("RIS_CLIENT_ID").expect("RIS_CLIENT_ID not set"); 10 | 11 | let ris_client = RisClient::new(None, None, None, &client_id, &api_key); 12 | 13 | let result = ris_client.station_search_by_name("Leipzig", Some(25)).await; 14 | 15 | let result = result.expect("Failed to get station search"); 16 | 17 | assert_eq!(result.len(), 25, "limit was exceeded"); 18 | assert_eq!( 19 | "8010205", result[0].eva_number, 20 | "first result should be leipzig hbf (eva)" 21 | ); 22 | assert_eq!( 23 | "Leipzig Hbf", result[0].names.de.name_long, 24 | "first result should be leipzig hbf (name)" 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /vendo-client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [dependencies] 2 | async-lock = '2.7.0' 3 | chrono-tz = '0.8.3' 4 | serde_json = '1.0.104' 5 | thiserror = '1.0.44' 6 | urlencoding = '2.1.3' 7 | 8 | [dependencies.chrono] 9 | features = ['serde'] 10 | version = '0.4.26' 11 | 12 | [dependencies.reqwest] 13 | default-features = false 14 | features = [ 15 | 'json', 16 | 'rustls-tls', 17 | ] 18 | version = '0.11.18' 19 | 20 | [dependencies.serde] 21 | features = ['derive'] 22 | version = '1.0.180' 23 | 24 | [dependencies.tokio] 25 | features = ['full'] 26 | version = '1.29.1' 27 | 28 | [dependencies.utoipa] 29 | features = ['chrono'] 30 | version = '3.4.3' 31 | [dev-dependencies.tokio] 32 | features = ['full'] 33 | version = '1.29.1' 34 | 35 | [package] 36 | edition = '2021' 37 | name = 'vendo-client' 38 | version = '0.2.0' 39 | -------------------------------------------------------------------------------- /vendo-client/src/endpoints.rs: -------------------------------------------------------------------------------- 1 | pub mod journey_details; 2 | pub mod location_search; 3 | pub mod station_board; 4 | -------------------------------------------------------------------------------- /vendo-client/src/endpoints/journey_details.rs: -------------------------------------------------------------------------------- 1 | use reqwest::header::{HeaderValue, ACCEPT, CONTENT_TYPE}; 2 | use serde::Deserialize; 3 | use urlencoding::encode; 4 | 5 | pub use transformed::*; 6 | 7 | use crate::journey_details::response::JourneyDetailsResponse; 8 | use crate::shared::Time; 9 | use crate::{VendoClient, VendoError, VendoOrRequestError}; 10 | 11 | pub mod response; 12 | mod transformed; 13 | 14 | const VENDO_JOURNEY_DETAILS_HEADER: &str = "application/x.db.vendo.mob.zuglauf.v2+json"; 15 | 16 | impl VendoClient { 17 | /// Get journey details for a specific journey. 18 | /// 19 | /// The ID has to be a Vendo ID e.G. \ 20 | /// `2|#VN#1#ST#1673463547#PI#0#ZI#166635#TA#0#DA#150123#1S#8006132#1T#1415#LS#8000105#LT#1514#PU#80#RT#1#CA#RB#ZE#15519#ZB#RB 15519#PC#3#FR#8006132#FT#1415#TO#8000105#TT#1514#` 21 | pub async fn journey_details( 22 | &self, 23 | id: &str, 24 | ) -> Result { 25 | let _permit = self.semaphore.acquire().await; 26 | 27 | let response: VendoJourneyDetailsResponse = self 28 | .client 29 | .get(format!("{}/mob/zuglauf/{}", self.base_url, encode(id))) 30 | .header( 31 | CONTENT_TYPE, 32 | HeaderValue::from_static(VENDO_JOURNEY_DETAILS_HEADER), 33 | ) 34 | .header( 35 | ACCEPT, 36 | HeaderValue::from_static(VENDO_JOURNEY_DETAILS_HEADER), 37 | ) 38 | .header("x-correlation-id", "railboard") 39 | .send() 40 | .await? 41 | .json() 42 | .await?; 43 | 44 | match response { 45 | VendoJourneyDetailsResponse::VendoResponse(response) => { 46 | let mapped = VendoJourneyDetails { 47 | short_name: response.short_name, 48 | name: response.name, 49 | long_name: response.long_name, 50 | destination: response.destination, 51 | 52 | journey_id: id.to_string(), 53 | 54 | stops: response 55 | .stops 56 | .into_iter() 57 | .map(|stop| VendoStop { 58 | name: stop.stop_details.name, 59 | eva: stop.stop_details.eva, 60 | position: PolylinePosition { 61 | longitude: stop.stop_details.position.longitude, 62 | latitude: stop.stop_details.position.latitude, 63 | }, 64 | arrival: stop.arrival.map(|arrival| Time { 65 | scheduled: arrival, 66 | realtime: stop.realtime_arrival, 67 | }), 68 | departure: stop.departure.map(|departure| Time { 69 | scheduled: departure, 70 | realtime: stop.realtime_departure, 71 | }), 72 | platform: stop.platform, 73 | realtime_platform: stop.realtime_platform, 74 | notes: stop.notes.into_iter().map(|note| note.text).collect(), 75 | him_notices: stop 76 | .him_notices 77 | .into_iter() 78 | .map(|from| from.into()) 79 | .collect(), 80 | attributes: stop 81 | .attributes 82 | .into_iter() 83 | .map(|from| from.into()) 84 | .collect(), 85 | service_note: stop.service_note.map(|service| service.into()), 86 | }) 87 | .collect(), 88 | 89 | transport_number: response.transport_number, 90 | product_type: response.product_type, 91 | notes: response.notes.into_iter().map(|note| note.text).collect(), 92 | him_notices: response 93 | .him_notices 94 | .into_iter() 95 | .map(|from| from.into()) 96 | .collect(), 97 | attributes: response 98 | .attributes 99 | .into_iter() 100 | .map(|from| from.into()) 101 | .collect(), 102 | schedule: VendoTrainSchedule { 103 | regular_schedule: response.schedule.regular_schedule, 104 | days_of_operation: response.schedule.days_of_operation, 105 | }, 106 | journey_day: response.journey_day, 107 | 108 | polyline: response.polyline_group.map(|group| { 109 | group 110 | .polyline_desc 111 | .map(|desc| { 112 | desc.first() 113 | .unwrap() 114 | .coordinates 115 | .iter() 116 | .map(|point| PolylinePosition { 117 | longitude: point.longitude, 118 | latitude: point.latitude, 119 | }) 120 | .collect() 121 | }) 122 | .unwrap_or_default() 123 | }), 124 | }; 125 | 126 | Ok(mapped) 127 | } 128 | VendoJourneyDetailsResponse::VendoError(error) => { 129 | Err(VendoOrRequestError::VendoError(error)) 130 | } 131 | } 132 | } 133 | } 134 | 135 | #[derive(Deserialize, Debug)] 136 | #[serde(untagged)] 137 | enum VendoJourneyDetailsResponse { 138 | VendoResponse(Box), 139 | VendoError(VendoError), 140 | } 141 | -------------------------------------------------------------------------------- /vendo-client/src/endpoints/journey_details/response.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, FixedOffset}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Serialize, Deserialize)] 5 | pub struct JourneyDetailsResponse { 6 | #[serde(rename = "kurztext")] 7 | pub short_name: String, 8 | #[serde(rename = "mitteltext")] 9 | pub name: String, 10 | #[serde(rename = "langtext")] 11 | pub long_name: Option, 12 | #[serde(rename = "richtung")] 13 | pub destination: String, 14 | 15 | #[serde(rename = "halte")] 16 | pub stops: Vec, 17 | 18 | #[serde(rename = "verkehrsmittelNummer")] 19 | pub transport_number: Option, 20 | #[serde(rename = "produktGattung")] 21 | pub product_type: String, 22 | 23 | #[serde(rename = "echtzeitNotizen")] 24 | pub notes: Vec, 25 | #[serde(rename = "himNotizen")] 26 | pub him_notices: Vec, 27 | #[serde(rename = "attributNotizen")] 28 | pub attributes: Vec, 29 | 30 | #[serde(rename = "fahrplan")] 31 | pub schedule: JourneyDetailsTrainSchedule, 32 | #[serde(rename = "reisetag")] 33 | pub journey_day: String, 34 | #[serde(rename = "polylineGroup")] 35 | pub polyline_group: Option, 36 | } 37 | 38 | #[derive(Debug, Serialize, Deserialize)] 39 | pub struct JourneyDetailsPolylineGroup { 40 | #[serde(rename = "polylineDesc")] 41 | pub polyline_desc: Option>, 42 | } 43 | 44 | #[derive(Debug, Serialize, Deserialize)] 45 | pub struct JourneyDetailsPolylineDescription { 46 | pub coordinates: Vec, 47 | pub delta: bool, 48 | } 49 | 50 | #[derive(Debug, Serialize, Deserialize)] 51 | pub struct JourneyDetailsPolylinePoint { 52 | pub longitude: f64, 53 | pub latitude: f64, 54 | } 55 | 56 | #[derive(Debug, Serialize, Deserialize)] 57 | pub struct JourneyDetailsTrainSchedule { 58 | #[serde(rename = "regulaererFahrplan")] 59 | pub regular_schedule: String, 60 | #[serde(rename = "tageOhneFahrt")] 61 | pub days_of_operation: Option, 62 | } 63 | 64 | #[derive(Debug, Serialize, Deserialize)] 65 | pub struct JourneyDetailsStop { 66 | #[serde(rename = "ort")] 67 | pub stop_details: JourneyDetailsStopDetails, 68 | #[serde(rename = "ankunftsDatum")] 69 | pub arrival: Option>, 70 | #[serde(rename = "ezAnkunftsDatum")] 71 | pub realtime_arrival: Option>, 72 | #[serde(rename = "abgangsDatum")] 73 | pub departure: Option>, 74 | #[serde(rename = "ezAbgangsDatum")] 75 | pub realtime_departure: Option>, 76 | #[serde(rename = "gleis")] 77 | pub platform: Option, 78 | #[serde(rename = "ezGleis")] 79 | pub realtime_platform: Option, 80 | #[serde(rename = "echtzeitNotizen")] 81 | pub notes: Vec, 82 | #[serde(rename = "himNotizen")] 83 | pub him_notices: Vec, 84 | #[serde(rename = "serviceNotiz")] 85 | pub service_note: Option, 86 | #[serde(rename = "attributNotizen")] 87 | pub attributes: Vec, 88 | #[serde(rename = "auslastungsInfos")] 89 | pub demand: Vec, 90 | } 91 | 92 | #[derive(Debug, Serialize, Deserialize)] 93 | pub struct JourneyDetailsStopDemand { 94 | #[serde(rename = "klasse")] 95 | pub class: JourneyDetailsStopDemandClass, 96 | #[serde(rename = "stufe")] 97 | pub demand_level: u32, 98 | #[serde(rename = "anzeigeTextKurz")] 99 | pub text: String, 100 | } 101 | 102 | #[derive(Debug, Serialize, Deserialize)] 103 | pub enum JourneyDetailsStopDemandClass { 104 | #[serde(rename = "KLASSE_1")] 105 | Class1, 106 | #[serde(rename = "KLASSE_2")] 107 | Class2, 108 | } 109 | 110 | #[derive(Debug, Serialize, Deserialize)] 111 | pub struct JourneyDetailsStopDetails { 112 | pub name: String, 113 | #[serde(rename = "locationId")] 114 | pub location_id: String, 115 | #[serde(rename = "evaNr")] 116 | pub eva: String, 117 | pub position: JourneyDetailsStopPosition, 118 | } 119 | 120 | #[derive(Debug, Serialize, Deserialize)] 121 | pub struct JourneyDetailsStopPosition { 122 | pub longitude: f64, 123 | pub latitude: f64, 124 | } 125 | 126 | #[derive(Debug, Clone, Serialize, Deserialize)] 127 | pub struct JourneyDetailsNotice { 128 | pub text: String, 129 | } 130 | 131 | #[derive(Debug, Clone, Serialize, Deserialize)] 132 | pub struct JourneyDetailsHimNotice { 133 | pub text: String, 134 | #[serde(rename = "ueberschrift")] 135 | pub heading: String, 136 | #[serde(rename = "prio")] 137 | pub priority: String, 138 | } 139 | 140 | #[derive(Debug, Clone, Serialize, Deserialize)] 141 | pub struct JourneyDetailsAttribute { 142 | pub text: String, 143 | pub priority: Option, 144 | pub key: String, 145 | } 146 | -------------------------------------------------------------------------------- /vendo-client/src/endpoints/journey_details/transformed.rs: -------------------------------------------------------------------------------- 1 | use crate::journey_details::response::{JourneyDetailsAttribute, JourneyDetailsHimNotice}; 2 | use crate::shared::{Attribute, HimNotice, Time}; 3 | use serde::{Deserialize, Serialize}; 4 | use utoipa::ToSchema; 5 | 6 | #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] 7 | #[serde(rename_all = "camelCase")] 8 | pub struct VendoJourneyDetails { 9 | pub short_name: String, 10 | pub name: String, 11 | pub long_name: Option, 12 | pub destination: String, 13 | 14 | pub journey_id: String, 15 | 16 | pub stops: Vec, 17 | 18 | #[schema(nullable)] 19 | pub transport_number: Option, 20 | pub product_type: String, 21 | 22 | pub notes: Vec, 23 | pub him_notices: Vec, 24 | pub attributes: Vec, 25 | 26 | pub schedule: VendoTrainSchedule, 27 | pub journey_day: String, 28 | 29 | #[schema(nullable)] 30 | pub polyline: Option>, 31 | } 32 | 33 | #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] 34 | #[serde(rename_all = "camelCase")] 35 | pub struct PolylinePosition { 36 | pub longitude: f64, 37 | pub latitude: f64, 38 | } 39 | 40 | #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] 41 | #[serde(rename_all = "camelCase")] 42 | pub struct VendoTrainSchedule { 43 | pub regular_schedule: String, 44 | #[schema(nullable)] 45 | pub days_of_operation: Option, 46 | } 47 | 48 | #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] 49 | #[serde(rename_all = "camelCase")] 50 | pub struct VendoStop { 51 | pub name: String, 52 | pub eva: String, 53 | pub position: PolylinePosition, 54 | #[schema(nullable)] 55 | pub arrival: Option