use quick_xml::Reader;
use quick_xml::events::Event;
use crate::error::YukiError;
use super::local_name;
use super::soap_client::{SoapClient, SoapEnvelope};
const BASE_URL: &str = "https://api.yukiworks.nl/ws/Contact.asmx";
#[derive(Debug, Clone)]
pub struct Contact {
pub id: String,
pub name: String,
pub contact_type: String,
pub country: String,
pub is_supplier: bool,
pub is_customer: bool,
}
pub struct ContactClient {
soap: SoapClient,
}
impl ContactClient {
pub fn new() -> Self {
Self {
soap: SoapClient::new(BASE_URL),
}
}
pub fn with_client(http: reqwest::Client) -> Self {
Self {
soap: SoapClient::with_client(BASE_URL, http),
}
}
fn require_session(&self) -> Result<&str, YukiError> {
self.soap.session_id().ok_or_else(|| {
YukiError::AuthFailed("not authenticated — call authenticate() first".to_string())
})
}
pub async fn authenticate(&mut self, api_key: &str) -> Result<String, YukiError> {
self.soap.authenticate(api_key).await
}
pub async fn search_contacts(&self, query: &str) -> Result<Vec<Contact>, YukiError> {
let session = self.require_session()?;
let envelope = SoapEnvelope::new("SearchContacts")
.session(session)
.param("searchQuery", query)
.build();
let body = self.soap.call("SearchContacts", envelope).await?;
parse_contacts(&body)
}
pub async fn get_suppliers_and_customers_page(
&self,
contact_type: &str,
page_number: u32,
) -> Result<Vec<Contact>, YukiError> {
let session = self.require_session()?;
let envelope = suppliers_envelope(session, contact_type, page_number);
let body = self.soap.call("GetSuppliersAndCustomers", envelope).await?;
parse_contacts(&body)
}
pub async fn get_suppliers_and_customers(
&self,
contact_type: &str,
) -> Result<Vec<Contact>, YukiError> {
let mut collected: Vec<Contact> = Vec::new();
let mut page = 1;
loop {
let batch = self
.get_suppliers_and_customers_page(contact_type, page)
.await?;
if batch.is_empty() {
break;
}
let received = batch.len();
collected.extend(batch);
if received < CONTACT_PAGE_SIZE {
break;
}
page += 1;
}
Ok(collected)
}
}
const CONTACT_PAGE_SIZE: usize = 100;
pub fn parse_contacts(xml: &str) -> Result<Vec<Contact>, YukiError> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut contacts = Vec::new();
let mut in_contact = false;
let mut current_field = String::new();
let mut contact = Contact {
id: String::new(),
name: String::new(),
contact_type: String::new(),
country: String::new(),
is_supplier: false,
is_customer: false,
};
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
let local = local_name(e.name().as_ref()).to_string();
match local.as_str() {
"Contact" => {
in_contact = true;
contact = Contact {
id: String::new(),
name: String::new(),
contact_type: String::new(),
country: String::new(),
is_supplier: false,
is_customer: false,
};
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"ID" {
contact.id = String::from_utf8_lossy(&attr.value).to_string();
}
}
}
"Type" | "Name" | "Country" | "IsSupplier" | "IsCustomer" if in_contact => {
current_field = local;
}
_ => {}
}
}
Ok(Event::Text(ref e)) if in_contact && !current_field.is_empty() => {
let text = e
.unescape()
.map_err(|e| YukiError::Xml(e.to_string()))?
.trim()
.to_string();
match current_field.as_str() {
"Type" => contact.contact_type = text,
"Name" => contact.name = text,
"Country" => contact.country = text,
"IsSupplier" => contact.is_supplier = text.eq_ignore_ascii_case("true"),
"IsCustomer" => contact.is_customer = text.eq_ignore_ascii_case("true"),
_ => {}
}
}
Ok(Event::End(ref e)) => {
let name = e.name();
let local = local_name(name.as_ref());
match local {
"Type" | "Name" | "Country" | "IsSupplier" | "IsCustomer" => {
current_field.clear();
}
"Contact" => {
if !contact.id.is_empty() {
contacts.push(contact.clone());
}
in_contact = false;
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(YukiError::Xml(e.to_string())),
_ => {}
}
buf.clear();
}
Ok(contacts)
}
impl Default for ContactClient {
fn default() -> Self {
Self::new()
}
}
pub(crate) fn suppliers_envelope(session: &str, contact_type: &str, page_number: u32) -> String {
SoapEnvelope::new("GetSuppliersAndCustomers")
.session(session)
.param("searchOption", "All")
.param("searchValue", "")
.param("sortOrder", "Name")
.param("active", "Both")
.param("pageNumber", &page_number.to_string())
.param("contactType", contact_type)
.build()
}
#[cfg(test)]
mod envelope_tests {
use super::suppliers_envelope;
#[test]
fn sends_the_requested_page_number() {
let xml = suppliers_envelope("sess", "Supplier", 3);
assert!(xml.contains("pageNumber"), "{xml}");
assert!(
xml.contains(">3<"),
"page number must reach the request: {xml}"
);
}
#[test]
fn sends_a_non_empty_contact_type() {
let xml = suppliers_envelope("sess", "Both", 1);
assert!(xml.contains("contactType"), "{xml}");
assert!(
!xml.contains("<yuki:contactType></yuki:contactType>"),
"{xml}"
);
assert!(!xml.contains("<yuki:contactType/>"), "{xml}");
}
}