From fc6e940d39b96f53497e39cf6b4ceff10e659286 Mon Sep 17 00:00:00 2001 From: "NGnius (Graham)" Date: Thu, 7 Mar 2024 19:30:32 -0500 Subject: [PATCH] Add UPS support --- Cargo.lock | 143 +++++++++++++++++- Cargo.toml | 2 + odeli-core/src/debug/methods.rs | 16 ++ odeli-core/src/debug/mod.rs | 4 + odeli-core/src/debug/shippers.rs | 16 ++ odeli-core/src/debug_info.rs | 4 + odeli-core/src/lib.rs | 1 + shipper/odeli-canadapost/src/adapter.rs | 8 + shipper/odeli-ups/Cargo.toml | 21 +++ shipper/odeli-ups/src/adapter.rs | 141 +++++++++++++++++ shipper/odeli-ups/src/consts.rs | 1 + shipper/odeli-ups/src/data/address.rs | 66 ++++++++ shipper/odeli-ups/src/data/details.rs | 23 +++ shipper/odeli-ups/src/data/mod.rs | 10 ++ .../odeli-ups/src/data/progress_activity.rs | 72 +++++++++ shipper/odeli-ups/src/data/status_request.rs | 28 ++++ shipper/odeli-ups/src/data/status_response.rs | 15 ++ shipper/odeli-ups/src/lib.rs | 58 +++++++ shipper/odeli-ups/src/urls.rs | 14 ++ 19 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 odeli-core/src/debug/methods.rs create mode 100644 odeli-core/src/debug/mod.rs create mode 100644 odeli-core/src/debug/shippers.rs create mode 100644 shipper/odeli-ups/Cargo.toml create mode 100644 shipper/odeli-ups/src/adapter.rs create mode 100644 shipper/odeli-ups/src/consts.rs create mode 100644 shipper/odeli-ups/src/data/address.rs create mode 100644 shipper/odeli-ups/src/data/details.rs create mode 100644 shipper/odeli-ups/src/data/mod.rs create mode 100644 shipper/odeli-ups/src/data/progress_activity.rs create mode 100644 shipper/odeli-ups/src/data/status_request.rs create mode 100644 shipper/odeli-ups/src/data/status_response.rs create mode 100644 shipper/odeli-ups/src/lib.rs create mode 100644 shipper/odeli-ups/src/urls.rs diff --git a/Cargo.lock b/Cargo.lock index 6a45e9c..8d89a98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,6 +110,34 @@ dependencies = [ "windows-targets 0.52.4", ] +[[package]] +name = "cookie" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "387461abbc748185c3a6e1673d826918b450b87ff22639429c694619a83b6cf6" +dependencies = [ + "cookie", + "idna 0.3.0", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -126,6 +154,15 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "encoding_rs" version = "0.8.33" @@ -351,6 +388,16 @@ dependencies = [ "cc", ] +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.5.0" @@ -476,6 +523,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.18" @@ -500,6 +553,7 @@ version = "0.1.0" dependencies = [ "odeli-canadapost", "odeli-core", + "odeli-ups", ] [[package]] @@ -525,6 +579,20 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "odeli-ups" +version = "0.1.0" +dependencies = [ + "chrono", + "isocountry", + "odeli-core", + "reqwest", + "reqwest_cookie_store", + "serde", + "serde_derive", + "tokio", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -599,6 +667,12 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.78" @@ -608,6 +682,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457" +dependencies = [ + "idna 0.3.0", + "psl-types", +] + [[package]] name = "quote" version = "1.0.35" @@ -625,6 +715,8 @@ checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251" dependencies = [ "base64", "bytes", + "cookie", + "cookie_store", "encoding_rs", "futures-core", "futures-util", @@ -657,6 +749,18 @@ dependencies = [ "winreg", ] +[[package]] +name = "reqwest_cookie_store" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba529055ea150e42e4eb9c11dcd380a41025ad4d594b0cb4904ef28b037e1061" +dependencies = [ + "bytes", + "cookie_store", + "reqwest", + "url", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -855,6 +959,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -980,7 +1115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", ] @@ -990,6 +1125,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 3f19ecc..d671104 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,11 @@ readme = "README.md" members = [ "odeli-core", "shipper/odeli-canadapost", + "shipper/odeli-ups", ] [dependencies] odeli-core = { version = "0.1", path = "./odeli-core" } odeli-canadapost = { version = "0.1", path = "./shipper/odeli-canadapost" } +odeli-ups = { version = "0.1", path = "./shipper/odeli-ups" } diff --git a/odeli-core/src/debug/methods.rs b/odeli-core/src/debug/methods.rs new file mode 100644 index 0000000..d34a3f6 --- /dev/null +++ b/odeli-core/src/debug/methods.rs @@ -0,0 +1,16 @@ +#[derive(Debug, Clone, Copy)] +pub enum Method { + WebApi, + OfficialApi, + Custom(&'static str), +} + +impl core::fmt::Display for Method { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::WebApi => write!(f, "Web API"), + Self::OfficialApi => write!(f, "Official API"), + Self::Custom(s) => write!(f, "{}", s), + } + } +} diff --git a/odeli-core/src/debug/mod.rs b/odeli-core/src/debug/mod.rs new file mode 100644 index 0000000..b035689 --- /dev/null +++ b/odeli-core/src/debug/mod.rs @@ -0,0 +1,4 @@ +mod methods; +pub use methods::Method; +mod shippers; +pub use shippers::Shipper; diff --git a/odeli-core/src/debug/shippers.rs b/odeli-core/src/debug/shippers.rs new file mode 100644 index 0000000..6207aef --- /dev/null +++ b/odeli-core/src/debug/shippers.rs @@ -0,0 +1,16 @@ +#[derive(Debug, Clone, Copy)] +pub enum Shipper { + CanadaPost, + Ups, + Custom(&'static str), +} + +impl core::fmt::Display for Shipper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CanadaPost => write!(f, "Canada Post"), + Self::Ups => write!(f, "UPS"), + Self::Custom(s) => write!(f, "{}", s), + } + } +} diff --git a/odeli-core/src/debug_info.rs b/odeli-core/src/debug_info.rs index 26fa3ff..4e79b4d 100644 --- a/odeli-core/src/debug_info.rs +++ b/odeli-core/src/debug_info.rs @@ -1,3 +1,7 @@ pub trait DebugInfo: super::Adapter + core::fmt::Debug { fn shipper_name(&self) -> String; + + fn shipper(&self) -> super::debug::Shipper; + + fn api(&self) -> super::debug::Method; } diff --git a/odeli-core/src/lib.rs b/odeli-core/src/lib.rs index 9457ea6..7f78da6 100644 --- a/odeli-core/src/lib.rs +++ b/odeli-core/src/lib.rs @@ -1,6 +1,7 @@ mod adapter; pub mod data; pub use adapter::{Adapter, Confidence}; +pub mod debug; mod debug_info; pub use debug_info::DebugInfo; mod errors; diff --git a/shipper/odeli-canadapost/src/adapter.rs b/shipper/odeli-canadapost/src/adapter.rs index 3cd8679..7c0ad7b 100644 --- a/shipper/odeli-canadapost/src/adapter.rs +++ b/shipper/odeli-canadapost/src/adapter.rs @@ -128,4 +128,12 @@ impl DebugInfo for CanadaPost { fn shipper_name(&self) -> String { crate::consts::SHIPPER_NAME.to_owned() } + + fn shipper(&self) -> odeli_core::debug::Shipper { + odeli_core::debug::Shipper::CanadaPost + } + + fn api(&self) -> odeli_core::debug::Method { + odeli_core::debug::Method::WebApi + } } diff --git a/shipper/odeli-ups/Cargo.toml b/shipper/odeli-ups/Cargo.toml new file mode 100644 index 0000000..9969b83 --- /dev/null +++ b/shipper/odeli-ups/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "odeli-ups" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +odeli-core = { version = "0.1", path = "../../odeli-core" } + +chrono = { version = "0.4" } +isocountry = { version = "0.3" } + +reqwest = { version = "0.11", features = [ "json", "cookies" ] } +reqwest_cookie_store = "0.6" + +serde = { version = "1.0" } +serde_derive = { version = "1.0" } + +[dev-dependencies] +tokio = { version = "1", features = [ "rt", "macros" ] } diff --git a/shipper/odeli-ups/src/adapter.rs b/shipper/odeli-ups/src/adapter.rs new file mode 100644 index 0000000..7cec959 --- /dev/null +++ b/shipper/odeli-ups/src/adapter.rs @@ -0,0 +1,141 @@ +use odeli_core::{Adapter, Confidence, DebugInfo}; +use std::sync::Arc; + +#[derive(Debug)] +pub struct Ups { + client: Arc, + //cookies: Arc, +} + +impl Ups { + pub fn new() -> Self { + //let cookie_store = Arc::new(reqwest_cookie_store::CookieStoreMutex::new(reqwest_cookie_store::CookieStore::new(None))); + Self { + client: Arc::new( + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .cookie_store(true) + //.cookie_provider(cookie_store.clone()) + .build() + .unwrap(), + ), + //cookies: cookie_store, + } + } + + pub fn with_client(client: Arc) -> Self { + Self { client } + } + + fn progress_activity_to_core_event( + activity: crate::data::ProgressActivity, + ) -> Result { + let type_ = match &activity.act_code as &str { + "RQ" => odeli_core::data::EventType::Delivered, + "RB" => odeli_core::data::EventType::OutForDelivery, + "Q2" => odeli_core::data::EventType::Info, + "Q0" => odeli_core::data::EventType::Creation, + _ => odeli_core::data::EventType::Unknown, + }; + Ok(odeli_core::data::TrackingEvent { + time: activity + .datetime() + .map_err(|e| odeli_core::AdapterError::Custom(Box::new(e)))?, + location: activity + .core_location() + .map_err(|e| odeli_core::AdapterError::Custom(e.to_owned().into()))?, + type_, + }) + } +} + +impl Adapter for Ups { + async fn track( + &self, + id: odeli_core::data::TrackingNumber, + ) -> Result { + let payload = crate::data::StatusRequest { + tracking_numbers: vec![id.as_str().to_owned()], + ..Default::default() + }; + let main_page = self + .client + .get(crate::urls::html_page_url(&id)) + .header("Accept-Encoding", "") + .header("Accept", "text/html") + .send() + .await + .map_err(|e| odeli_core::AdapterError::Network(Box::new(e)))?; + let mut status_req = self + .client + .post(crate::urls::SHIPMENT_STATUS_URL) + .header("Accept-Encoding", "") + .header("Origin", "https://www.ups.com") + //.version(reqwest::Version::HTTP_11) + .json(&payload); + for cookie in main_page.cookies() { + println!("Cookie {}={}", cookie.name(), cookie.value()); + if cookie.name().to_uppercase() == "X-XSRF-TOKEN-ST" { + println!("setting xsrf token to {}", cookie.value()); + status_req = status_req.header("X-XSRF-TOKEN", cookie.value()); + break; + } + } + let mut ship_status: crate::data::StatusResponse = status_req + //let text = status_req + .send() + .await + .map_err(|e| odeli_core::AdapterError::Network(Box::new(e)))? + .json() + .await + .map_err(|e| odeli_core::AdapterError::Network(Box::new(e)))?; + + let mut ship_details: Option = None; + while let Some(deets) = ship_status.track_details.pop() { + if deets.requested_tracking_number == id.as_str() + && deets.tracking_number == id.as_str() + { + ship_details = Some(deets); + break; + } + } + if ship_details.is_none() { + return Err(odeli_core::AdapterError::UnsupportedTrackingNumber(id)); + } + let ship_details = ship_details.unwrap(); + + let mut events = Vec::with_capacity(ship_details.shipment_progress_activities.len()); + for activity in ship_details.shipment_progress_activities.into_iter() { + events.push(Self::progress_activity_to_core_event(activity)?); + } + let is_delivered = ship_details.package_status_type == "Delivered"; + Ok(odeli_core::data::TrackingInfo { + id, + is_delivered, + is_finished: is_delivered, + events, + destination: ship_details.ship_to_address.to_core_location_opt().ok(), + origin: ship_details + .ship_from_address + .and_then(|addr| addr.to_core_location_opt().ok()), + }) + } + + fn is_supported_id(&self, _id: odeli_core::data::TrackingNumber) -> Confidence { + Confidence::new(0.5) + } +} + +impl DebugInfo for Ups { + fn shipper_name(&self) -> String { + crate::consts::SHIPPER_NAME.to_owned() + } + + fn shipper(&self) -> odeli_core::debug::Shipper { + odeli_core::debug::Shipper::Ups + } + + fn api(&self) -> odeli_core::debug::Method { + odeli_core::debug::Method::WebApi + } +} diff --git a/shipper/odeli-ups/src/consts.rs b/shipper/odeli-ups/src/consts.rs new file mode 100644 index 0000000..87ab3b1 --- /dev/null +++ b/shipper/odeli-ups/src/consts.rs @@ -0,0 +1 @@ +pub const SHIPPER_NAME: &'static str = "UPS"; diff --git a/shipper/odeli-ups/src/data/address.rs b/shipper/odeli-ups/src/data/address.rs new file mode 100644 index 0000000..6cbf0c2 --- /dev/null +++ b/shipper/odeli-ups/src/data/address.rs @@ -0,0 +1,66 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Address { + #[serde(rename = "streetAddress1")] + pub address_1: String, + #[serde(rename = "streetAddress2")] + pub address_2: String, + #[serde(rename = "streetAddress3")] + pub address_3: String, + pub city: String, + pub state: String, + pub country: String, + /// Zip or postal code + #[serde(rename = "zipCode")] + pub zip_code: String, + #[serde(rename = "companyName")] + pub company_name: String, + #[serde(rename = "attentionName")] + pub attention_name: String, + #[serde(rename = "isAddressCorrected")] + pub is_corrected: bool, + #[serde(rename = "isReturnAddress")] + pub is_return: bool, + #[serde(rename = "isHoldAddress")] + pub is_hold: bool, +} + +impl Address { + pub fn to_core_location_opt( + self, + ) -> Result { + self.parse_country_code().map(|cc| { + let region = odeli_core::data::Region { + municipality: empty_to_none(self.city), + administrative_division: empty_to_none(self.state), + country: cc.name().to_owned(), + country_code: cc, + }; + if self.address_1.is_empty() { + region.into() + } else { + odeli_core::data::Address { + address_1: self.address_1, + address_2: empty_to_none(self.address_2), + region, + } + .into() + } + }) + } + + fn parse_country_code( + &self, + ) -> Result { + isocountry::CountryCode::for_alpha2_caseless(&self.country) + } +} + +fn empty_to_none(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(s) + } +} diff --git a/shipper/odeli-ups/src/data/details.rs b/shipper/odeli-ups/src/data/details.rs new file mode 100644 index 0000000..fdea3bb --- /dev/null +++ b/shipper/odeli-ups/src/data/details.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Details { + #[serde(rename = "requestedTrackingNumber")] + pub requested_tracking_number: String, + #[serde(rename = "trackingNumber")] + pub tracking_number: String, + #[serde(rename = "packageStatusType")] + pub package_status_type: String, + #[serde(rename = "packageStatusCode")] + pub package_status_code: String, + #[serde(rename = "progressBarType")] + pub progress_bar_type: String, + #[serde(rename = "shipToAddress")] + pub ship_to_address: super::Address, + #[serde(rename = "shipFromAddress")] + pub ship_from_address: Option, + #[serde(rename = "proofOfDeliveryUrl")] + pub proof_of_delivery_url: Option, + #[serde(rename = "shipmentProgressActivities")] + pub shipment_progress_activities: Vec, +} diff --git a/shipper/odeli-ups/src/data/mod.rs b/shipper/odeli-ups/src/data/mod.rs new file mode 100644 index 0000000..41444fd --- /dev/null +++ b/shipper/odeli-ups/src/data/mod.rs @@ -0,0 +1,10 @@ +mod address; +pub use address::Address; +mod details; +pub use details::Details; +mod progress_activity; +pub use progress_activity::ProgressActivity; +mod status_request; +pub use status_request::StatusRequest; +mod status_response; +pub use status_response::StatusResponse; diff --git a/shipper/odeli-ups/src/data/progress_activity.rs b/shipper/odeli-ups/src/data/progress_activity.rs new file mode 100644 index 0000000..e253d89 --- /dev/null +++ b/shipper/odeli-ups/src/data/progress_activity.rs @@ -0,0 +1,72 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct ProgressActivity { + pub date: String, + /// Local time? + pub time: String, + /// Country? + pub location: String, + /// Event type + #[serde(rename = "activityScan")] + pub activity_scan: String, + /// Event code + #[serde(rename = "actCode")] + pub act_code: String, + #[serde(rename = "gmtDate")] + pub gmt_date: String, + #[serde(rename = "gmtOffset")] + pub gmt_offset: String, + #[serde(rename = "gmtTime")] + pub gmt_time: Option, +} + +impl ProgressActivity { + pub fn datetime(&self) -> chrono::format::ParseResult> { + let date = chrono::naive::NaiveDate::parse_from_str(&self.date, "%m/%d/%Y")?; + let time = + chrono::naive::NaiveTime::parse_from_str(&self.time.replace('.', ""), "%I:%M %p")?; + let datetime = date.and_time(time); + // FIXME + // The timezone cannot be guessed, so everything is assumed to be the UTC timezone. + // UPS seems to use local timezone for this but doesn't provide enough information + // to figure out what the local timezone actually is... + Ok(datetime.and_utc()) + } + + pub fn core_location(&self) -> Result, &'static str> { + if self.location.is_empty() { + return Ok(None); + } + let mut split_location: std::collections::VecDeque<_> = self.location.split(", ").collect(); + if let Some(country_name) = split_location.pop_back() { + let cc = Self::find_country_code(country_name)?; + let administrative_division = split_location.pop_back().map(|s| s.to_owned()); + let municipality = split_location.pop_back().map(|s| s.to_owned()); + Ok(Some( + odeli_core::data::Region { + municipality, + administrative_division, + country: country_name.to_owned(), + country_code: cc, + } + .into(), + )) + } else { + Err("Unrecognized location string") + } + } + + fn find_country_code(country: &str) -> Result { + let country_lower = country.to_lowercase(); + for cc in isocountry::CountryCode::iter() { + let cc_lower = cc.name().to_lowercase(); + if cc_lower == country_lower + || (cc_lower.contains(' ') && cc_lower.contains(&country_lower)) + { + return Ok(*cc); + } + } + Err("Unrecognized country name") + } +} diff --git a/shipper/odeli-ups/src/data/status_request.rs b/shipper/odeli-ups/src/data/status_request.rs new file mode 100644 index 0000000..6e43287 --- /dev/null +++ b/shipper/odeli-ups/src/data/status_request.rs @@ -0,0 +1,28 @@ +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct StatusRequest { + /// Browser language + #[serde(rename = "Locale")] + pub locale: String, + /// Tracking numbers for which to retrieve status info + #[serde(rename = "TrackingNumber")] + pub tracking_numbers: Vec, + /// ??? + #[serde(rename = "Requester")] + pub requester: String, + /// ??? + #[serde(rename = "returnToValue")] + pub return_to_value: String, +} + +impl Default for StatusRequest { + fn default() -> Self { + Self { + locale: "en_US".to_owned(), + requester: "st/trackdetails".to_owned(), + return_to_value: "".to_owned(), + tracking_numbers: Vec::new(), + } + } +} diff --git a/shipper/odeli-ups/src/data/status_response.rs b/shipper/odeli-ups/src/data/status_response.rs new file mode 100644 index 0000000..a292695 --- /dev/null +++ b/shipper/odeli-ups/src/data/status_response.rs @@ -0,0 +1,15 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct StatusResponse { + #[serde(rename = "statusCode")] + pub status_code: String, + #[serde(rename = "statusText")] + pub status_text: String, + #[serde(rename = "isLoggedInUser")] + pub is_logged_in: bool, + #[serde(rename = "trackedDateTime")] + pub tracked_datetime: String, + #[serde(rename = "trackDetails")] + pub track_details: Vec, +} diff --git a/shipper/odeli-ups/src/lib.rs b/shipper/odeli-ups/src/lib.rs new file mode 100644 index 0000000..34ba2f5 --- /dev/null +++ b/shipper/odeli-ups/src/lib.rs @@ -0,0 +1,58 @@ +mod adapter; +pub use adapter::Ups; +pub(crate) mod consts; +pub(crate) mod data; +pub(crate) mod urls; + +#[cfg(test)] +mod tests { + use super::*; + use odeli_core::Adapter; + + const TEST_VALID_TRACKING_NUMBER: &str = "801189003898800967"; + const TEST_INVALID_TRACKING_NUMBER: &str = "NOTATRACKINGNUMBER"; + + #[test] + fn it_works() { + println!("Ups::new() -> {:?}", Ups::new()) + } + + #[tokio::test] + async fn track_valid() { + let adapter = Ups::new(); + println!( + "Trying to get tracking info for {}", + TEST_VALID_TRACKING_NUMBER + ); + let info_result = adapter + .track(odeli_core::data::TrackingNumber::new( + TEST_VALID_TRACKING_NUMBER, + )) + .await; + dbg!(&info_result); + assert!(info_result.is_ok()); + let info = info_result.unwrap(); + println!( + "Got tracking info for {}: {:?}", + TEST_VALID_TRACKING_NUMBER, info + ); + } + + #[tokio::test] + async fn track_invalid() { + let adapter = Ups::new(); + println!( + "Trying to get tracking info for (bad) {}", + TEST_INVALID_TRACKING_NUMBER + ); + let info_result = adapter + .track(odeli_core::data::TrackingNumber::new( + TEST_INVALID_TRACKING_NUMBER, + )) + .await; + assert!( + info_result.is_err(), + "Tracking info retrieval should return Result::Err(...) for invalid tracking number" + ); + } +} diff --git a/shipper/odeli-ups/src/urls.rs b/shipper/odeli-ups/src/urls.rs new file mode 100644 index 0000000..a151cd0 --- /dev/null +++ b/shipper/odeli-ups/src/urls.rs @@ -0,0 +1,14 @@ +pub const SHIPMENT_STATUS_URL: &'static str = + "https://webapis.ups.com/track/api/Track/GetStatus?loc=en_US"; + +const HTML_PAGE_URL_START: &'static str = "https://www.ups.com/track?track=yes&trackNums="; +const HTML_PAGE_URL_END: &'static str = "&loc=en_US&requester=ST/trackdetails"; + +pub fn html_page_url(id: &odeli_core::data::TrackingNumber) -> String { + format!( + "{}{}{}", + HTML_PAGE_URL_START, + id.as_str(), + HTML_PAGE_URL_END + ) +}