use crate::Result;
use crate::tracker::{Event, Package, PackageStatus, TimeWindow, Tracker};
use crate::{tracker::TrackerContext, utils::UtcTime};
use async_trait::async_trait;
use regex::Regex;
use serde::Deserialize;
use serde_json::Value;
pub struct DhlTracker;
#[async_trait]
impl Tracker for DhlTracker {
fn can_handle(&self, url: &str) -> bool {
url.contains("dhl")
}
async fn get_raw(&self, url: &str, ctx: &TrackerContext) -> Result<String> {
let barcode = get_barcode(url, ctx.recipient_postcode)?;
let url = get_url(barcode);
let response = reqwest::get(url)
.await?
.error_for_status()?;
let body = response.text().await?;
Ok(body)
}
fn parse(&self, text: String) -> Result<Package> {
let value: Value = serde_json::from_str(&text)?;
let data = get_first_package(value)?;
let package: DhlPackage = serde_json::from_value(data.clone())?;
Ok(Package {
barcode: package.barcode.clone(),
channel: "DHL".into(),
status: package.status(),
sender: package.sender(),
recipient: package.recipient(),
eta: package.eta(),
eta_window: package.eta_window()?,
delivered: package.delivered_at,
events: package.events(),
})
}
}
fn get_barcode(url: &str, default_postcode: Option<&str>) -> Result<String> {
get_dhl_barcode(url, default_postcode)
.or_else(|_| get_ecommerce_barcode(url, default_postcode))
}
fn get_dhl_barcode(
url: &str,
default_postcode: Option<&str>,
) -> Result<String> {
let rx = Regex::new(r".*dhl.com.*tracking-id=([A-Z0-9-].*)")?;
let barcode = rx
.captures(url)
.and_then(|caps| caps.get(1))
.ok_or(format!("Couldn't get barcode from {url}"))?
.as_str()
.to_owned();
let out = if let Some(postcode) = default_postcode {
format!("{barcode}%2B{postcode}")
} else {
barcode
};
Ok(out)
}
fn get_ecommerce_barcode(
url: &str,
default_postcode: Option<&str>,
) -> Result<String> {
let rx = Regex::new(
r".*dhlecommerce.*tracktrace/([A-Z0-9-]+)/?([A-Z0-9-]+)?\??.*",
)?;
let captures = rx
.captures(url)
.ok_or(format!("Couldn't match {url}"))?;
let barcode = captures
.get(1)
.map(|m| m.as_str())
.ok_or(format!("Couldn't get barcode from {url}"))?
.to_owned();
let postcode = captures
.get(2)
.map(|m| m.as_str())
.to_owned();
let out = if let Some(postcode) = postcode.or(default_postcode) {
format!("{barcode}%2B{postcode}")
} else {
barcode
};
Ok(out)
}
fn get_first_package(data: Value) -> Result<Value> {
let x = data
.as_array()
.and_then(|arr| arr.iter().next())
.ok_or("No packages!")?
.clone();
Ok(x)
}
fn get_url(barcode: String) -> String {
format!(
"https://api-gw.dhlparcel.nl/track-trace?key={barcode}&role=consumer-receiver"
)
}
#[derive(Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct DhlPackage {
barcode: String,
delivered_at: Option<UtcTime>,
planned_delivery_timeframe: Option<String>,
receiver: Option<Party>,
shipper: Option<Party>,
events: Vec<DhlEvent>,
transit_time: Option<TransitTime>,
destination: Option<Destination>,
}
fn get_neighbour_address(package: &DhlPackage) -> Option<String> {
let dest = package.clone().destination?;
let receiver = dest.r#type?;
if receiver != "NEIGHBOUR" {
return None;
}
let address = dest.address?;
let street = address.street?;
let number = address.house_number?;
Some(format!("{street} {number}"))
}
impl DhlPackage {
fn status(&self) -> PackageStatus {
if let Some(_) = &self.delivered_at {
if let Some(address) = get_neighbour_address(self) {
return PackageStatus::DeliveredToNeighbour { address };
}
return PackageStatus::Delivered;
}
PackageStatus::InTransit
}
fn events(&self) -> Vec<Event> {
self.events
.iter()
.map(|e| e.to_event())
.collect()
}
fn eta(&self) -> Option<UtcTime> {
self.transit_time
.as_ref()
.map(|t| t.expected_delivery_moment)
}
fn eta_window(&self) -> Result<Option<TimeWindow>> {
if let Some(s) = &self.planned_delivery_timeframe {
let window = parse_eta_window(s)?;
Ok(Some(window))
} else {
Ok(None)
}
}
fn sender(&self) -> Option<String> {
self.shipper
.as_ref()
.map(|s| s.name.clone())
}
fn recipient(&self) -> Option<String> {
self.receiver
.as_ref()
.map(|r| r.name.clone())
}
}
fn parse_eta_window(s: &str) -> Result<TimeWindow> {
let mut parts = s.split("/");
let (left, right) = parts
.next()
.zip(parts.next())
.ok_or(format!("Couldn't parse EtaWindow {s}"))?;
Ok(TimeWindow {
start: left.parse()?,
end: right.parse()?,
})
}
#[derive(Deserialize, Clone)]
struct Party {
name: String,
}
#[derive(Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct TransitTime {
expected_delivery_moment: UtcTime,
}
#[derive(Deserialize, Clone)]
struct DhlEvent {
timestamp: UtcTime,
category: String,
status: String,
}
impl DhlEvent {
fn to_event(&self) -> Event {
Event {
timestamp: self.timestamp,
text: format!("{}: {}", self.category, self.status),
}
}
}
#[derive(Deserialize, Clone)]
struct Destination {
address: Option<Address>,
r#type: Option<String>,
}
#[allow(unused)]
#[derive(Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct Address {
country_code: Option<String>,
postal_code: Option<String>,
street: Option<String>,
city: Option<String>,
house_number: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mocks;
fn utc(s: &str) -> UtcTime {
s.parse().unwrap()
}
#[test]
fn test_get_barcode() -> Result<()> {
for (url, barcode) in [
(
"https://my.dhlecommerce.nl/home/tracktrace/3SQLW0022110709/1234AB",
"3SQLW0022110709%2B1234AB",
),
(
"https://my.dhlecommerce.nl/home/tracktrace/3SQLW0022110709",
"3SQLW0022110709",
),
(
"https://www.dhl.com/nl-en/home/tracking/tracking-parcel.html?locale=true&submit=1&tracking-id=JVGL0614394500301769",
"JVGL0614394500301769",
),
] {
let result = get_barcode(url, None)?;
assert_eq!(result, barcode);
}
Ok(())
}
#[test]
fn test_deserialization_undelivered() -> Result<()> {
let mock = mocks::load_json("dhlecommerce_undelivered_with_postcode")?;
let data = get_first_package(mock)?;
let package: DhlPackage = serde_json::from_value(data)?;
assert_eq!(package.sender().unwrap(), "Sender Name");
assert_eq!(package.recipient().unwrap(), "Receiver Name");
assert_eq!(package.barcode, "JVGL06244768002038487552");
assert_eq!(package.eta().unwrap(), utc("2024-11-07T20:00:00Z"));
assert_eq!(
package.eta_window()?.unwrap().start,
utc("2024-11-08T13:40:00+01:00")
);
assert_eq!(
package.eta_window()?.unwrap().end,
utc("2024-11-08T15:40:00+01:00")
);
assert_eq!(package.delivered_at, None);
assert_eq!(package.events().len(), 5);
let event = &package
.events()
.into_iter()
.last()
.unwrap();
assert_eq!(event.timestamp, utc("2024-11-08T12:07:05Z"));
assert_eq!(event.text, "IN_DELIVERY: OUT_FOR_DELIVERY");
Ok(())
}
#[test]
fn test_delivered_neighbours() -> Result<()> {
for (mock_name, expected_address) in [
("dhlecommerce_delivered_neighbours.json", "Streetname 418"),
("dhlecommerce_delivered_neighbours2.json", "Streetname 416"),
("dhlecommerce_delivered_neighbours5.json", "Streetname 419"),
("dhlecommerce_delivered_neighbours6.json", "Streetname 419"),
] {
let mock = mocks::load_text(mock_name)?;
let package = DhlTracker.parse(mock)?;
assert_eq!(
package.status,
PackageStatus::DeliveredToNeighbour {
address: expected_address.into(),
},
"{mock_name} should give {expected_address}"
);
}
Ok(())
}
}