1use std::time::Duration;
16use xyo_openapi_client::apis::configuration::Configuration;
17use xyo_openapi_client::models::{EnrichmentRequest as ApiEnrichmentRequest, EnrichTransactionsRequestInner};
18use serde::{Deserialize, Serialize};
19
20use crate::error::{extract_rate_limit_headers, ClientError, RateLimitError};
21
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct RequestOptions {
26 pub correlation_id: Option<String>,
28 pub traceparent: Option<String>,
30 pub api_user: Option<String>,
34}
35
36impl RequestOptions {
37 pub fn new() -> Self {
38 Self::default()
39 }
40
41 pub fn correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
42 self.correlation_id = Some(correlation_id.into());
43 self
44 }
45
46 pub fn traceparent(mut self, traceparent: impl Into<String>) -> Self {
47 self.traceparent = Some(traceparent.into());
48 self
49 }
50
51 pub fn api_user(mut self, api_user: impl Into<String>) -> Self {
52 self.api_user = Some(api_user.into());
53 self
54 }
55}
56
57fn deserialize_null_as_empty_string<'de, D>(deserializer: D) -> Result<String, D::Error>
60where
61 D: serde::Deserializer<'de>,
62{
63 let opt = Option::<String>::deserialize(deserializer)?;
64 Ok(opt.unwrap_or_default())
65}
66
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub struct EnrichmentResponse {
72 #[serde(default, deserialize_with = "deserialize_null_as_empty_string")]
73 pub merchant: String,
74 #[serde(default, deserialize_with = "deserialize_null_as_empty_string")]
75 pub description: String,
76 #[serde(default)]
77 pub categories: Vec<String>,
78 #[serde(default, deserialize_with = "deserialize_null_as_empty_string")]
79 pub logo: String,
80 #[serde(default, deserialize_with = "deserialize_null_as_empty_string")]
82 pub location: String,
83 #[serde(default, deserialize_with = "deserialize_null_as_empty_string")]
85 pub address: String,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct EnrichTransactionCollectionResponse {
91 pub id: String,
93 pub link: String,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub enum EnrichmentStatus {
100 Ready,
101 Pending,
102 Failed,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct EnrichmentRequest {
108 pub content: String,
110 pub country_code: String,
112}
113
114impl EnrichmentRequest {
115 pub fn new(content: impl Into<String>, country_code: impl Into<String>) -> Self {
117 Self {
118 content: content.into(),
119 country_code: country_code.into(),
120 }
121 }
122
123 pub fn validate(&self) -> Result<(), ClientError> {
125 let content = self.content.trim();
126 if content.is_empty() {
127 return Err(ClientError::new(0, "request content must not be empty"));
128 }
129 if content.chars().count() > 128 {
130 return Err(ClientError::new(
131 0,
132 "request content exceeds maximum length of 128 characters",
133 ));
134 }
135 let country = self.country_code.trim();
136 if country.is_empty() {
137 return Err(ClientError::new(
138 0,
139 "request country_code must not be empty",
140 ));
141 }
142 if country.chars().count() != 2 {
143 return Err(ClientError::new(
144 0,
145 "request country_code must be a 2-letter ISO 3166-1 alpha-2 code",
146 ));
147 }
148 Ok(())
149 }
150}
151
152pub const DEFAULT_MAX_TAR_ENTRIES: usize = 50_000;
155pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 10 * 1024 * 1024; pub const DEFAULT_MAX_ARCHIVE_BYTES: usize = 100 * 1024 * 1024; pub const DEFAULT_USER_AGENT: &str = "xyo-sdk-rust/2.1.0";
158
159#[derive(Debug, Clone)]
161pub struct DownloadSecurityPolicy {
162 pub allowed_hosts: Vec<String>,
164 pub allow_same_origin: bool,
166}
167
168impl Default for DownloadSecurityPolicy {
169 fn default() -> Self {
170 Self {
171 allowed_hosts: vec![
172 "api.xyo.financial".to_string(),
173 "download.xyo.financial".to_string(),
174 ],
175 allow_same_origin: true,
176 }
177 }
178}
179
180impl DownloadSecurityPolicy {
181 pub fn is_allowed(&self, target_host: &str, api_host: &str) -> bool {
183 let target_lower = target_host.to_ascii_lowercase();
184 if self.allow_same_origin && !api_host.is_empty() && target_lower.eq_ignore_ascii_case(api_host) {
185 return true;
186 }
187 for allowed in &self.allowed_hosts {
188 let allowed_lower = allowed.to_ascii_lowercase();
189 if target_lower == allowed_lower
190 || target_lower.ends_with(&format!(".{}", allowed_lower))
191 {
192 return true;
193 }
194 }
195 false
196 }
197}
198
199fn sanitize_entry_name(name: &str) -> String {
201 name.chars()
202 .map(|c| if c.is_control() { '_' } else { c })
203 .collect()
204}
205
206fn validate_header_value(val: Option<&str>) -> Result<(), ClientError> {
207 if let Some(v) = val {
208 if v.contains('\r') || v.contains('\n') {
209 return Err(ClientError::new(
210 0,
211 "header value contains invalid CRLF characters",
212 ));
213 }
214 }
215 Ok(())
216}
217
218pub const MAX_ERROR_BODY_BYTES: usize = 64 * 1024; async fn read_bounded_error_body(mut resp: reqwest::Response) -> String {
221 let mut buf = Vec::new();
222 while let Ok(Some(chunk)) = resp.chunk().await {
223 if buf.len() + chunk.len() >= MAX_ERROR_BODY_BYTES {
224 let remaining = MAX_ERROR_BODY_BYTES.saturating_sub(buf.len());
225 buf.extend_from_slice(&chunk[..remaining]);
226 break;
227 }
228 buf.extend_from_slice(&chunk);
229 }
230 String::from_utf8_lossy(&buf).to_string()
231}
232
233fn validate_api_user(api_user: Option<&str>) -> Result<(), ClientError> {
234 validate_header_value(api_user)
235}
236
237type TokenSupplier = std::sync::Arc<dyn Fn() -> String + Send + Sync>;
238
239pub struct ClientBuilder {
243 bearer_token: Option<String>,
244 token_supplier: Option<TokenSupplier>,
245 base_url: Option<String>,
246 user_agent: Option<String>,
247 timeout: Option<Duration>,
248 connect_timeout: Option<Duration>,
249 download_policy: DownloadSecurityPolicy,
250 custom_http_client: Option<reqwest::Client>,
251 correlation_id: Option<String>,
252 traceparent: Option<String>,
253}
254
255impl Default for ClientBuilder {
256 fn default() -> Self {
257 Self::new()
258 }
259}
260
261impl ClientBuilder {
262 pub fn new() -> Self {
264 Self {
265 bearer_token: None,
266 token_supplier: None,
267 base_url: None,
268 user_agent: Some(DEFAULT_USER_AGENT.to_string()),
269 timeout: Some(Duration::from_secs(30)),
270 connect_timeout: Some(Duration::from_secs(10)),
271 download_policy: DownloadSecurityPolicy::default(),
272 custom_http_client: None,
273 correlation_id: None,
274 traceparent: None,
275 }
276 }
277
278 pub fn token(mut self, token: impl Into<String>) -> Self {
280 self.bearer_token = Some(token.into());
281 self
282 }
283
284 pub fn token_supplier<F>(mut self, supplier: F) -> Self
286 where
287 F: Fn() -> String + Send + Sync + 'static,
288 {
289 self.token_supplier = Some(std::sync::Arc::new(supplier));
290 self
291 }
292
293 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
295 self.base_url = Some(base_url.into());
296 self
297 }
298
299 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
301 self.user_agent = Some(user_agent.into());
302 self
303 }
304
305 pub fn timeout(mut self, timeout: Duration) -> Self {
307 self.timeout = Some(timeout);
308 self
309 }
310
311 pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self {
313 self.connect_timeout = Some(connect_timeout);
314 self
315 }
316
317 pub fn allow_download_host(mut self, host: impl Into<String>) -> Self {
319 self.download_policy.allowed_hosts.push(host.into());
320 self
321 }
322
323 pub fn download_policy(mut self, policy: DownloadSecurityPolicy) -> Self {
325 self.download_policy = policy;
326 self
327 }
328
329 pub fn custom_http_client(mut self, client: reqwest::Client) -> Self {
331 self.custom_http_client = Some(client);
332 self
333 }
334
335 pub fn correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
337 self.correlation_id = Some(correlation_id.into());
338 self
339 }
340
341 pub fn traceparent(mut self, traceparent: impl Into<String>) -> Self {
343 self.traceparent = Some(traceparent.into());
344 self
345 }
346
347 pub fn build(self) -> Result<Client, ClientError> {
349 let http_client = if let Some(client) = self.custom_http_client {
350 client
351 } else {
352 let mut builder = reqwest::Client::builder();
353 if let Some(to) = self.timeout {
354 builder = builder.timeout(to);
355 }
356 if let Some(cto) = self.connect_timeout {
357 builder = builder.connect_timeout(cto);
358 }
359 builder.build().map_err(|e| ClientError::new(0, format!("Failed to build HTTP client: {}", e)))?
360 };
361
362 let mut configuration = Configuration::new();
363 configuration.client = http_client;
364 configuration.bearer_access_token = self.bearer_token;
365 configuration.user_agent = self.user_agent;
366
367 let effective_url = self
368 .base_url
369 .or_else(|| std::env::var("XYO_API_BASE_URL").ok())
370 .unwrap_or_else(|| "https://api.xyo.financial".to_string());
371
372 let parsed_url = url::Url::parse(&effective_url).map_err(|e| {
373 ClientError::new(0, format!("Invalid base URL {:?}: {}", effective_url, e))
374 })?;
375 let scheme = parsed_url.scheme();
376 if scheme != "http" && scheme != "https" {
377 return Err(ClientError::new(
378 0,
379 format!("Unsupported base URL scheme {:?} (only http and https are permitted)", scheme),
380 ));
381 }
382
383 configuration.base_path = effective_url.trim_end_matches('/').to_string();
384
385 Ok(Client {
386 configuration,
387 token_supplier: self.token_supplier,
388 download_policy: self.download_policy,
389 default_correlation_id: self.correlation_id,
390 default_traceparent: self.traceparent,
391 })
392 }
393}
394
395impl std::fmt::Debug for ClientBuilder {
396 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397 f.debug_struct("ClientBuilder")
398 .field("base_url", &self.base_url)
399 .field("bearer_token", &"[REDACTED]")
400 .field("user_agent", &self.user_agent)
401 .field("timeout", &self.timeout)
402 .field("connect_timeout", &self.connect_timeout)
403 .field("download_policy", &self.download_policy)
404 .field("correlation_id", &self.correlation_id)
405 .field("traceparent", &self.traceparent)
406 .finish()
407 }
408}
409
410#[derive(Clone)]
414pub struct Client {
415 configuration: Configuration,
416 token_supplier: Option<TokenSupplier>,
417 download_policy: DownloadSecurityPolicy,
418 default_correlation_id: Option<String>,
419 default_traceparent: Option<String>,
420}
421
422impl std::fmt::Debug for Client {
423 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424 f.debug_struct("Client")
425 .field("base_url", &self.configuration.base_path)
426 .field("bearer_token", &"[REDACTED]")
427 .field("user_agent", &self.configuration.user_agent)
428 .field("download_policy", &self.download_policy)
429 .finish()
430 }
431}
432
433impl Client {
434 pub fn builder() -> ClientBuilder {
436 ClientBuilder::new()
437 }
438
439 pub fn new(bearer_token: impl Into<String>, base_url: Option<String>) -> Result<Self, ClientError> {
444 let mut builder = Client::builder().token(bearer_token);
445 if let Some(url) = base_url {
446 builder = builder.base_url(url);
447 }
448 builder.build()
449 }
450
451 pub fn with_token_supplier<F>(supplier: F, base_url: Option<String>) -> Result<Self, ClientError>
453 where
454 F: Fn() -> String + Send + Sync + 'static,
455 {
456 let mut builder = Client::builder().token_supplier(supplier);
457 if let Some(url) = base_url {
458 builder = builder.base_url(url);
459 }
460 builder.build()
461 }
462
463 fn get_effective_config(&self) -> Configuration {
464 let mut config = self.configuration.clone();
465 if let Some(ref supplier) = self.token_supplier {
466 config.bearer_access_token = Some(supplier());
467 }
468 config
469 }
470
471 pub async fn enrich_transaction(
475 &self,
476 content: impl Into<String>,
477 country_code: impl Into<String>,
478 ) -> Result<EnrichmentResponse, ClientError> {
479 self.enrich_transaction_with_options(content, country_code, None).await
480 }
481
482 pub async fn enrich_transaction_with_options(
484 &self,
485 content: impl Into<String>,
486 country_code: impl Into<String>,
487 options: Option<&RequestOptions>,
488 ) -> Result<EnrichmentResponse, ClientError> {
489 let content_str = content.into();
490 let country_str = country_code.into();
491 let req = EnrichmentRequest::new(&content_str, &country_str);
492 req.validate()?;
493
494 if let Some(opts) = options {
495 if opts.api_user.is_some() {
496 return Err(ClientError::new(
497 0,
498 "`api_user` is only applicable to bulk operations",
499 ));
500 }
501 }
502
503 let corr_id = options
504 .and_then(|o| o.correlation_id.as_deref())
505 .or(self.default_correlation_id.as_deref());
506 let traceparent = options
507 .and_then(|o| o.traceparent.as_deref())
508 .or(self.default_traceparent.as_deref());
509
510 validate_header_value(corr_id)?;
511 validate_header_value(traceparent)?;
512
513 tracing::debug!(country = %country_str, ?corr_id, ?traceparent, "enrich_transaction executing");
514
515 let body = ApiEnrichmentRequest::new(content_str, country_str);
516 let config = self.get_effective_config();
517
518 let uri_str = format!("{}/v1/ai/finance/enrichment/transaction", config.base_path);
519 let mut req_builder = config.client.request(reqwest::Method::POST, &uri_str);
520
521 if let Some(ref user_agent) = config.user_agent {
522 req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent);
523 }
524 if let Some(param_value) = corr_id {
525 req_builder = req_builder.header("X-Correlation-ID", param_value);
526 }
527 if let Some(param_value) = traceparent {
528 req_builder = req_builder.header("traceparent", param_value);
529 }
530 if let Some(ref token) = config.bearer_access_token {
531 req_builder = req_builder.bearer_auth(token);
532 }
533 req_builder = req_builder.json(&body);
534
535 let resp = req_builder.send().await.map_err(|e| ClientError::new(
536 e.status().map(|s| s.as_u16()).unwrap_or(0),
537 e.to_string(),
538 ))?;
539
540 let status = resp.status();
541 if status.is_client_error() || status.is_server_error() {
542 let code = status.as_u16();
543 let rate_limit = extract_rate_limit_headers(resp.headers())
544 .or_else(|| if code == 429 { Some(RateLimitError::default()) } else { None });
545 let message = read_bounded_error_body(resp).await;
546 return Err(ClientError {
547 code,
548 message,
549 rate_limit,
550 });
551 }
552
553 resp.json::<EnrichmentResponse>().await.map_err(|e| ClientError::new(0, e.to_string()))
554 }
555
556 pub async fn enrich_transactions(
562 &self,
563 requests: impl IntoIterator<Item = EnrichmentRequest>,
564 api_user: Option<&str>,
565 ) -> Result<EnrichTransactionCollectionResponse, ClientError> {
566 let mut opts = RequestOptions::default();
567 if let Some(user) = api_user {
568 opts = opts.api_user(user);
569 }
570 self.enrich_transactions_with_options(requests, Some(&opts)).await
571 }
572
573 pub async fn enrich_transactions_with_options(
575 &self,
576 requests: impl IntoIterator<Item = EnrichmentRequest>,
577 options: Option<&RequestOptions>,
578 ) -> Result<EnrichTransactionCollectionResponse, ClientError> {
579 let api_user = options.and_then(|o| o.api_user.as_deref());
580 validate_api_user(api_user)?;
581
582 let corr_id = options
583 .and_then(|o| o.correlation_id.as_deref())
584 .or(self.default_correlation_id.as_deref());
585 let traceparent = options
586 .and_then(|o| o.traceparent.as_deref())
587 .or(self.default_traceparent.as_deref());
588
589 validate_header_value(corr_id)?;
590 validate_header_value(traceparent)?;
591
592 let iter = requests.into_iter();
593 let (lower, upper) = iter.size_hint();
594 let initial_capacity = upper.unwrap_or(lower).min(DEFAULT_MAX_TAR_ENTRIES);
595 let mut items = Vec::with_capacity(initial_capacity);
596
597 for (i, req) in iter.enumerate() {
598 if i >= DEFAULT_MAX_TAR_ENTRIES {
599 return Err(ClientError::new(
600 0,
601 format!(
602 "requests batch size exceeds maximum allowed limit of {} items",
603 DEFAULT_MAX_TAR_ENTRIES
604 ),
605 ));
606 }
607 req.validate().map_err(|e| ClientError::new(
608 0,
609 format!("request at index {} is invalid: {}", i, e.message),
610 ))?;
611 items.push(EnrichTransactionsRequestInner {
612 content: req.content,
613 country_code: req.country_code,
614 });
615 }
616
617 if items.is_empty() {
618 return Err(ClientError::new(0, "requests batch cannot be empty"));
619 }
620
621 tracing::debug!(batch_size = items.len(), user = ?api_user, ?corr_id, ?traceparent, "enrich_transactions batch submission");
622
623 let config = self.get_effective_config();
624
625 let uri_str = format!("{}/v1/ai/finance/enrichment/transactions", config.base_path);
626 let mut req_builder = config.client.request(reqwest::Method::POST, &uri_str);
627
628 if let Some(ref user_agent) = config.user_agent {
629 req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent);
630 }
631 if let Some(param_value) = api_user {
632 req_builder = req_builder.header("x-api-user", param_value);
633 }
634 if let Some(param_value) = corr_id {
635 req_builder = req_builder.header("X-Correlation-ID", param_value);
636 }
637 if let Some(param_value) = traceparent {
638 req_builder = req_builder.header("traceparent", param_value);
639 }
640 if let Some(ref token) = config.bearer_access_token {
641 req_builder = req_builder.bearer_auth(token);
642 }
643 req_builder = req_builder.json(&items);
644
645 let resp = req_builder.send().await.map_err(|e| ClientError::new(
646 e.status().map(|s| s.as_u16()).unwrap_or(0),
647 e.to_string(),
648 ))?;
649
650 let status = resp.status();
651 if status.is_client_error() || status.is_server_error() {
652 let code = status.as_u16();
653 let rate_limit = extract_rate_limit_headers(resp.headers())
654 .or_else(|| if code == 429 { Some(RateLimitError::default()) } else { None });
655 let message = read_bounded_error_body(resp).await;
656 return Err(ClientError {
657 code,
658 message,
659 rate_limit,
660 });
661 }
662
663 let resp_obj: xyo_openapi_client::models::EnrichTransactionCollectionResponse = resp.json().await.map_err(|e| ClientError::new(0, e.to_string()))?;
664
665 Ok(EnrichTransactionCollectionResponse {
666 id: resp_obj.id,
667 link: resp_obj.link,
668 })
669 }
670
671 pub async fn get_enrichment_status(
675 &self,
676 id: &str,
677 api_user: Option<&str>,
678 ) -> Result<EnrichmentStatus, ClientError> {
679 let mut opts = RequestOptions::default();
680 if let Some(user) = api_user {
681 opts = opts.api_user(user);
682 }
683 self.get_enrichment_status_with_options(id, Some(&opts)).await
684 }
685
686 pub async fn get_enrichment_status_with_options(
688 &self,
689 id: &str,
690 options: Option<&RequestOptions>,
691 ) -> Result<EnrichmentStatus, ClientError> {
692 let api_user = options.and_then(|o| o.api_user.as_deref());
693 validate_api_user(api_user)?;
694
695 let corr_id = options
696 .and_then(|o| o.correlation_id.as_deref())
697 .or(self.default_correlation_id.as_deref());
698 let traceparent = options
699 .and_then(|o| o.traceparent.as_deref())
700 .or(self.default_traceparent.as_deref());
701
702 validate_header_value(corr_id)?;
703 validate_header_value(traceparent)?;
704
705 tracing::debug!(job_id = %id, user = ?api_user, ?corr_id, ?traceparent, "get_enrichment_status polling");
706
707 let config = self.get_effective_config();
708
709 let uri_str = format!("{}/v1/ai/finance/enrichment/status/{}", config.base_path, xyo_openapi_client::apis::urlencode(id));
710 let mut req_builder = config.client.request(reqwest::Method::GET, &uri_str);
711
712 if let Some(ref user_agent) = config.user_agent {
713 req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent);
714 }
715 if let Some(param_value) = api_user {
716 req_builder = req_builder.header("x-api-user", param_value);
717 }
718 if let Some(param_value) = corr_id {
719 req_builder = req_builder.header("X-Correlation-ID", param_value);
720 }
721 if let Some(param_value) = traceparent {
722 req_builder = req_builder.header("traceparent", param_value);
723 }
724 if let Some(ref token) = config.bearer_access_token {
725 req_builder = req_builder.bearer_auth(token);
726 }
727
728 let resp = req_builder.send().await.map_err(|e| ClientError::new(
729 e.status().map(|s| s.as_u16()).unwrap_or(0),
730 e.to_string(),
731 ))?;
732
733 let status = resp.status();
734 if status.is_client_error() || status.is_server_error() {
735 let code = status.as_u16();
736 let rate_limit = extract_rate_limit_headers(resp.headers())
737 .or_else(|| if code == 429 { Some(RateLimitError::default()) } else { None });
738 let message = read_bounded_error_body(resp).await;
739 return Err(ClientError {
740 code,
741 message,
742 rate_limit,
743 });
744 }
745
746 let resp_obj: xyo_openapi_client::models::EnrichmentCollectionStatusResponse = resp.json().await.map_err(|e| ClientError::new(0, e.to_string()))?;
747
748 use xyo_openapi_client::models::enrichment_collection_status_response::Status;
749 Ok(match resp_obj.status {
750 Status::Ready => EnrichmentStatus::Ready,
751 Status::Pending => EnrichmentStatus::Pending,
752 Status::Failed => EnrichmentStatus::Failed,
753 })
754 }
755
756 pub async fn download_enrichment_collection(
764 &self,
765 download_url: &str,
766 ) -> Result<Vec<EnrichmentResponse>, ClientError> {
767 let trimmed_url = download_url.trim();
768 if trimmed_url.is_empty() {
769 return Err(ClientError::new(0, "download_url cannot be empty"));
770 }
771
772 let parsed_download_url = if let Ok(parsed) = url::Url::parse(trimmed_url) {
773 if parsed.scheme() == "http" || parsed.scheme() == "https" {
774 parsed
775 } else if !parsed.scheme().is_empty() && (trimmed_url.contains("://") || trimmed_url.starts_with("javascript:") || trimmed_url.starts_with("data:")) {
776 return Err(ClientError::new(
777 0,
778 format!("Unsupported URL scheme {:?} (only http and https are permitted)", parsed.scheme()),
779 ));
780 } else {
781 let base_clean = self.configuration.base_path.trim_end_matches('/');
782 let rel_clean = trimmed_url.trim_start_matches('/');
783 url::Url::parse(&format!("{}/{}", base_clean, rel_clean)).map_err(|e| ClientError::new(
784 0,
785 format!("Invalid download URL: {}", e),
786 ))?
787 }
788 } else {
789 let base_clean = self.configuration.base_path.trim_end_matches('/');
790 let rel_clean = trimmed_url.trim_start_matches('/');
791 url::Url::parse(&format!("{}/{}", base_clean, rel_clean)).map_err(|e| ClientError::new(
792 0,
793 format!("Invalid download URL: {}", e),
794 ))?
795 };
796
797 let scheme = parsed_download_url.scheme();
798 if scheme != "http" && scheme != "https" {
799 return Err(ClientError::new(
800 0,
801 format!("Unsupported URL scheme {:?} (only http and https are permitted)", scheme),
802 ));
803 }
804
805 let mut req_builder = self.configuration.client.get(parsed_download_url.as_str());
806
807 if let Some(ref user_agent) = self.configuration.user_agent {
808 req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent);
809 }
810
811 let mut attach_auth = false;
813 let down_host = parsed_download_url.host_str().unwrap_or("");
814 let base_url_parsed = url::Url::parse(&self.configuration.base_path).ok();
815 let api_host = base_url_parsed
816 .as_ref()
817 .and_then(|u| u.host_str())
818 .unwrap_or("");
819
820 if !self.download_policy.is_allowed(down_host, api_host) {
821 return Err(ClientError::new(
822 0,
823 format!("domain {:?} is not permitted for secure archive downloads", down_host),
824 ));
825 }
826
827 if let Some(ref base_parsed) = base_url_parsed {
828 if let Some(base_h) = base_parsed.host_str() {
829 let down_port = parsed_download_url.port_or_known_default();
830 let base_port = base_parsed.port_or_known_default();
831 if down_host.eq_ignore_ascii_case(base_h) && down_port == base_port {
832 attach_auth = true;
833 }
834 }
835 }
836
837 if attach_auth {
838 let current_token = self
839 .token_supplier
840 .as_ref()
841 .map(|s| s())
842 .or_else(|| self.configuration.bearer_access_token.clone());
843 if let Some(ref token) = current_token {
844 req_builder = req_builder.bearer_auth(token);
845 }
846 }
847
848 req_builder = req_builder.header(
849 reqwest::header::ACCEPT,
850 "application/gzip, application/x-tar, application/octet-stream;q=0.9, */*;q=0.8",
851 );
852
853 let resp = req_builder.send().await.map_err(|e| ClientError::new(
854 e.status().map(|s| s.as_u16()).unwrap_or(0),
855 e.to_string(),
856 ))?;
857
858 let status = resp.status();
859 if status.is_client_error() || status.is_server_error() {
860 let code = status.as_u16();
861 let rate_limit = extract_rate_limit_headers(resp.headers())
862 .or_else(|| if code == 429 { Some(RateLimitError::default()) } else { None });
863 let message = read_bounded_error_body(resp).await;
864 return Err(ClientError {
865 code,
866 message,
867 rate_limit,
868 });
869 }
870
871 let content_type = resp
873 .headers()
874 .get(reqwest::header::CONTENT_TYPE)
875 .and_then(|v| v.to_str().ok())
876 .map(|s| s.to_string());
877
878 if let Some(ref ct_str) = content_type {
879 let ct_lower = ct_str.to_lowercase();
880 if !ct_lower.contains("gzip")
881 && !ct_lower.contains("tar")
882 && !ct_lower.contains("octet-stream")
883 && !ct_lower.contains("binary")
884 {
885 return Err(ClientError::new(
886 status.as_u16(),
887 format!(
888 "Unexpected Content-Type {:?} received when expecting binary archive",
889 ct_str
890 ),
891 ));
892 }
893 }
894
895 if let Some(content_length) = resp.content_length() {
897 if content_length > DEFAULT_MAX_ARCHIVE_BYTES as u64 {
898 return Err(ClientError::new(
899 0,
900 format!(
901 "Content-Length ({} bytes) exceeds maximum limit of {} bytes",
902 content_length, DEFAULT_MAX_ARCHIVE_BYTES
903 ),
904 ));
905 }
906 }
907
908 let initial_capacity = resp
910 .content_length()
911 .and_then(|l| usize::try_from(l).ok())
912 .unwrap_or(0)
913 .min(DEFAULT_MAX_ARCHIVE_BYTES);
914 let mut buffer = Vec::with_capacity(initial_capacity);
915
916 let mut resp = resp;
917 while let Some(chunk) = resp.chunk().await.map_err(|e| ClientError::new(
918 e.status().map(|s| s.as_u16()).unwrap_or(0),
919 format!("Network stream error: {}", e),
920 ))? {
921 if buffer.len() + chunk.len() > DEFAULT_MAX_ARCHIVE_BYTES {
922 return Err(ClientError::new(
923 0,
924 format!(
925 "Compressed archive exceeded maximum allowed size of {} bytes",
926 DEFAULT_MAX_ARCHIVE_BYTES
927 ),
928 ));
929 }
930 buffer.extend_from_slice(&chunk);
931 }
932
933 let results = tokio::task::spawn_blocking(move || -> Result<Vec<EnrichmentResponse>, ClientError> {
935 let gz_decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(buffer));
936 let mut archive = tar::Archive::new(gz_decoder);
937
938 let entries = archive.entries().map_err(|e| ClientError::new(
939 0,
940 format!("Failed to read tar archive: {}", e),
941 ))?;
942
943 let mut results = Vec::new();
944 let mut entry_count: usize = 0;
945
946 for entry_res in entries {
947 entry_count += 1;
948 if entry_count > DEFAULT_MAX_TAR_ENTRIES {
949 return Err(ClientError::new(
950 0,
951 format!("Archive contains too many entries (exceeded limit of {})", DEFAULT_MAX_TAR_ENTRIES),
952 ));
953 }
954
955 let mut entry = entry_res.map_err(|e| ClientError::new(
956 0,
957 format!("Failed to read tar entry: {}", e),
958 ))?;
959
960 let entry_size = entry.header().size().unwrap_or(0);
961 if entry_size > DEFAULT_MAX_ENTRY_BYTES {
962 let name = entry.path().map(|p| p.display().to_string()).unwrap_or_default();
963 return Err(ClientError::new(
964 0,
965 format!("Entry {:?} size ({} bytes) exceeds limit of {} bytes", sanitize_entry_name(&name), entry_size, DEFAULT_MAX_ENTRY_BYTES),
966 ));
967 }
968
969 let is_file = entry.header().entry_type().is_file();
970 let path_buf = entry
971 .path()
972 .map_err(|e| ClientError::new(
973 0,
974 format!("Failed to read tar entry path: {}", e),
975 ))?
976 .into_owned();
977
978 let path_str = path_buf.to_string_lossy();
980 if path_str.contains("..") || path_str.starts_with('/') || path_str.starts_with('\\') {
981 continue;
982 }
983
984 if is_file {
985 if let Some(ext) = path_buf.extension() {
986 if ext == "json" {
987 let item: EnrichmentResponse = serde_json::from_reader(&mut entry).map_err(|e| ClientError::new(
988 0,
989 format!("Failed to parse JSON from {}: {}", sanitize_entry_name(&path_buf.display().to_string()), e),
990 ))?;
991 results.push(item);
992 }
993 }
994 }
995 }
996
997 Ok(results)
998 })
999 .await
1000 .map_err(|join_err| ClientError::new(
1001 0,
1002 format!("Decompression task failed: {}", join_err),
1003 ))??;
1004
1005 Ok(results)
1006 }
1007}
1008
1009#[allow(dead_code)]
1012fn map_error<T: std::fmt::Debug>(err: xyo_openapi_client::apis::Error<T>) -> ClientError {
1013 match err {
1014 xyo_openapi_client::apis::Error::ResponseError(rc) => {
1015 let code = rc.status.as_u16();
1016 let rate_limit = if code == 429 { Some(RateLimitError::default()) } else { None };
1017 ClientError {
1018 code,
1019 message: rc.content,
1020 rate_limit,
1021 }
1022 }
1023 xyo_openapi_client::apis::Error::Reqwest(e) => ClientError::new(
1024 e.status().map(|s| s.as_u16()).unwrap_or(0),
1025 e.to_string(),
1026 ),
1027 xyo_openapi_client::apis::Error::Serde(e) => ClientError::new(0, e.to_string()),
1028 xyo_openapi_client::apis::Error::Io(e) => ClientError::new(0, e.to_string()),
1029 }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034 use super::*;
1035 use xyo_openapi_client::apis::ResponseContent;
1036
1037 #[test]
1038 fn test_client_new_default_base_url() {
1039 let client = Client::new("my-token", None).expect("Client::new should succeed");
1040 assert_eq!(client.configuration.base_path, "https://api.xyo.financial");
1041 assert_eq!(
1042 client.configuration.bearer_access_token,
1043 Some("my-token".to_string())
1044 );
1045 }
1046
1047 #[test]
1048 fn test_client_new_custom_base_url() {
1049 let client = Client::new("my-token", Some("https://sandbox.api.xyo.financial".to_string()))
1050 .expect("Client::new with custom URL should succeed");
1051 assert_eq!(
1052 client.configuration.base_path,
1053 "https://sandbox.api.xyo.financial"
1054 );
1055 assert_eq!(
1056 client.configuration.bearer_access_token,
1057 Some("my-token".to_string())
1058 );
1059 }
1060
1061 #[test]
1062 fn test_client_new_with_string_and_str() {
1063 let token_str = "token-1";
1064 let token_string = "token-2".to_string();
1065
1066 let client1 = Client::new(token_str, None).expect("client1 should succeed");
1067 let client2 = Client::new(token_string, None).expect("client2 should succeed");
1068
1069 assert_eq!(
1070 client1.configuration.bearer_access_token,
1071 Some("token-1".to_string())
1072 );
1073 assert_eq!(
1074 client2.configuration.bearer_access_token,
1075 Some("token-2".to_string())
1076 );
1077 }
1078
1079 #[test]
1080 fn test_client_builder_customization() {
1081 let client = Client::builder()
1082 .token("custom-builder-token")
1083 .base_url("https://custom.api.xyo.financial")
1084 .user_agent("custom-app/1.0.0")
1085 .timeout(Duration::from_secs(45))
1086 .connect_timeout(Duration::from_secs(15))
1087 .allow_download_host("custom-cdn.internal")
1088 .build()
1089 .expect("builder should succeed");
1090
1091 assert_eq!(client.configuration.base_path, "https://custom.api.xyo.financial");
1092 assert_eq!(client.configuration.bearer_access_token, Some("custom-builder-token".to_string()));
1093 assert_eq!(client.configuration.user_agent, Some("custom-app/1.0.0".to_string()));
1094 assert!(client.download_policy.is_allowed("custom-cdn.internal", "custom.api.xyo.financial"));
1095 }
1096
1097 #[test]
1098 fn test_client_debug_token_redaction() {
1099 let client = Client::new("super-secret-key-123", None).expect("Client::new should succeed");
1100 let debug_str = format!("{:?}", client);
1101 assert!(!debug_str.contains("super-secret-key-123"));
1102 assert!(debug_str.contains("[REDACTED]"));
1103 }
1104
1105 #[test]
1106 fn test_enrichment_response_serde_with_nulls() {
1107 let json_str = r#"{
1108 "merchant": "Uber",
1109 "description": "Ridesharing service",
1110 "categories": ["Transportation", "Taxi"],
1111 "logo": null,
1112 "location": null,
1113 "address": null
1114 }"#;
1115
1116 let parsed: EnrichmentResponse = serde_json::from_str(json_str).unwrap();
1117 assert_eq!(parsed.merchant, "Uber");
1118 assert_eq!(parsed.description, "Ridesharing service");
1119 assert_eq!(parsed.categories, vec!["Transportation", "Taxi"]);
1120 assert_eq!(parsed.logo, "");
1121 assert_eq!(parsed.location, "");
1122 assert_eq!(parsed.address, "");
1123 }
1124
1125 #[test]
1126 fn test_enrich_transaction_collection_response_serde() {
1127 let json_str = r#"{
1128 "id": "work-item-12345",
1129 "link": "https://download.xyo.financial/file.tar.gz"
1130 }"#;
1131
1132 let parsed: EnrichTransactionCollectionResponse = serde_json::from_str(json_str).unwrap();
1133 assert_eq!(parsed.id, "work-item-12345");
1134 assert_eq!(parsed.link, "https://download.xyo.financial/file.tar.gz");
1135
1136 let serialized = serde_json::to_string(&parsed).unwrap();
1137 assert!(serialized.contains("work-item-12345"));
1138 }
1139
1140 #[test]
1141 fn test_enrichment_status_serde_and_variants() {
1142 let ready = EnrichmentStatus::Ready;
1143 let pending = EnrichmentStatus::Pending;
1144 let failed = EnrichmentStatus::Failed;
1145
1146 let json_ready = serde_json::to_string(&ready).unwrap();
1147 let json_pending = serde_json::to_string(&pending).unwrap();
1148 let json_failed = serde_json::to_string(&failed).unwrap();
1149
1150 assert_eq!(
1151 serde_json::from_str::<EnrichmentStatus>(&json_ready).unwrap(),
1152 EnrichmentStatus::Ready
1153 );
1154 assert_eq!(
1155 serde_json::from_str::<EnrichmentStatus>(&json_pending).unwrap(),
1156 EnrichmentStatus::Pending
1157 );
1158 assert_eq!(
1159 serde_json::from_str::<EnrichmentStatus>(&json_failed).unwrap(),
1160 EnrichmentStatus::Failed
1161 );
1162 }
1163
1164 #[test]
1165 fn test_enrichment_request_serde() {
1166 let req = EnrichmentRequest {
1167 content: "COSTA COFFEE".to_string(),
1168 country_code: "GB".to_string(),
1169 };
1170
1171 let json_str = serde_json::to_string(&req).unwrap();
1172 let parsed: EnrichmentRequest = serde_json::from_str(&json_str).unwrap();
1173 assert_eq!(parsed.content, "COSTA COFFEE");
1174 assert_eq!(parsed.country_code, "GB");
1175 }
1176
1177 #[test]
1178 fn test_map_error_response_error() {
1179 let err: xyo_openapi_client::apis::Error<()> =
1180 xyo_openapi_client::apis::Error::ResponseError(ResponseContent {
1181 status: reqwest::StatusCode::FORBIDDEN,
1182 content: "Forbidden action".to_string(),
1183 entity: None,
1184 });
1185
1186 let client_err = map_error(err);
1187 assert_eq!(client_err.code, 403);
1188 assert_eq!(client_err.message, "Forbidden action");
1189 }
1190
1191 #[test]
1192 fn test_map_error_serde() {
1193 let serde_err: serde_json::Error = serde_json::from_str::<i32>("not an integer").unwrap_err();
1194 let err: xyo_openapi_client::apis::Error<()> = xyo_openapi_client::apis::Error::Serde(serde_err);
1195
1196 let client_err = map_error(err);
1197 assert_eq!(client_err.code, 0);
1198 assert!(!client_err.message.is_empty());
1199 }
1200
1201 #[test]
1202 fn test_map_error_io() {
1203 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "connection reset");
1204 let err: xyo_openapi_client::apis::Error<()> = xyo_openapi_client::apis::Error::Io(io_err);
1205
1206 let client_err = map_error(err);
1207 assert_eq!(client_err.code, 0);
1208 assert!(client_err.message.contains("connection reset"));
1209 }
1210
1211 #[test]
1212 fn test_sanitize_entry_name() {
1213 let malicious = "test\r\nmalicious\x00entry\x1b[31m.json";
1214 let sanitized = sanitize_entry_name(malicious);
1215 assert_eq!(sanitized, "test__malicious_entry_[31m.json");
1216 }
1217
1218 #[test]
1219 fn test_client_builder_custom_base_url() {
1220 let client = Client::builder()
1221 .token("test-token")
1222 .base_url("https://env.api.xyo.financial")
1223 .build()
1224 .expect("builder with custom base_url should succeed");
1225 assert_eq!(client.configuration.base_path, "https://env.api.xyo.financial");
1226 }
1227
1228 #[test]
1229 fn test_enrichment_request_validate() {
1230 let valid_req = EnrichmentRequest {
1231 content: "COSTA COFFEE".to_string(),
1232 country_code: "GB".to_string(),
1233 };
1234 assert!(valid_req.validate().is_ok());
1235
1236 let empty_content = EnrichmentRequest {
1237 content: "".to_string(),
1238 country_code: "GB".to_string(),
1239 };
1240 assert_eq!(
1241 empty_content.validate().unwrap_err().message,
1242 "request content must not be empty"
1243 );
1244
1245 let long_content = EnrichmentRequest {
1246 content: "A".repeat(129),
1247 country_code: "GB".to_string(),
1248 };
1249 assert_eq!(
1250 long_content.validate().unwrap_err().message,
1251 "request content exceeds maximum length of 128 characters"
1252 );
1253
1254 let empty_country = EnrichmentRequest {
1255 content: "Valid".to_string(),
1256 country_code: "".to_string(),
1257 };
1258 assert_eq!(
1259 empty_country.validate().unwrap_err().message,
1260 "request country_code must not be empty"
1261 );
1262
1263 let invalid_country = EnrichmentRequest {
1264 content: "Valid".to_string(),
1265 country_code: "USA".to_string(),
1266 };
1267 assert_eq!(
1268 invalid_country.validate().unwrap_err().message,
1269 "request country_code must be a 2-letter ISO 3166-1 alpha-2 code"
1270 );
1271 }
1272
1273 #[test]
1274 fn test_validate_api_user_crlf_rejection() {
1275 assert!(validate_api_user(Some("valid-user-123")).is_ok());
1276 assert!(validate_api_user(None).is_ok());
1277
1278 let crlf1 = validate_api_user(Some("user\r\ninjected-header: val"));
1279 assert!(crlf1.is_err());
1280 assert!(crlf1.unwrap_err().message.contains("CRLF"));
1281
1282 let crlf2 = validate_api_user(Some("user\ninjected-header: val"));
1283 assert!(crlf2.is_err());
1284 }
1285
1286 #[tokio::test]
1287 async fn test_enrich_transaction_rejects_api_user() {
1288 let client = Client::new("test-token", Some("https://api.xyo.financial".to_string())).unwrap();
1289 let opts = RequestOptions::new().api_user("user-123");
1290 let err = client
1291 .enrich_transaction_with_options("COSTA", "GB", Some(&opts))
1292 .await
1293 .expect_err("api_user should be rejected for single transaction");
1294 assert_eq!(err.message, "`api_user` is only applicable to bulk operations");
1295 }
1296
1297 #[test]
1298 fn test_client_with_token_supplier() {
1299 let key_holder = std::sync::Arc::new(std::sync::Mutex::new("key-1".to_string()));
1300 let key_clone = key_holder.clone();
1301
1302 let client = Client::with_token_supplier(
1303 move || key_clone.lock().unwrap().clone(),
1304 Some("https://api.xyo.financial".to_string()),
1305 )
1306 .expect("Client::with_token_supplier should succeed");
1307
1308 let cfg1 = client.get_effective_config();
1309 assert_eq!(cfg1.bearer_access_token, Some("key-1".to_string()));
1310
1311 *key_holder.lock().unwrap() = "rotated-key-2".to_string();
1312 let cfg2 = client.get_effective_config();
1313 assert_eq!(cfg2.bearer_access_token, Some("rotated-key-2".to_string()));
1314 }
1315
1316 #[test]
1317 fn test_validate_header_value_crlf_rejection() {
1318 assert!(validate_header_value(Some("valid-header-val")).is_ok());
1319 assert!(validate_header_value(None).is_ok());
1320
1321 let crlf1 = validate_header_value(Some("val\r\ninjected-header: bad"));
1322 assert!(crlf1.is_err());
1323 assert_eq!(
1324 crlf1.unwrap_err().message,
1325 "header value contains invalid CRLF characters"
1326 );
1327
1328 let crlf2 = validate_header_value(Some("val\ninjected-header: bad"));
1329 assert!(crlf2.is_err());
1330 }
1331
1332 #[tokio::test]
1333 async fn test_enrich_transactions_lazy_iterator_limit() {
1334 let client = Client::new("test-token", Some("https://api.xyo.financial".to_string())).unwrap();
1335
1336 let infinite_requests = std::iter::repeat_with(|| EnrichmentRequest {
1337 content: "COSTA COFFEE".to_string(),
1338 country_code: "GB".to_string(),
1339 });
1340
1341 let err = client
1342 .enrich_transactions(infinite_requests, None)
1343 .await
1344 .expect_err("infinite requests iterator should terminate early with error");
1345
1346 assert!(err.message.contains("requests batch size exceeds maximum allowed limit"));
1347 }
1348
1349 #[test]
1350 fn test_client_builder_invalid_url_scheme() {
1351 let err = Client::builder()
1352 .token("test-token")
1353 .base_url("ftp://api.xyo.financial")
1354 .build()
1355 .expect_err("ftp scheme should be rejected");
1356 assert!(err.message.contains("Unsupported base URL scheme"));
1357 }
1358}