Skip to main content

yuki_client/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    /// Build over a caller-provided HTTP client, so a long-running consumer can
35    /// share a single pooled client across all service clients.
36    pub fn with_client(http: reqwest::Client) -> Self {
37        Self {
38            soap: SoapClient::with_client(BASE_URL, http),
39        }
40    }
41
42    fn require_session(&self) -> Result<&str, YukiError> {
43        self.soap.session_id().ok_or_else(|| {
44            YukiError::AuthFailed("not authenticated — call authenticate() first".to_string())
45        })
46    }
47
48    /// Authenticate with the Yuki API and store the session ID.
49    pub async fn authenticate(&mut self, api_key: &str) -> Result<String, YukiError> {
50        self.soap.authenticate(api_key).await
51    }
52
53    /// Search for contacts matching a query string.
54    pub async fn search_contacts(&self, query: &str) -> Result<Vec<Contact>, YukiError> {
55        let session = self.require_session()?;
56        let envelope = SoapEnvelope::new("SearchContacts")
57            .session(session)
58            .param("searchQuery", query)
59            .build();
60        let body = self.soap.call("SearchContacts", envelope).await?;
61        parse_contacts(&body)
62    }
63
64    /// Fetch one page of suppliers and customers. Pages are 1-based.
65    pub async fn get_suppliers_and_customers_page(
66        &self,
67        contact_type: &str,
68        page_number: u32,
69    ) -> Result<Vec<Contact>, YukiError> {
70        let session = self.require_session()?;
71        let envelope = suppliers_envelope(session, contact_type, page_number);
72        let body = self.soap.call("GetSuppliersAndCustomers", envelope).await?;
73        parse_contacts(&body)
74    }
75
76    /// Retrieve every supplier and customer of the given type, following pagination.
77    ///
78    /// The API returns a fixed-size page; without `pageNumber` only the first page is
79    /// ever returned, which silently truncates larger address books.
80    pub async fn get_suppliers_and_customers(
81        &self,
82        contact_type: &str,
83    ) -> Result<Vec<Contact>, YukiError> {
84        let mut collected: Vec<Contact> = Vec::new();
85        let mut page = 1;
86        loop {
87            let batch = self
88                .get_suppliers_and_customers_page(contact_type, page)
89                .await?;
90            if batch.is_empty() {
91                break;
92            }
93            let received = batch.len();
94            collected.extend(batch);
95            // A short page means the last page was reached.
96            if received < CONTACT_PAGE_SIZE {
97                break;
98            }
99            page += 1;
100        }
101        Ok(collected)
102    }
103}
104
105/// Records returned per `GetSuppliersAndCustomers` page, fixed by the API.
106const CONTACT_PAGE_SIZE: usize = 100;
107
108/// Parse a SearchContacts or GetSuppliersAndCustomers SOAP response into a list of contacts.
109///
110/// Each `<Contact ID="uuid">` element carries child elements for each field.
111/// The contact ID is an XML attribute; all other fields are child text nodes.
112pub fn parse_contacts(xml: &str) -> Result<Vec<Contact>, YukiError> {
113    let mut reader = Reader::from_str(xml);
114    reader.config_mut().trim_text(true);
115
116    let mut contacts = Vec::new();
117    let mut in_contact = false;
118    let mut current_field = String::new();
119    let mut contact = Contact {
120        id: String::new(),
121        name: String::new(),
122        contact_type: String::new(),
123        country: String::new(),
124        is_supplier: false,
125        is_customer: false,
126    };
127    let mut buf = Vec::new();
128
129    loop {
130        match reader.read_event_into(&mut buf) {
131            Ok(Event::Start(ref e)) => {
132                let local = local_name(e.name().as_ref()).to_string();
133                match local.as_str() {
134                    "Contact" => {
135                        in_contact = true;
136                        contact = Contact {
137                            id: String::new(),
138                            name: String::new(),
139                            contact_type: String::new(),
140                            country: String::new(),
141                            is_supplier: false,
142                            is_customer: false,
143                        };
144                        for attr in e.attributes().flatten() {
145                            if attr.key.as_ref() == b"ID" {
146                                contact.id = String::from_utf8_lossy(&attr.value).to_string();
147                            }
148                        }
149                    }
150                    "Type" | "Name" | "Country" | "IsSupplier" | "IsCustomer" if in_contact => {
151                        current_field = local;
152                    }
153                    _ => {}
154                }
155            }
156            Ok(Event::Text(ref e)) if in_contact && !current_field.is_empty() => {
157                let text = e
158                    .unescape()
159                    .map_err(|e| YukiError::Xml(e.to_string()))?
160                    .trim()
161                    .to_string();
162                match current_field.as_str() {
163                    "Type" => contact.contact_type = text,
164                    "Name" => contact.name = text,
165                    "Country" => contact.country = text,
166                    "IsSupplier" => contact.is_supplier = text.eq_ignore_ascii_case("true"),
167                    "IsCustomer" => contact.is_customer = text.eq_ignore_ascii_case("true"),
168                    _ => {}
169                }
170            }
171            Ok(Event::End(ref e)) => {
172                let name = e.name();
173                let local = local_name(name.as_ref());
174                match local {
175                    "Type" | "Name" | "Country" | "IsSupplier" | "IsCustomer" => {
176                        current_field.clear();
177                    }
178                    "Contact" => {
179                        if !contact.id.is_empty() {
180                            contacts.push(contact.clone());
181                        }
182                        in_contact = false;
183                    }
184                    _ => {}
185                }
186            }
187            Ok(Event::Eof) => break,
188            Err(e) => return Err(YukiError::Xml(e.to_string())),
189            _ => {}
190        }
191        buf.clear();
192    }
193
194    Ok(contacts)
195}
196
197impl Default for ContactClient {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203/// Build the `GetSuppliersAndCustomers` envelope for a single page.
204///
205/// Every element the schema declares is sent. Omitting `pageNumber` pins the request
206/// to the first page; omitting `contactType` sends an empty enum value and the whole
207/// request is rejected.
208pub(crate) fn suppliers_envelope(session: &str, contact_type: &str, page_number: u32) -> String {
209    SoapEnvelope::new("GetSuppliersAndCustomers")
210        .session(session)
211        .param("searchOption", "All")
212        .param("searchValue", "")
213        .param("sortOrder", "Name")
214        .param("active", "Both")
215        .param("pageNumber", &page_number.to_string())
216        .param("contactType", contact_type)
217        .build()
218}
219
220#[cfg(test)]
221mod envelope_tests {
222    use super::suppliers_envelope;
223
224    #[test]
225    fn sends_the_requested_page_number() {
226        // Regression: pageNumber was never sent, so only the first 100 contacts
227        // were ever returned and larger address books were silently truncated.
228        let xml = suppliers_envelope("sess", "Supplier", 3);
229        assert!(xml.contains("pageNumber"), "{xml}");
230        assert!(
231            xml.contains(">3<"),
232            "page number must reach the request: {xml}"
233        );
234    }
235
236    #[test]
237    fn sends_a_non_empty_contact_type() {
238        // Regression: an empty ContactType is not a member of Yuki's enum and the
239        // API rejects the entire request with a schema validation fault.
240        let xml = suppliers_envelope("sess", "Both", 1);
241        assert!(xml.contains("contactType"), "{xml}");
242        assert!(
243            !xml.contains("<yuki:contactType></yuki:contactType>"),
244            "{xml}"
245        );
246        assert!(!xml.contains("<yuki:contactType/>"), "{xml}");
247    }
248}