Skip to main content

yuki_client/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    /// Create a transport with its own default HTTP client. Convenient for
72    /// short-lived consumers such as the CLI, where each invocation is fresh.
73    pub fn new(base_url: &str) -> Self {
74        Self::with_client(base_url, Client::new())
75    }
76
77    /// Create a transport over a caller-provided HTTP client. Long-running
78    /// consumers (e.g. a server) should pass a single shared, pooled client
79    /// rather than constructing one per request.
80    pub fn with_client(base_url: &str, http: Client) -> Self {
81        Self {
82            http,
83            base_url: base_url.to_string(),
84            session_id: None,
85        }
86    }
87
88    pub fn with_session(mut self, session_id: &str) -> Self {
89        self.session_id = Some(session_id.to_string());
90        self
91    }
92
93    pub fn session_id(&self) -> Option<&str> {
94        self.session_id.as_deref()
95    }
96
97    /// Build the SOAPAction header value for a given operation.
98    pub fn soap_action(_service: &str, operation: &str) -> String {
99        format!("{YUKI_NS}{operation}")
100    }
101
102    /// POST a SOAP envelope and return the raw response body.
103    pub async fn call(&self, operation: &str, envelope: String) -> Result<String, YukiError> {
104        let action = Self::soap_action("", operation);
105        let response = self
106            .http
107            .post(&self.base_url)
108            .header("Content-Type", "text/xml; charset=utf-8")
109            .header("SOAPAction", format!("\"{action}\""))
110            .body(envelope)
111            .send()
112            .await?;
113
114        let status = response.status();
115        let body = response.text().await?;
116
117        // Set YUKI_DEBUG_XML to dump raw SOAP responses to stderr for diagnosis.
118        if std::env::var_os("YUKI_DEBUG_XML").is_some() {
119            eprintln!(
120                "=== YUKI_DEBUG_XML {operation} HTTP {} ===\n{body}\n=== end {operation} ===",
121                status.as_u16()
122            );
123        }
124
125        if status == 401 || status == 403 {
126            return Err(YukiError::AuthFailed(format!("HTTP {status}")));
127        }
128        if status == 429 {
129            return Err(YukiError::RateLimited);
130        }
131        if !status.is_success() {
132            // SOAP faults are returned as HTTP 500 — try to parse them
133            if let Some(fault) = Self::parse_soap_fault(&body) {
134                return Err(fault);
135            }
136            return Err(YukiError::Http {
137                status: status.as_u16(),
138                body,
139            });
140        }
141
142        Ok(body)
143    }
144
145    /// Authenticate with an API key, storing the returned session ID.
146    pub async fn authenticate(&mut self, api_key: &str) -> Result<String, YukiError> {
147        let envelope = SoapEnvelope::new("Authenticate")
148            .param("accessKey", api_key)
149            .build();
150
151        let body = self.call("Authenticate", envelope).await?;
152        let session = Self::parse_single_result(&body, "AuthenticateResult")?;
153        self.session_id = Some(session.clone());
154        Ok(session)
155    }
156
157    /// Extract the text content of a single named element from a SOAP response.
158    ///
159    /// Returns an error if a SOAP fault is found or the element is missing.
160    pub fn parse_single_result(xml: &str, result_tag: &str) -> Result<String, YukiError> {
161        // Check for fault first.
162        if let Some(err) = Self::parse_soap_fault(xml) {
163            return Err(err);
164        }
165
166        let mut reader = Reader::from_str(xml);
167        reader.config_mut().trim_text(true);
168
169        let mut inside_target = false;
170        let mut buf = Vec::new();
171
172        loop {
173            match reader.read_event_into(&mut buf) {
174                Ok(Event::Start(ref e)) => {
175                    let name = e.name();
176                    let local = local_name(name.as_ref());
177                    if local == result_tag {
178                        inside_target = true;
179                    }
180                }
181                Ok(Event::Text(ref e)) if inside_target => {
182                    let text = e
183                        .unescape()
184                        .map_err(|e| YukiError::Xml(e.to_string()))?
185                        .trim()
186                        .to_string();
187                    if !text.is_empty() {
188                        return Ok(text);
189                    }
190                }
191                Ok(Event::End(ref e)) => {
192                    let name = e.name();
193                    let local = local_name(name.as_ref());
194                    if local == result_tag {
195                        inside_target = false;
196                    }
197                }
198                Ok(Event::Eof) => break,
199                Err(e) => return Err(YukiError::Xml(e.to_string())),
200                _ => {}
201            }
202            buf.clear();
203        }
204
205        Err(YukiError::Xml(format!(
206            "element '{result_tag}' not found in response"
207        )))
208    }
209
210    /// Detect a SOAP fault in the response and return it as a `YukiError`.
211    ///
212    /// Returns `None` if no fault is present.
213    pub fn parse_soap_fault(xml: &str) -> Option<YukiError> {
214        let mut reader = Reader::from_str(xml);
215        reader.config_mut().trim_text(true);
216
217        let mut in_fault = false;
218        let mut in_faultcode = false;
219        let mut in_faultstring = false;
220        let mut faultcode = String::new();
221        let mut faultstring = String::new();
222        let mut buf = Vec::new();
223
224        loop {
225            match reader.read_event_into(&mut buf) {
226                Ok(Event::Start(ref e)) => {
227                    let name = e.name();
228                    let local = local_name(name.as_ref());
229                    match local {
230                        "Fault" => in_fault = true,
231                        "faultcode" if in_fault => in_faultcode = true,
232                        "faultstring" if in_fault => in_faultstring = true,
233                        _ => {}
234                    }
235                }
236                Ok(Event::Text(ref e)) => {
237                    if in_faultcode {
238                        faultcode = e.unescape().unwrap_or_default().trim().to_string();
239                    } else if in_faultstring {
240                        faultstring = e.unescape().unwrap_or_default().trim().to_string();
241                    }
242                }
243                Ok(Event::End(ref e)) => {
244                    let name = e.name();
245                    let local = local_name(name.as_ref());
246                    match local {
247                        "faultcode" => in_faultcode = false,
248                        "faultstring" => in_faultstring = false,
249                        "Fault" => {
250                            if !faultstring.is_empty() {
251                                let msg_lower = faultstring.to_lowercase();
252                                if msg_lower.contains("invalid")
253                                    && (msg_lower.contains("key")
254                                        || msg_lower.contains("session")
255                                        || msg_lower.contains("auth"))
256                                {
257                                    return Some(YukiError::AuthFailed(faultstring));
258                                }
259                                return Some(YukiError::SoapFault {
260                                    code: faultcode,
261                                    message: faultstring,
262                                });
263                            }
264                            return None;
265                        }
266                        _ => {}
267                    }
268                }
269                Ok(Event::Eof) | Err(_) => break,
270                _ => {}
271            }
272            buf.clear();
273        }
274
275        None
276    }
277}