yuki_client/client/
soap_client.rs1use 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
12pub 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
63pub 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 {
74 Self::with_client(base_url, Client::new())
75 }
76
77 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 pub fn soap_action(_service: &str, operation: &str) -> String {
99 format!("{YUKI_NS}{operation}")
100 }
101
102 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 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 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 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 pub fn parse_single_result(xml: &str, result_tag: &str) -> Result<String, YukiError> {
161 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 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}