Skip to main content

yuki_cli/client/
contact.rs

1use quick_xml::Reader;
2use quick_xml::events::Event;
3
4use crate::error::YukiError;
5
6use super::local_name;
7use super::soap_client::{SoapClient, SoapEnvelope};
8
9const BASE_URL: &str = "https://api.yukiworks.nl/ws/Contact.asmx";
10
11/// A Yuki contact (customer or supplier).
12#[derive(Debug, Clone)]
13pub struct Contact {
14    pub id: String,
15    pub name: String,
16    pub contact_type: String,
17    pub country: String,
18    pub is_supplier: bool,
19    pub is_customer: bool,
20}
21
22/// Client for the Yuki Contact SOAP service.
23pub struct ContactClient {
24    soap: SoapClient,
25}
26
27impl ContactClient {
28    pub fn new() -> Self {
29        Self {
30            soap: SoapClient::new(BASE_URL),
31        }
32    }
33
34    fn require_session(&self) -> Result<&str, YukiError> {
35        self.soap.session_id().ok_or_else(|| {
36            YukiError::AuthFailed("not authenticated — call authenticate() first".to_string())
37        })
38    }
39
40    /// Authenticate with the Yuki API and store the session ID.
41    pub async fn authenticate(&mut self, api_key: &str) -> Result<String, YukiError> {
42        self.soap.authenticate(api_key).await
43    }
44
45    /// Search for contacts matching a query string.
46    pub async fn search_contacts(&self, query: &str) -> Result<Vec<Contact>, YukiError> {
47        let session = self.require_session()?;
48        let envelope = SoapEnvelope::new("SearchContacts")
49            .session(session)
50            .param("searchQuery", query)
51            .build();
52        let body = self.soap.call("SearchContacts", envelope).await?;
53        parse_contacts(&body)
54    }
55
56    /// Retrieve suppliers and customers filtered by contact type.
57    pub async fn get_suppliers_and_customers(
58        &self,
59        contact_type: &str,
60    ) -> Result<Vec<Contact>, YukiError> {
61        let session = self.require_session()?;
62        let envelope = SoapEnvelope::new("GetSuppliersAndCustomers")
63            .session(session)
64            .param("contactType", contact_type)
65            .build();
66        let body = self.soap.call("GetSuppliersAndCustomers", envelope).await?;
67        parse_contacts(&body)
68    }
69}
70
71/// Parse a SearchContacts or GetSuppliersAndCustomers SOAP response into a list of contacts.
72///
73/// Each `<Contact ID="uuid">` element carries child elements for each field.
74/// The contact ID is an XML attribute; all other fields are child text nodes.
75pub fn parse_contacts(xml: &str) -> Result<Vec<Contact>, YukiError> {
76    let mut reader = Reader::from_str(xml);
77    reader.config_mut().trim_text(true);
78
79    let mut contacts = Vec::new();
80    let mut in_contact = false;
81    let mut current_field = String::new();
82    let mut contact = Contact {
83        id: String::new(),
84        name: String::new(),
85        contact_type: String::new(),
86        country: String::new(),
87        is_supplier: false,
88        is_customer: false,
89    };
90    let mut buf = Vec::new();
91
92    loop {
93        match reader.read_event_into(&mut buf) {
94            Ok(Event::Start(ref e)) => {
95                let local = local_name(e.name().as_ref()).to_string();
96                match local.as_str() {
97                    "Contact" => {
98                        in_contact = true;
99                        contact = Contact {
100                            id: String::new(),
101                            name: String::new(),
102                            contact_type: String::new(),
103                            country: String::new(),
104                            is_supplier: false,
105                            is_customer: false,
106                        };
107                        for attr in e.attributes().flatten() {
108                            if attr.key.as_ref() == b"ID" {
109                                contact.id = String::from_utf8_lossy(&attr.value).to_string();
110                            }
111                        }
112                    }
113                    "Type" | "Name" | "Country" | "IsSupplier" | "IsCustomer" if in_contact => {
114                        current_field = local;
115                    }
116                    _ => {}
117                }
118            }
119            Ok(Event::Text(ref e)) if in_contact && !current_field.is_empty() => {
120                let text = e
121                    .unescape()
122                    .map_err(|e| YukiError::Xml(e.to_string()))?
123                    .trim()
124                    .to_string();
125                match current_field.as_str() {
126                    "Type" => contact.contact_type = text,
127                    "Name" => contact.name = text,
128                    "Country" => contact.country = text,
129                    "IsSupplier" => contact.is_supplier = text.eq_ignore_ascii_case("true"),
130                    "IsCustomer" => contact.is_customer = text.eq_ignore_ascii_case("true"),
131                    _ => {}
132                }
133            }
134            Ok(Event::End(ref e)) => {
135                let name = e.name();
136                let local = local_name(name.as_ref());
137                match local {
138                    "Type" | "Name" | "Country" | "IsSupplier" | "IsCustomer" => {
139                        current_field.clear();
140                    }
141                    "Contact" => {
142                        if !contact.id.is_empty() {
143                            contacts.push(contact.clone());
144                        }
145                        in_contact = false;
146                    }
147                    _ => {}
148                }
149            }
150            Ok(Event::Eof) => break,
151            Err(e) => return Err(YukiError::Xml(e.to_string())),
152            _ => {}
153        }
154        buf.clear();
155    }
156
157    Ok(contacts)
158}
159
160impl Default for ContactClient {
161    fn default() -> Self {
162        Self::new()
163    }
164}