Add UPS support
This commit is contained in:
parent
f005a9d106
commit
fc6e940d39
19 changed files with 642 additions and 1 deletions
143
Cargo.lock
generated
143
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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" }
|
||||
|
|
16
odeli-core/src/debug/methods.rs
Normal file
16
odeli-core/src/debug/methods.rs
Normal file
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
4
odeli-core/src/debug/mod.rs
Normal file
4
odeli-core/src/debug/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
mod methods;
|
||||
pub use methods::Method;
|
||||
mod shippers;
|
||||
pub use shippers::Shipper;
|
16
odeli-core/src/debug/shippers.rs
Normal file
16
odeli-core/src/debug/shippers.rs
Normal file
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
21
shipper/odeli-ups/Cargo.toml
Normal file
21
shipper/odeli-ups/Cargo.toml
Normal file
|
@ -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" ] }
|
141
shipper/odeli-ups/src/adapter.rs
Normal file
141
shipper/odeli-ups/src/adapter.rs
Normal file
|
@ -0,0 +1,141 @@
|
|||
use odeli_core::{Adapter, Confidence, DebugInfo};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Ups {
|
||||
client: Arc<reqwest::Client>,
|
||||
//cookies: Arc<reqwest_cookie_store::CookieStoreMutex>,
|
||||
}
|
||||
|
||||
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<reqwest::Client>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
fn progress_activity_to_core_event(
|
||||
activity: crate::data::ProgressActivity,
|
||||
) -> Result<odeli_core::data::TrackingEvent, odeli_core::AdapterError> {
|
||||
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<odeli_core::data::TrackingInfo, odeli_core::AdapterError> {
|
||||
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<crate::data::Details> = 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
|
||||
}
|
||||
}
|
1
shipper/odeli-ups/src/consts.rs
Normal file
1
shipper/odeli-ups/src/consts.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub const SHIPPER_NAME: &'static str = "UPS";
|
66
shipper/odeli-ups/src/data/address.rs
Normal file
66
shipper/odeli-ups/src/data/address.rs
Normal file
|
@ -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<odeli_core::data::Location, isocountry::CountryCodeParseErr> {
|
||||
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, isocountry::CountryCodeParseErr> {
|
||||
isocountry::CountryCode::for_alpha2_caseless(&self.country)
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_to_none(s: String) -> Option<String> {
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
23
shipper/odeli-ups/src/data/details.rs
Normal file
23
shipper/odeli-ups/src/data/details.rs
Normal file
|
@ -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<super::Address>,
|
||||
#[serde(rename = "proofOfDeliveryUrl")]
|
||||
pub proof_of_delivery_url: Option<String>,
|
||||
#[serde(rename = "shipmentProgressActivities")]
|
||||
pub shipment_progress_activities: Vec<super::ProgressActivity>,
|
||||
}
|
10
shipper/odeli-ups/src/data/mod.rs
Normal file
10
shipper/odeli-ups/src/data/mod.rs
Normal file
|
@ -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;
|
72
shipper/odeli-ups/src/data/progress_activity.rs
Normal file
72
shipper/odeli-ups/src/data/progress_activity.rs
Normal file
|
@ -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<String>,
|
||||
}
|
||||
|
||||
impl ProgressActivity {
|
||||
pub fn datetime(&self) -> chrono::format::ParseResult<chrono::DateTime<chrono::offset::Utc>> {
|
||||
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<Option<odeli_core::data::Location>, &'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<isocountry::CountryCode, &'static str> {
|
||||
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")
|
||||
}
|
||||
}
|
28
shipper/odeli-ups/src/data/status_request.rs
Normal file
28
shipper/odeli-ups/src/data/status_request.rs
Normal file
|
@ -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<String>,
|
||||
/// ???
|
||||
#[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(),
|
||||
}
|
||||
}
|
||||
}
|
15
shipper/odeli-ups/src/data/status_response.rs
Normal file
15
shipper/odeli-ups/src/data/status_response.rs
Normal file
|
@ -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<super::Details>,
|
||||
}
|
58
shipper/odeli-ups/src/lib.rs
Normal file
58
shipper/odeli-ups/src/lib.rs
Normal file
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
14
shipper/odeli-ups/src/urls.rs
Normal file
14
shipper/odeli-ups/src/urls.rs
Normal file
|
@ -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
|
||||
)
|
||||
}
|
Loading…
Reference in a new issue