Create basic structure, add Canada Post support

This commit is contained in:
NGnius (Graham) 2024-03-03 10:20:12 -05:00
commit 287af3d63a
25 changed files with 2072 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

1233
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

12
Cargo.toml Normal file
View 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
View 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
View 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;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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

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

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

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