Create basic structure, add Canada Post support
This commit is contained in:
commit
287af3d63a
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
1233
Cargo.lock
generated
Normal file
1233
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
12
Cargo.toml
Normal file
12
Cargo.toml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
workspace = { members = [ "odeli-core", "shipper/odeli-canadapost"] }
|
||||||
|
[package]
|
||||||
|
name = "odeli"
|
||||||
|
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" }
|
||||||
|
|
||||||
|
odeli-canadapost = { version = "0.1", path = "./shipper/odeli-canadapost" }
|
18
odeli-core/Cargo.toml
Normal file
18
odeli-core/Cargo.toml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "odeli-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1.0", optional = true }
|
||||||
|
serde_derive = { version = "1.0", optional = true }
|
||||||
|
|
||||||
|
chrono = { version = "0.4" }
|
||||||
|
isocountry = { version = "0.3" }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = [ "ser", "de" ]
|
||||||
|
ser = [ "serde", "serde/derive", "chrono/serde" ]
|
||||||
|
de = [ "serde", "serde/derive", "chrono/serde" ]
|
40
odeli-core/src/adapter.rs
Normal file
40
odeli-core/src/adapter.rs
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
use crate::data::{TrackingInfo, TrackingNumber};
|
||||||
|
use crate::{AdapterError, ConfidenceError};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Confidence(f64);
|
||||||
|
|
||||||
|
impl std::ops::Deref for Confidence {
|
||||||
|
type Target = f64;
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Confidence {
|
||||||
|
pub fn new(confidence: f64) -> Self {
|
||||||
|
assert!(confidence >= 0.0);
|
||||||
|
assert!(confidence <= 1.0);
|
||||||
|
Self(confidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_new(confidence: f64) -> Result<Self, ConfidenceError> {
|
||||||
|
if confidence < 1.0 {
|
||||||
|
Err(ConfidenceError::NumberTooLow)
|
||||||
|
} else if confidence > 1.0 {
|
||||||
|
Err(ConfidenceError::NumberTooHigh)
|
||||||
|
} else {
|
||||||
|
Ok(Self(confidence))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shipping service adapter
|
||||||
|
pub trait Adapter {
|
||||||
|
/// Retrieve shipment information by tracking number
|
||||||
|
#[allow(async_fn_in_trait)]
|
||||||
|
async fn track(&self, id: TrackingNumber) -> Result<TrackingInfo, AdapterError>;
|
||||||
|
|
||||||
|
/// Determines if `id` is supported by this adapter, returns confidence (0.0 to 1.0)
|
||||||
|
fn is_supported_id(&self, id: TrackingNumber) -> Confidence;
|
||||||
|
}
|
59
odeli-core/src/data/location.rs
Normal file
59
odeli-core/src/data/location.rs
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
#[cfg(feature = "de")]
|
||||||
|
use serde::Deserialize;
|
||||||
|
#[cfg(feature = "ser")]
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "ser", derive(Serialize))]
|
||||||
|
#[cfg_attr(feature = "de", derive(Deserialize))]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Region {
|
||||||
|
pub municipality: Option<String>,
|
||||||
|
pub administrative_division: Option<String>,
|
||||||
|
pub country: String,
|
||||||
|
pub country_code: isocountry::CountryCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "ser", derive(Serialize))]
|
||||||
|
#[cfg_attr(feature = "de", derive(Deserialize))]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Address {
|
||||||
|
pub address_1: String,
|
||||||
|
pub address_2: Option<String>,
|
||||||
|
pub region: Region,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "ser", derive(Serialize))]
|
||||||
|
#[cfg_attr(feature = "de", derive(Deserialize))]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Location {
|
||||||
|
Region(Region),
|
||||||
|
Address(Address),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Location {
|
||||||
|
pub fn region(&self) -> &'_ Region {
|
||||||
|
match self {
|
||||||
|
Self::Region(reg) => reg,
|
||||||
|
Self::Address(addr) => &addr.region,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn address(&self) -> Option<&'_ Address> {
|
||||||
|
match self {
|
||||||
|
Self::Region(_) => None,
|
||||||
|
Self::Address(addr) => Some(addr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::convert::From<Region> for Location {
|
||||||
|
fn from(value: Region) -> Self {
|
||||||
|
Self::Region(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::convert::From<Address> for Location {
|
||||||
|
fn from(value: Address) -> Self {
|
||||||
|
Self::Address(value)
|
||||||
|
}
|
||||||
|
}
|
8
odeli-core/src/data/mod.rs
Normal file
8
odeli-core/src/data/mod.rs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
mod location;
|
||||||
|
pub use location::{Address, Location, Region};
|
||||||
|
mod tracking_event;
|
||||||
|
pub use tracking_event::{EventType, TrackingEvent};
|
||||||
|
mod tracking_info;
|
||||||
|
pub use tracking_info::TrackingInfo;
|
||||||
|
mod tracking_num;
|
||||||
|
pub use tracking_num::TrackingNumber;
|
25
odeli-core/src/data/tracking_event.rs
Normal file
25
odeli-core/src/data/tracking_event.rs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
#[cfg(feature = "de")]
|
||||||
|
use serde::Deserialize;
|
||||||
|
#[cfg(feature = "ser")]
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "ser", derive(Serialize))]
|
||||||
|
#[cfg_attr(feature = "de", derive(Deserialize))]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TrackingEvent {
|
||||||
|
pub time: chrono::DateTime<chrono::offset::Utc>,
|
||||||
|
pub location: Option<super::Location>,
|
||||||
|
#[cfg_attr(any(feature = "de", feature = "ser"), serde(rename = "type"))]
|
||||||
|
pub type_: EventType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "ser", derive(Serialize))]
|
||||||
|
#[cfg_attr(feature = "de", derive(Deserialize))]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum EventType {
|
||||||
|
Delivered,
|
||||||
|
OutForDelivery,
|
||||||
|
Info,
|
||||||
|
Creation,
|
||||||
|
Unknown,
|
||||||
|
}
|
25
odeli-core/src/data/tracking_info.rs
Normal file
25
odeli-core/src/data/tracking_info.rs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
#[cfg(feature = "de")]
|
||||||
|
use serde::Deserialize;
|
||||||
|
#[cfg(feature = "ser")]
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "ser", derive(Serialize))]
|
||||||
|
#[cfg_attr(feature = "de", derive(Deserialize))]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TrackingInfo {
|
||||||
|
/// Tracking ID
|
||||||
|
pub id: super::TrackingNumber,
|
||||||
|
/// Is the package delivered to its destination?
|
||||||
|
pub is_delivered: bool,
|
||||||
|
/// Is the package finished its journey?
|
||||||
|
/// This does not necessarily mean it is delivered, e.g. the package may have been rejected, destroyed, owner may not have been home, etc.
|
||||||
|
pub is_finished: bool,
|
||||||
|
/// Shipment events
|
||||||
|
pub events: Vec<super::TrackingEvent>,
|
||||||
|
/// Shipment target destination.
|
||||||
|
/// Some tracking APIs do not provide this information without some form of authentication, so this may be `None` when there is a destination.
|
||||||
|
pub destination: Option<super::Location>,
|
||||||
|
/// Shipment originating location.
|
||||||
|
/// Some tracking APIs do not provide this information without authentication, so this may be `None` when there is an origin.
|
||||||
|
pub origin: Option<super::Location>,
|
||||||
|
}
|
25
odeli-core/src/data/tracking_num.rs
Normal file
25
odeli-core/src/data/tracking_num.rs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
#[cfg(feature = "de")]
|
||||||
|
use serde::Deserialize;
|
||||||
|
#[cfg(feature = "ser")]
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "ser", derive(Serialize))]
|
||||||
|
#[cfg_attr(feature = "de", derive(Deserialize))]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TrackingNumber(String);
|
||||||
|
|
||||||
|
impl core::fmt::Display for TrackingNumber {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackingNumber {
|
||||||
|
pub fn as_str(&self) -> &'_ str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(s: impl Into<String>) -> Self {
|
||||||
|
Self(s.into())
|
||||||
|
}
|
||||||
|
}
|
3
odeli-core/src/debug_info.rs
Normal file
3
odeli-core/src/debug_info.rs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
pub trait DebugInfo: super::Adapter + core::fmt::Debug {
|
||||||
|
fn shipper_name(&self) -> String;
|
||||||
|
}
|
37
odeli-core/src/errors.rs
Normal file
37
odeli-core/src/errors.rs
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AdapterError {
|
||||||
|
UnsupportedTrackingNumber(crate::data::TrackingNumber),
|
||||||
|
Network(Box<dyn std::error::Error>),
|
||||||
|
Custom(Box<dyn std::error::Error>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for AdapterError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::UnsupportedTrackingNumber(num) => {
|
||||||
|
write!(f, "Adapter tracking number error: {} is unsupported", num)
|
||||||
|
}
|
||||||
|
Self::Network(err) => write!(f, "Adapter network error: {}", err),
|
||||||
|
Self::Custom(err) => write!(f, "Adapter error: {}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for AdapterError {}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ConfidenceError {
|
||||||
|
NumberTooLow,
|
||||||
|
NumberTooHigh,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for ConfidenceError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NumberTooLow => write!(f, "Confidence cannot be less than 0.0"),
|
||||||
|
Self::NumberTooHigh => write!(f, "Confidence cannot be greater than 1.0"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ConfidenceError {}
|
22
odeli-core/src/lib.rs
Normal file
22
odeli-core/src/lib.rs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
mod adapter;
|
||||||
|
pub mod data;
|
||||||
|
pub use adapter::{Adapter, Confidence};
|
||||||
|
mod debug_info;
|
||||||
|
pub use debug_info::DebugInfo;
|
||||||
|
mod errors;
|
||||||
|
pub use errors::{AdapterError, ConfidenceError};
|
||||||
|
|
||||||
|
pub fn add(left: usize, right: usize) -> usize {
|
||||||
|
left + right
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_works() {
|
||||||
|
let result = add(2, 2);
|
||||||
|
assert_eq!(result, 4);
|
||||||
|
}
|
||||||
|
}
|
20
shipper/odeli-canadapost/Cargo.toml
Normal file
20
shipper/odeli-canadapost/Cargo.toml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "odeli-canadapost"
|
||||||
|
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" ] }
|
||||||
|
|
||||||
|
serde = { version = "1.0" }
|
||||||
|
serde_derive = { version = "1.0" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1", features = [ "rt", "macros" ] }
|
131
shipper/odeli-canadapost/src/adapter.rs
Normal file
131
shipper/odeli-canadapost/src/adapter.rs
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
use odeli_core::{Adapter, Confidence, DebugInfo};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CanadaPost {
|
||||||
|
client: Arc<reqwest::Client>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanadaPost {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: Arc::new(reqwest::Client::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_client(client: Arc<reqwest::Client>) -> Self {
|
||||||
|
Self { client }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_to_core_event(
|
||||||
|
event: super::data::Event,
|
||||||
|
) -> Result<odeli_core::data::TrackingEvent, odeli_core::AdapterError> {
|
||||||
|
let time = event
|
||||||
|
.datetime
|
||||||
|
.to_chrono()
|
||||||
|
.map_err(|e| odeli_core::AdapterError::Custom(Box::new(e)))?;
|
||||||
|
let location = event.location.to_core_location_opt().ok();
|
||||||
|
let type_ = match &event.type_ as &str {
|
||||||
|
"Delivered" => odeli_core::data::EventType::Delivered,
|
||||||
|
"Out" => odeli_core::data::EventType::OutForDelivery,
|
||||||
|
"Info" | "VehicleInfo" => odeli_core::data::EventType::Info,
|
||||||
|
"Induction" => odeli_core::data::EventType::Creation,
|
||||||
|
_ => odeli_core::data::EventType::Unknown,
|
||||||
|
};
|
||||||
|
Ok(odeli_core::data::TrackingEvent {
|
||||||
|
time,
|
||||||
|
location,
|
||||||
|
type_,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Adapter for CanadaPost {
|
||||||
|
async fn track(
|
||||||
|
&self,
|
||||||
|
id: odeli_core::data::TrackingNumber,
|
||||||
|
) -> Result<odeli_core::data::TrackingInfo, odeli_core::AdapterError> {
|
||||||
|
/*let url_package = crate::urls::build_shipment_package_url(&id);
|
||||||
|
let _package: crate::data::PackageResponse = self.client.get(url_package)
|
||||||
|
.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 url_details = crate::urls::build_shipment_details_url(&id);
|
||||||
|
let details: crate::data::DetailResponse = self
|
||||||
|
.client
|
||||||
|
.get(url_details)
|
||||||
|
//.header("Accept", "application/json")
|
||||||
|
//.header("Authorization", "Basic Og==")
|
||||||
|
.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 events = Vec::with_capacity(details.events.len());
|
||||||
|
for event in details.events.into_iter() {
|
||||||
|
events.push(Self::event_to_core_event(event)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(odeli_core::data::TrackingInfo {
|
||||||
|
id,
|
||||||
|
is_delivered: details.delivered,
|
||||||
|
is_finished: details.final_event,
|
||||||
|
events,
|
||||||
|
destination: details
|
||||||
|
.ship_to_address
|
||||||
|
.to_core_location_opt()
|
||||||
|
.ok()
|
||||||
|
.or_else(|| {
|
||||||
|
if details.addition_destination_info.is_empty() || !details.canadian_destination
|
||||||
|
{
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let mut city_province = details.addition_destination_info.split(", "); // "CITY, PROVINCE"
|
||||||
|
Some(
|
||||||
|
odeli_core::data::Region {
|
||||||
|
municipality: city_province.next().map(|s| s.to_owned()),
|
||||||
|
administrative_division: city_province.next().map(|s| s.to_owned()),
|
||||||
|
country: isocountry::full::ISO_FULL_CAN.to_owned(),
|
||||||
|
country_code: isocountry::CountryCode::CAN,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
origin: details
|
||||||
|
.ship_from_address
|
||||||
|
.to_core_location_opt()
|
||||||
|
.ok()
|
||||||
|
.or_else(|| {
|
||||||
|
if details.additional_origin_info.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let mut city_province = details.additional_origin_info.split(", "); // "CITY, PROVINCE"
|
||||||
|
Some(
|
||||||
|
odeli_core::data::Region {
|
||||||
|
municipality: city_province.next().map(|s| s.to_owned()),
|
||||||
|
administrative_division: city_province.next().map(|s| s.to_owned()),
|
||||||
|
country: isocountry::full::ISO_FULL_CAN.to_owned(),
|
||||||
|
country_code: isocountry::CountryCode::CAN,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_supported_id(&self, _id: odeli_core::data::TrackingNumber) -> Confidence {
|
||||||
|
Confidence::new(0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DebugInfo for CanadaPost {
|
||||||
|
fn shipper_name(&self) -> String {
|
||||||
|
crate::consts::SHIPPER_NAME.to_owned()
|
||||||
|
}
|
||||||
|
}
|
3
shipper/odeli-canadapost/src/consts.rs
Normal file
3
shipper/odeli-canadapost/src/consts.rs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
// pub const SHIPPER_NAME_EN: &'static str = "Canada Post";
|
||||||
|
// pub const SHIPPER_NAME_FR: &'static str = "Postes Canada";
|
||||||
|
pub const SHIPPER_NAME: &'static str = "Canada Post - Postes Canada";
|
90
shipper/odeli-canadapost/src/data/address.rs
Normal file
90
shipper/odeli-canadapost/src/data/address.rs
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct StreetAddress {
|
||||||
|
#[serde(rename = "addrLn1")]
|
||||||
|
pub address_1: String,
|
||||||
|
#[serde(rename = "addrLn2")]
|
||||||
|
pub address_2: String,
|
||||||
|
#[serde(rename = "countryCd")]
|
||||||
|
pub country_code: String,
|
||||||
|
pub city: String,
|
||||||
|
#[serde(rename = "regionCd")]
|
||||||
|
pub region_code: String,
|
||||||
|
/// NOT postal code
|
||||||
|
#[serde(rename = "postCd")]
|
||||||
|
pub post_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreetAddress {
|
||||||
|
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.region_code),
|
||||||
|
country: cc.name().to_owned(),
|
||||||
|
country_code: cc,
|
||||||
|
};
|
||||||
|
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_code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct City {
|
||||||
|
#[serde(rename = "countryCd")]
|
||||||
|
pub country_code: String,
|
||||||
|
#[serde(rename = "countryNmEn")]
|
||||||
|
pub country_name_en: Option<String>,
|
||||||
|
#[serde(rename = "countryNmFr")]
|
||||||
|
pub country_name_fr: Option<String>,
|
||||||
|
pub city: String,
|
||||||
|
#[serde(rename = "regionCd")]
|
||||||
|
pub region_code: String,
|
||||||
|
/// NOT postal code
|
||||||
|
#[serde(rename = "postCd")]
|
||||||
|
pub post_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl City {
|
||||||
|
pub fn to_core_location_opt(
|
||||||
|
self,
|
||||||
|
) -> Result<odeli_core::data::Location, isocountry::CountryCodeParseErr> {
|
||||||
|
self.parse_country_code().map(|cc| {
|
||||||
|
odeli_core::data::Region {
|
||||||
|
municipality: empty_to_none(self.city),
|
||||||
|
administrative_division: empty_to_none(self.region_code),
|
||||||
|
country: cc.name().to_owned(),
|
||||||
|
country_code: cc,
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_country_code(
|
||||||
|
&self,
|
||||||
|
) -> Result<isocountry::CountryCode, isocountry::CountryCodeParseErr> {
|
||||||
|
isocountry::CountryCode::for_alpha2_caseless(&self.country_code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_to_none(s: String) -> Option<String> {
|
||||||
|
if s.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
}
|
41
shipper/odeli-canadapost/src/data/datetime.rs
Normal file
41
shipper/odeli-canadapost/src/data/datetime.rs
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DateTime {
|
||||||
|
pub date: String,
|
||||||
|
pub time: String,
|
||||||
|
#[serde(rename = "zoneOffset")]
|
||||||
|
pub zone_offset: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DateTime {
|
||||||
|
pub fn to_chrono(
|
||||||
|
mut self,
|
||||||
|
) -> chrono::format::ParseResult<chrono::DateTime<chrono::offset::Utc>> {
|
||||||
|
let date = chrono::naive::NaiveDate::parse_from_str(&self.date, "%Y-%m-%d")?;
|
||||||
|
let time = chrono::naive::NaiveTime::parse_from_str(&self.time, "%H:%M:%S")?;
|
||||||
|
let datetime = date.and_time(time);
|
||||||
|
let sign = self.zone_offset.remove(0);
|
||||||
|
let offset = chrono::naive::NaiveTime::parse_from_str(&self.zone_offset, "%H:%M")?;
|
||||||
|
let time_since_0 =
|
||||||
|
offset.signed_duration_since(chrono::naive::NaiveTime::from_hms_opt(0, 0, 0).unwrap());
|
||||||
|
let tz = chrono::offset::FixedOffset::east_opt(if sign == '-' {
|
||||||
|
-(time_since_0.num_seconds() as i32)
|
||||||
|
} else {
|
||||||
|
time_since_0.num_seconds() as i32
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let datetime_with_tz = datetime.and_local_timezone(tz).unwrap();
|
||||||
|
Ok(datetime_with_tz.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ExpectedDate {
|
||||||
|
/// Latest estimated day
|
||||||
|
#[serde(rename = "revisedDate")]
|
||||||
|
pub revised_date: String,
|
||||||
|
/// Original estimated day
|
||||||
|
#[serde(rename = "dlvryDate")]
|
||||||
|
pub delivery_date: String,
|
||||||
|
}
|
87
shipper/odeli-canadapost/src/data/detail_response.rs
Normal file
87
shipper/odeli-canadapost/src/data/detail_response.rs
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DetailResponse {
|
||||||
|
/// Package tracking number
|
||||||
|
pub pin: String,
|
||||||
|
/// Product English display name
|
||||||
|
#[serde(rename = "productNmEn")]
|
||||||
|
pub product_name_english: String,
|
||||||
|
/// Product French display name
|
||||||
|
#[serde(rename = "productNmFr")]
|
||||||
|
pub product_name_french: String,
|
||||||
|
/// Shipping service type
|
||||||
|
#[serde(rename = "productNbr")]
|
||||||
|
pub product_number: String,
|
||||||
|
/// Is shipment carbon offset?
|
||||||
|
#[serde(rename = "carbonNeutral")]
|
||||||
|
pub carbon_neutral: bool,
|
||||||
|
/// Is shipment's last event?
|
||||||
|
#[serde(rename = "finalEvent")]
|
||||||
|
pub final_event: bool,
|
||||||
|
/// Is shipment delivered?
|
||||||
|
pub delivered: bool,
|
||||||
|
/// Shipment status
|
||||||
|
pub status: String,
|
||||||
|
/// Shipment received datetime
|
||||||
|
#[serde(rename = "shippedDateTime")]
|
||||||
|
pub shipped_datetime: super::DateTime,
|
||||||
|
/// Expected delivery datetime
|
||||||
|
#[serde(rename = "expectedDlvryDateTime")]
|
||||||
|
pub expected_delivery_datetime: super::ExpectedDate,
|
||||||
|
/// First delivery attempt day
|
||||||
|
#[serde(rename = "attemptedDlvryDate")]
|
||||||
|
pub attempted_delivery_date: String,
|
||||||
|
/// Successful delivery day
|
||||||
|
#[serde(rename = "actualDlvryDate")]
|
||||||
|
pub actual_delivery_date: String,
|
||||||
|
/// Originating address
|
||||||
|
#[serde(rename = "shipFromAddr")]
|
||||||
|
pub ship_from_address: super::StreetAddress,
|
||||||
|
/// Destination address
|
||||||
|
#[serde(rename = "shipToAddr")]
|
||||||
|
pub ship_to_address: super::StreetAddress,
|
||||||
|
/// Shipment events
|
||||||
|
pub events: Vec<super::Event>,
|
||||||
|
/// Shipment created by
|
||||||
|
#[serde(rename = "custNm")]
|
||||||
|
pub customer_name: String,
|
||||||
|
/// Origin city name
|
||||||
|
#[serde(rename = "addtnlOrigInfo")]
|
||||||
|
pub additional_origin_info: String,
|
||||||
|
/// Destination city name
|
||||||
|
#[serde(rename = "addtnlDestInfo")]
|
||||||
|
pub addition_destination_info: String,
|
||||||
|
/*
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "suppressSignature")]
|
||||||
|
pub suppress_signature: bool,
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "lagTime")]
|
||||||
|
pub lag_time: bool,
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "returnPinIndicator")]
|
||||||
|
pub return_pin_indicator: bool,
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "refundAllowed")]
|
||||||
|
pub refund_allowed: bool,
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "dtcBarcode")]
|
||||||
|
pub dtc_barcode: bool,*/
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "canadianDest")]
|
||||||
|
pub canadian_destination: bool,
|
||||||
|
/*/// ???
|
||||||
|
#[serde(rename = "correctedPostalCode")]
|
||||||
|
pub corrected_postal_code: String,
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "sigReqByAmtDue")]
|
||||||
|
pub signature_reuired_by_amount_due: bool,
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "shipperPostalCode")]
|
||||||
|
pub shipper_postal_code: String,
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "deliveryCertificateOption")]
|
||||||
|
pub delivery_certificate_option: String,
|
||||||
|
*/
|
||||||
|
}
|
18
shipper/odeli-canadapost/src/data/event.rs
Normal file
18
shipper/odeli-canadapost/src/data/event.rs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Event {
|
||||||
|
#[serde(rename = "cd")]
|
||||||
|
pub code: String,
|
||||||
|
#[serde(rename = "webCd")]
|
||||||
|
pub web_code: String,
|
||||||
|
pub datetime: super::DateTime,
|
||||||
|
#[serde(rename = "locationAddr")]
|
||||||
|
pub location: super::City,
|
||||||
|
#[serde(rename = "descEn")]
|
||||||
|
pub description_english: String,
|
||||||
|
#[serde(rename = "descFr")]
|
||||||
|
pub description_french: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub type_: String,
|
||||||
|
}
|
15
shipper/odeli-canadapost/src/data/mod.rs
Normal file
15
shipper/odeli-canadapost/src/data/mod.rs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
mod address;
|
||||||
|
pub use address::{City, StreetAddress};
|
||||||
|
mod datetime;
|
||||||
|
pub use datetime::{DateTime, ExpectedDate};
|
||||||
|
mod detail_response;
|
||||||
|
pub use detail_response::DetailResponse;
|
||||||
|
mod event;
|
||||||
|
pub use event::Event;
|
||||||
|
//mod package_response;
|
||||||
|
//pub use package_response::{PackageResponse, Package};
|
||||||
|
|
||||||
|
// NOTES
|
||||||
|
// All dates are YYYY-MM-DD
|
||||||
|
// All times are HH:MM:SS
|
||||||
|
// Delivery is always shortened to dlvry despite every other word keeping its vowels
|
71
shipper/odeli-canadapost/src/data/package_response.rs
Normal file
71
shipper/odeli-canadapost/src/data/package_response.rs
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
pub type PackageResponse = Vec<Package>;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Package {
|
||||||
|
/// Package tracking number
|
||||||
|
pub pin: String,
|
||||||
|
/// Product English display name
|
||||||
|
#[serde(rename = "productNmEn")]
|
||||||
|
pub product_name_english: String,
|
||||||
|
/// Product French display name
|
||||||
|
#[serde(rename = "productNmFr")]
|
||||||
|
pub product_name_french: String,
|
||||||
|
/// Photo confirmation indicator
|
||||||
|
#[serde(rename = "photoConfIndicator")]
|
||||||
|
pub photo_confirmation_indicator: bool,
|
||||||
|
/// Is shipment's last event?
|
||||||
|
#[serde(rename = "finalEvent")]
|
||||||
|
pub final_event: bool,
|
||||||
|
/// Is shipment delivered?
|
||||||
|
pub delivered: bool,
|
||||||
|
/// Shipment status
|
||||||
|
pub status: String,
|
||||||
|
/// Shipment received datetime
|
||||||
|
#[serde(rename = "shippedDateTime")]
|
||||||
|
pub shipped_datetime: super::DateTime,
|
||||||
|
/// Expected delivery datetime
|
||||||
|
#[serde(rename = "expectedDlvryDateTime")]
|
||||||
|
pub expected_delivery_datetime: super::ExpectedDate,
|
||||||
|
/// First delivery attempt day
|
||||||
|
#[serde(rename = "attemptedDlvryDate")]
|
||||||
|
pub attempted_delivery_date: String,
|
||||||
|
/// Successful delivery day
|
||||||
|
#[serde(rename = "actualDlvryDate")]
|
||||||
|
pub actual_delivery_date: String,
|
||||||
|
#[serde(rename = "shipToAddr")]
|
||||||
|
pub ship_to_address: super::StreetAddress,
|
||||||
|
/// Most recent shipment event
|
||||||
|
#[serde(rename = "latestEvent")]
|
||||||
|
pub latest_event: super::Event,
|
||||||
|
/// Shipment event codes
|
||||||
|
#[serde(rename = "eventCds")]
|
||||||
|
pub event_codes: Vec<String>,
|
||||||
|
/// Shipment created by
|
||||||
|
#[serde(rename = "custNm")]
|
||||||
|
pub customer_name: String,
|
||||||
|
/// Origin city name
|
||||||
|
#[serde(rename = "addtnlOrigInfo")]
|
||||||
|
pub additional_origin_info: String,
|
||||||
|
/// Destination city name
|
||||||
|
#[serde(rename = "addtnlDestInfo")]
|
||||||
|
pub addition_destination_info: String,
|
||||||
|
/*
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "suppressSignature")]
|
||||||
|
pub suppress_signature: bool,
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "lagTime")]
|
||||||
|
pub lag_time: bool,
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "canadianDest")]
|
||||||
|
pub canadian_destination: bool,
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "shipperPostalCode")]
|
||||||
|
pub shipper_postal_code: String,
|
||||||
|
/// ???
|
||||||
|
#[serde(rename = "recipientNm")]
|
||||||
|
pub recipient_name: String,
|
||||||
|
*/
|
||||||
|
}
|
56
shipper/odeli-canadapost/src/lib.rs
Normal file
56
shipper/odeli-canadapost/src/lib.rs
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
mod adapter;
|
||||||
|
pub use adapter::CanadaPost;
|
||||||
|
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 = "9007115243942454";
|
||||||
|
const TEST_INVALID_TRACKING_NUMBER: &str = "NOTATRACKINGNUMBER";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_works() {
|
||||||
|
println!("CanadaPost::new() -> {:?}", CanadaPost::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn track_valid() {
|
||||||
|
let adapter = CanadaPost::new();
|
||||||
|
println!(
|
||||||
|
"Trying to get tracking info for {}",
|
||||||
|
TEST_VALID_TRACKING_NUMBER
|
||||||
|
);
|
||||||
|
let info = adapter
|
||||||
|
.track(odeli_core::data::TrackingNumber::new(
|
||||||
|
TEST_VALID_TRACKING_NUMBER,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!(
|
||||||
|
"Got tracking info for {}: {:?}",
|
||||||
|
TEST_VALID_TRACKING_NUMBER, info
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn track_invalid() {
|
||||||
|
let adapter = CanadaPost::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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
20
shipper/odeli-canadapost/src/urls.rs
Normal file
20
shipper/odeli-canadapost/src/urls.rs
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
const SHIPMENT_PACKAGE_URL_START: &'static str =
|
||||||
|
"https://www.canadapost-postescanada.ca/track-reperage/rs/track/json/package?pins=";
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
pub fn build_shipment_package_url(id: &odeli_core::data::TrackingNumber) -> String {
|
||||||
|
format!("{}{}", SHIPMENT_PACKAGE_URL_START, id.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHIPMENT_DETAILS_URL_START: &'static str =
|
||||||
|
"https://www.canadapost-postescanada.ca/track-reperage/rs/track/json/package/";
|
||||||
|
const SHIPMENT_DETAILS_URL_END: &'static str = "/detail";
|
||||||
|
|
||||||
|
pub fn build_shipment_details_url(id: &odeli_core::data::TrackingNumber) -> String {
|
||||||
|
format!(
|
||||||
|
"{}{}{}",
|
||||||
|
SHIPMENT_DETAILS_URL_START,
|
||||||
|
id.as_str(),
|
||||||
|
SHIPMENT_DETAILS_URL_END
|
||||||
|
)
|
||||||
|
}
|
12
src/lib.rs
Normal file
12
src/lib.rs
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
pub use odeli_core as core;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_works() {
|
||||||
|
let result = add(2, 2);
|
||||||
|
assert_eq!(result, 4);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue