yuki_client/client/
contact.rs1use 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#[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
22pub 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 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 pub async fn authenticate(&mut self, api_key: &str) -> Result<String, YukiError> {
50 self.soap.authenticate(api_key).await
51 }
52
53 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 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 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 if received < CONTACT_PAGE_SIZE {
97 break;
98 }
99 page += 1;
100 }
101 Ok(collected)
102 }
103}
104
105const CONTACT_PAGE_SIZE: usize = 100;
107
108pub 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
203pub(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 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 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}