Add UPS support

This commit is contained in:
NGnius (Graham) 2024-03-07 19:30:32 -05:00
parent f005a9d106
commit fc6e940d39
19 changed files with 642 additions and 1 deletions

143
Cargo.lock generated
View file

@ -110,6 +110,34 @@ dependencies = [
"windows-targets 0.52.4", "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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@ -126,6 +154,15 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
[[package]]
name = "deranged"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [
"powerfmt",
]
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.33" version = "0.8.33"
@ -351,6 +388,16 @@ dependencies = [
"cc", "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]] [[package]]
name = "idna" name = "idna"
version = "0.5.0" version = "0.5.0"
@ -476,6 +523,12 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.18" version = "0.2.18"
@ -500,6 +553,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"odeli-canadapost", "odeli-canadapost",
"odeli-core", "odeli-core",
"odeli-ups",
] ]
[[package]] [[package]]
@ -525,6 +579,20 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "odeli-ups"
version = "0.1.0"
dependencies = [
"chrono",
"isocountry",
"odeli-core",
"reqwest",
"reqwest_cookie_store",
"serde",
"serde_derive",
"tokio",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.19.0" version = "1.19.0"
@ -599,6 +667,12 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.78" version = "1.0.78"
@ -608,6 +682,22 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.35" version = "1.0.35"
@ -625,6 +715,8 @@ checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"cookie",
"cookie_store",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
@ -657,6 +749,18 @@ dependencies = [
"winreg", "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]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.23" version = "0.1.23"
@ -855,6 +959,37 @@ dependencies = [
"syn", "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]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.6.0" version = "1.6.0"
@ -980,7 +1115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633"
dependencies = [ dependencies = [
"form_urlencoded", "form_urlencoded",
"idna", "idna 0.5.0",
"percent-encoding", "percent-encoding",
] ]
@ -990,6 +1125,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"

View file

@ -12,9 +12,11 @@ readme = "README.md"
members = [ members = [
"odeli-core", "odeli-core",
"shipper/odeli-canadapost", "shipper/odeli-canadapost",
"shipper/odeli-ups",
] ]
[dependencies] [dependencies]
odeli-core = { version = "0.1", path = "./odeli-core" } odeli-core = { version = "0.1", path = "./odeli-core" }
odeli-canadapost = { version = "0.1", path = "./shipper/odeli-canadapost" } odeli-canadapost = { version = "0.1", path = "./shipper/odeli-canadapost" }
odeli-ups = { version = "0.1", path = "./shipper/odeli-ups" }

View 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),
}
}
}

View file

@ -0,0 +1,4 @@
mod methods;
pub use methods::Method;
mod shippers;
pub use shippers::Shipper;

View 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),
}
}
}

View file

@ -1,3 +1,7 @@
pub trait DebugInfo: super::Adapter + core::fmt::Debug { pub trait DebugInfo: super::Adapter + core::fmt::Debug {
fn shipper_name(&self) -> String; fn shipper_name(&self) -> String;
fn shipper(&self) -> super::debug::Shipper;
fn api(&self) -> super::debug::Method;
} }

View file

@ -1,6 +1,7 @@
mod adapter; mod adapter;
pub mod data; pub mod data;
pub use adapter::{Adapter, Confidence}; pub use adapter::{Adapter, Confidence};
pub mod debug;
mod debug_info; mod debug_info;
pub use debug_info::DebugInfo; pub use debug_info::DebugInfo;
mod errors; mod errors;

View file

@ -128,4 +128,12 @@ impl DebugInfo for CanadaPost {
fn shipper_name(&self) -> String { fn shipper_name(&self) -> String {
crate::consts::SHIPPER_NAME.to_owned() 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
}
} }

View 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" ] }

View 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
}
}

View file

@ -0,0 +1 @@
pub const SHIPPER_NAME: &'static str = "UPS";

View 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)
}
}

View 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>,
}

View 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;

View 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")
}
}

View 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(),
}
}
}

View 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>,
}

View 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"
);
}
}

View 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
)
}