Skip to main content

yuki_cli/client/
soap_client.rs

1use quick_xml::Reader;
2use quick_xml::events::Event;
3use reqwest::Client;
4
5use crate::error::YukiError;
6
7use super::local_name;
8
9const YUKI_NS: &str = "http://www.theyukicompany.com/";
10const SOAP_NS: &str = "http://schemas.xmlsoap.org/soap/envelope/";
11
12/// Builder for SOAP XML request envelopes.
13pub struct SoapEnvelope {
14    operation: String,
15    session_id: Option<String>,
16    params: Vec<(String, String)>,
17}
18
19impl SoapEnvelope {
20    pub fn new(operation: &str) -> Self {
21        Self {
22            operation: operation.to_string(),
23            session_id: None,
24            params: Vec::new(),
25        }
26    }
27
28    pub fn session(mut self, session_id: &str) -> Self {
29        self.session_id = Some(session_id.to_string());
30        self
31    }
32
33    pub fn param(mut self, name: &str, value: &str) -> Self {
34        self.params.push((name.to_string(), value.to_string()));
35        self
36    }
37
38    pub fn build(self) -> String {
39        let mut body = String::new();
40
41        if let Some(sid) = &self.session_id {
42            body.push_str(&format!("      <yuki:sessionID>{sid}</yuki:sessionID>\n"));
43        }
44
45        for (name, value) in &self.params {
46            body.push_str(&format!("      <yuki:{name}>{value}</yuki:{name}>\n"));
47        }
48
49        format!(
50            r#"<?xml version="1.0" encoding="utf-8"?>
51<soap:Envelope xmlns:soap="{SOAP_NS}"
52               xmlns:yuki="{YUKI_NS}">
53  <soap:Body>
54    <yuki:{op}>
55{body}    </yuki:{op}>
56  </soap:Body>
57</soap:Envelope>"#,
58            op = self.operation,
59        )
60    }
61}
62
63/// HTTP transport client for the Yuki SOAP API.
64pub struct SoapClient {
65    http: Client,
66    pub(super) base_url: String,
67    pub(super) session_id: Option<String>,
68}
69
70impl SoapClient {
71    pub fn new(base_url: &str) -> Self {
72        Self {
73            http: Client::new(),
74            base_url: base_url.to_string(),
75            session_id: None,
76        }
77    }
78
79    pub fn with_session(mut self, session_id: &str) -> Self {
80        self.session_id = Some(session_id.to_string());
81        self
82    }
83
84    pub fn session_id(&self) -> Option<&str> {
85        self.session_id.as_deref()
86    }
87
88    /// Build the SOAPAction header value for a given operation.
89    pub fn soap_action(_service: &str, operation: &str) -> String {
90        format!("{YUKI_NS}{operation}")
91    }
92
93    /// POST a SOAP envelope and return the raw response body.
94    pub async fn call(&self, operation: &str, envelope: String) -> Result<String, YukiError> {
95        let action = Self::soap_action("", operation);
96        let response = self
97            .http
98            .post(&self.base_url)
99            .header("Content-Type", "text/xml; charset=utf-8")
100            .header("SOAPAction", format!("\"{action}\""))
101            .body(envelope)
102            .send()
103            .await?;
104
105        let status = response.status();
106        let body = response.text().await?;
107
108        if status == 401 || status == 403 {
109            return Err(YukiError::AuthFailed(format!("HTTP {status}")));
110        }
111        if status == 429 {
112            return Err(YukiError::RateLimited);
113        }
114        if !status.is_success() {
115            // SOAP faults are returned as HTTP 500 — try to parse them
116            if let Some(fault) = Self::parse_soap_fault(&body) {
117                return Err(fault);
118            }
119            return Err(YukiError::Http {
120                status: status.as_u16(),
121                body,
122            });
123        }
124
125        Ok(body)
126    }
127
128    /// Authenticate with an API key, storing the returned session ID.
129    pub async fn authenticate(&mut self, api_key: &str) -> Result<String, YukiError> {
130        let envelope = SoapEnvelope::new("Authenticate")
131            .param("accessKey", api_key)
132            .build();
133
134        let body = self.call("Authenticate", envelope).await?;
135        let session = Self::parse_single_result(&body, "AuthenticateResult")?;
136        self.session_id = Some(session.clone());
137        Ok(session)
138    }
139
140    /// Extract the text content of a single named element from a SOAP response.
141    ///
142    /// Returns an error if a SOAP fault is found or the element is missing.
143    pub fn parse_single_result(xml: &str, result_tag: &str) -> Result<String, YukiError> {
144        // Check for fault first.
145        if let Some(err) = Self::parse_soap_fault(xml) {
146            return Err(err);
147        }
148
149        let mut reader = Reader::from_str(xml);
150        reader.config_mut().trim_text(true);
151
152        let mut inside_target = false;
153        let mut buf = Vec::new();
154
155        loop {
156            match reader.read_event_into(&mut buf) {
157                Ok(Event::Start(ref e)) => {
158                    let name = e.name();
159                    let local = local_name(name.as_ref());
160                    if local == result_tag {
161                        inside_target = true;
162                    }
163                }
164                Ok(Event::Text(ref e)) if inside_target => {
165                    let text = e
166                        .unescape()
167                        .map_err(|e| YukiError::Xml(e.to_string()))?
168                        .trim()
169                        .to_string();
170                    if !text.is_empty() {
171                        return Ok(text);
172                    }
173                }
174                Ok(Event::End(ref e)) => {
175                    let name = e.name();
176                    let local = local_name(name.as_ref());
177                    if local == result_tag {
178                        inside_target = false;
179                    }
180                }
181                Ok(Event::Eof) => break,
182                Err(e) => return Err(YukiError::Xml(e.to_string())),
183                _ => {}
184            }
185            buf.clear();
186        }
187
188        Err(YukiError::Xml(format!(
189            "element '{result_tag}' not found in response"
190        )))
191    }
192
193    /// Detect a SOAP fault in the response and return it as a `YukiError`.
194    ///
195    /// Returns `None` if no fault is present.
196    pub fn parse_soap_fault(xml: &str) -> Option<YukiError> {
197        let mut reader = Reader::from_str(xml);
198        reader.config_mut().trim_text(true);
199
200        let mut in_fault = false;
201        let mut in_faultcode = false;
202        let mut in_faultstring = false;
203        let mut faultcode = String::new();
204        let mut faultstring = String::new();
205        let mut buf = Vec::new();
206
207        loop {
208            match reader.read_event_into(&mut buf) {
209                Ok(Event::Start(ref e)) => {
210                    let name = e.name();
211                    let local = local_name(name.as_ref());
212                    match local {
213                        "Fault" => in_fault = true,
214                        "faultcode" if in_fault => in_faultcode = true,
215                        "faultstring" if in_fault => in_faultstring = true,
216                        _ => {}
217                    }
218                }
219                Ok(Event::Text(ref e)) => {
220                    if in_faultcode {
221                        faultcode = e.unescape().unwrap_or_default().trim().to_string();
222                    } else if in_faultstring {
223                        faultstring = e.unescape().unwrap_or_default().trim().to_string();
224                    }
225                }
226                Ok(Event::End(ref e)) => {
227                    let name = e.name();
228                    let local = local_name(name.as_ref());
229                    match local {
230                        "faultcode" => in_faultcode = false,
231                        "faultstring" => in_faultstring = false,
232                        "Fault" => {
233                            if !faultstring.is_empty() {
234                                let msg_lower = faultstring.to_lowercase();
235                                if msg_lower.contains("invalid")
236                                    && (msg_lower.contains("key")
237                                        || msg_lower.contains("session")
238                                        || msg_lower.contains("auth"))
239                                {
240                                    return Some(YukiError::AuthFailed(faultstring));
241                                }
242                                return Some(YukiError::SoapFault {
243                                    code: faultcode,
244                                    message: faultstring,
245                                });
246                            }
247                            return None;
248                        }
249                        _ => {}
250                    }
251                }
252                Ok(Event::Eof) | Err(_) => break,
253                _ => {}
254            }
255            buf.clear();
256        }
257
258        None
259    }
260}