yuki_cli/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 {
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 pub fn soap_action(_service: &str, operation: &str) -> String {
90 format!("{YUKI_NS}{operation}")
91 }
92
93 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 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 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 pub fn parse_single_result(xml: &str, result_tag: &str) -> Result<String, YukiError> {
144 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 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}