From 83c57a74d017e0704caf1b252a98fff3e972dfb1 Mon Sep 17 00:00:00 2001 From: mxdamien Date: Thu, 30 Jun 2022 15:09:58 +0200 Subject: [PATCH] Initial commit --- .github/workflows/ci.yml | 65 ++++++++++++++++++++++ .gitignore | 15 ++++++ Cargo.toml | 15 ++++++ LICENSE.md | 21 ++++++++ README.md | 15 ++++++ src/app.rs | 51 ++++++++++++++++++ src/config.rs | 20 +++++++ src/dbapiclient.rs | 46 ++++++++++++++++ src/main.rs | 27 ++++++++++ src/timetable.rs | 79 +++++++++++++++++++++++++++ src/timetablepresenter.rs | 5 ++ src/timetablepresenterconsole.rs | 93 ++++++++++++++++++++++++++++++++ src/xmlparser.rs | 19 +++++++ 13 files changed, 471 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 src/app.rs create mode 100644 src/config.rs create mode 100644 src/dbapiclient.rs create mode 100644 src/main.rs create mode 100644 src/timetable.rs create mode 100644 src/timetablepresenter.rs create mode 100644 src/timetablepresenterconsole.rs create mode 100644 src/xmlparser.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..49ebd74 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +env: + CARGO_TERM_COLOR: always + +jobs: + LinuxLatest: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + - uses: Swatinem/rust-cache@v1 + + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose + + WindowsLatest: + + runs-on: windows-latest + + steps: + - uses: actions/checkout@v2 + + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + - uses: Swatinem/rust-cache@v1 + + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose + + MacOsLatest: + + runs-on: macos-latest + + steps: + - uses: actions/checkout@v2 + + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + - uses: Swatinem/rust-cache@v1 + + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..596bd42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb +/.vscode \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1fd3fc0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "dbtimetable" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +reqwest = { version = "0.11", features = ["json"] } +tokio = { version = "1", features = ["full"] } +confy = { version = "0.3.1" } +serde = { version = "1.0", features = ["derive"] } +quick-xml = { version = "0.23.0", features = ["serialize"] } +chrono = { version = "0.4.19" } +merge = { version = "0.1.0" } \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..4b0c5e9 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 David Langhals + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed74229 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +## dbtimetable +A small Rust application to query the Deutsche Bahn (DB) timetable API. It can be used to display changes for a set of train stations + +## Setup +You need to register with the DB API Marketplace to use the timeable API. +[Link to API description](https://developers.deutschebahn.com/db-api-marketplace/apis/product/timetables/api/26494#/Timetables_10213/overview) +You also need to obtain a Client ID and an API Key for the timeable API and set them in your local configuration. [config.rs](src/config.rs) + +## Configure your stations +Stations are identified by their EVA numbers. The stations you want to observe have to be configured in your local configuration [config.rs](src/config.rs) +A list of stations alongside their EVA numbers can be found here: [Link](https://data.deutschebahn.com/dataset/data-haltestellen.html) + +## License + +[License](LICENSE.md) \ No newline at end of file diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..56e987b --- /dev/null +++ b/src/app.rs @@ -0,0 +1,51 @@ +use crate::{ + config::Config, dbapiclient::DbApiClient, timetablepresenter::TimetablePresenter, + xmlparser::XmlParser, +}; + +pub struct App { + apiclient: DbApiClient, + xmlparser: XmlParser, + config: Config, + presenter: Box, +} + +impl App { + fn construct_changes_endpoint(eva: &String) -> String { + format!("fchg/{}", eva) + } + + pub fn new( + apiclient: DbApiClient, + xmlparser: XmlParser, + config: Config, + presenter: Box, + ) -> Self { + Self { + apiclient, + xmlparser, + config, + presenter, + } + } + + pub async fn run(self) { + for eva in &self.config.evas { + let timetable_changes = match self + .apiclient + .get(App::construct_changes_endpoint(eva)) + .await + { + Ok(s) => s, + Err(_) => "".into(), + }; + + match self.xmlparser.get_timetable(&timetable_changes) { + Ok(changes) => { + self.presenter.present(&changes); + } + Err(_) => (), + } + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..70b4c4e --- /dev/null +++ b/src/config.rs @@ -0,0 +1,20 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Clone)] +pub struct Config { + pub url: String, + pub client_id: String, + pub api_key: String, + pub evas: Vec +} + +impl ::std::default::Default for Config { + fn default() -> Self { + Self { + url: "https://apis.deutschebahn.com/db-api-marketplace/apis/timetables/v1/".to_string(), + client_id: "123456789".to_string(), + api_key: "123456789".to_string(), + evas: vec!("8003368".to_string()) + } + } +} diff --git a/src/dbapiclient.rs b/src/dbapiclient.rs new file mode 100644 index 0000000..b516571 --- /dev/null +++ b/src/dbapiclient.rs @@ -0,0 +1,46 @@ +use crate::config::Config; +use reqwest::header::{HeaderMap, HeaderValue, ACCEPT}; + +pub struct DbApiClient { + config: Config, +} + +impl DbApiClient { + pub fn new(config: Config) -> Self { + Self { config } + } + + fn construct_headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "DB-Api-Key", + HeaderValue::from_str(self.config.api_key.as_str()).unwrap(), + ); + headers.insert( + "DB-Client-Id", + HeaderValue::from_str(self.config.client_id.as_str()).unwrap(), + ); + headers.insert(ACCEPT, HeaderValue::from_static("application/xml")); + headers + } + + fn construct_complete_url(&self, endpoint: String) -> String { + format!("{}{}", self.config.url, endpoint) + } + + pub async fn get(&self, endpoint: String) -> Result { + let client = reqwest::Client::new(); + let res = client + .get(self.construct_complete_url(endpoint)) + .headers(self.construct_headers()) + .send() + .await?; + + let body = match res.text().await { + Ok(it) => it, + Err(_err) => return Err(_err), + }; + + Ok(body) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..5cbb18e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,27 @@ +mod app; +mod config; +mod dbapiclient; +mod timetable; +mod timetablepresenter; +mod timetablepresenterconsole; +mod xmlparser; + +use app::App; +use config::Config; +use dbapiclient::DbApiClient; +use timetablepresenterconsole::TimetablePresenterConsole; +use xmlparser::XmlParser; + +extern crate confy; + +#[tokio::main] +async fn main() -> Result<(), std::io::Error> { + let config: Config = confy::load("dbtimetable")?; + let apiclient = DbApiClient::new(config.clone()); + let xmlparser = XmlParser::new(); + let presenter = Box::new(TimetablePresenterConsole::new()); + let app = App::new(apiclient, xmlparser, config.clone(), presenter); + app.run().await; + + Ok(()) +} diff --git a/src/timetable.rs b/src/timetable.rs new file mode 100644 index 0000000..34624ea --- /dev/null +++ b/src/timetable.rs @@ -0,0 +1,79 @@ +use serde::Deserialize; +use merge::Merge; + +#[derive(Deserialize, Merge)] +pub struct ArrivalDeparture { + pub cde: Option, + pub clt: Option, + pub cp: Option, + pub cpth: Option, + pub cs: Option, + pub ct: Option, + pub dc: Option, + pub hi: Option, + pub l: Option, + pub m: Option>, + pub pde: Option, + pub pp: Option, + pub ppth: Option, + pub ps: Option, + pub pt: Option, + pub tra: Option, + pub wings: Option, +} + +#[derive(Deserialize, Merge)] +pub struct TimetableStop { + pub eva: Option, + pub id: Option, + pub ar: Option, + pub conn: Option, + pub dp: Option, + pub hd: Option, + pub hpc: Option, + pub m: Option>, + pub r: Option, + pub rtr: Option, + pub tl: Option, + pub f: Option, +} + +#[derive(Deserialize, Merge)] +pub struct Triplabel { + pub c: Option, + pub n: Option, + pub o: Option, + pub f: Option, + + #[serde(rename = "false")] + pub fa: Option, + pub t: Option, +} + +#[derive(Deserialize, Merge)] +pub struct Message { + pub id: Option, + pub t: Option, + pub ts: Option, + pub c: Option, + pub cat: Option, + pub del: Option, + pub dm: Option, + pub ec: Option, + pub elnk: Option, + pub ext: Option, + pub from: Option, + pub int: Option, + pub o: Option, + pub pr: Option, + pub tl: Option, + pub to: Option, +} + +#[derive(Deserialize, Merge)] +pub struct Timetable { + pub station: Option, + pub eva: Option, + pub m: Option>, + pub s: Option>, +} diff --git a/src/timetablepresenter.rs b/src/timetablepresenter.rs new file mode 100644 index 0000000..56b4c77 --- /dev/null +++ b/src/timetablepresenter.rs @@ -0,0 +1,5 @@ +use crate::timetable::Timetable; + +pub trait TimetablePresenter { + fn present(&self, timetable: &Timetable); +} diff --git a/src/timetablepresenterconsole.rs b/src/timetablepresenterconsole.rs new file mode 100644 index 0000000..6dc4730 --- /dev/null +++ b/src/timetablepresenterconsole.rs @@ -0,0 +1,93 @@ +use crate::timetable::{ArrivalDeparture, Timetable}; +use crate::timetablepresenter::TimetablePresenter; +use chrono::NaiveDateTime; + +pub struct TimetablePresenterConsole(); + +impl TimetablePresenterConsole { + #[allow(dead_code)] + pub fn new() -> Self { + Self {} + } +} + +impl TimetablePresenter for TimetablePresenterConsole { + fn present(&self, timetable: &Timetable) { + print_station_name(timetable); + print_seperator_lines(2); + print_timetablestop(timetable); + print_seperator_lines(1); + } +} + +fn print_timetablestop(timetable: &Timetable) { + timetable.s.as_ref().unwrap().iter().for_each(|s| { + print_departure(s); + }); +} + +fn print_departure(s: &crate::timetable::TimetableStop) { + match &s.dp { + Some(dp) => match &s.tl { + Some(tl) => { + print_train_info(tl, dp); + print_time_info(dp); + print_seperator_lines(1); + } + None => (), + }, + None => (), + } +} + +fn print_time_info(dp: &ArrivalDeparture) { + print_planned_time(dp); + print_changed_time(dp); +} + +fn print_planned_time(dp: &ArrivalDeparture) { + println!( + "Planned time: {}", + NaiveDateTime::parse_from_str(dp.pt.as_ref().unwrap_or(&"-".to_string()), "%y%m%d%H%M") + .unwrap() + .to_string() + ); +} + +fn print_changed_time(dp: &crate::timetable::ArrivalDeparture) { + match NaiveDateTime::parse_from_str(dp.ct.as_ref().unwrap_or(&"-".to_string()), "%y%m%d%H%M") { + Ok(dt) => println!("Actual time: {}", dt.to_string()), + _ => println!("Actual time: No delay"), + } +} + +fn print_train_info(tl: &crate::timetable::Triplabel, dp: &crate::timetable::ArrivalDeparture) { + println!( + "{}{}: {}", + tl.c.as_ref().unwrap_or(&"".to_string()), + dp.l.as_ref() + .unwrap_or(tl.n.as_ref().unwrap_or(&"".to_string())), + dp.ppth + .as_ref() + .unwrap_or(&"".to_string()) + .split('|') + .last() + .unwrap_or(&"".to_string()) + ); +} + +fn print_station_name(timetable: &Timetable) { + println!( + "{}", + timetable + .station + .as_ref() + .unwrap_or(&"Station name missing".to_string()) + ); +} + +fn print_seperator_lines(count: i16) { + for _n in 0..count { + println!("----------------------"); + } +} diff --git a/src/xmlparser.rs b/src/xmlparser.rs new file mode 100644 index 0000000..639787d --- /dev/null +++ b/src/xmlparser.rs @@ -0,0 +1,19 @@ +use crate::timetable::Timetable; +use quick_xml::DeError; +pub struct XmlParser {} + +impl XmlParser { + pub fn new() -> Self { + Self {} + } + + pub fn get_timetable(&self, xml: &String) -> Result { + match quick_xml::de::from_str::(xml.as_str()) + { + Ok(tt) => Ok(tt), + Err(err) => { + println!("Timetable parsing error: {}", err); Err(err) + } + } + } +}