1#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
2
3use std::collections::HashMap;
4use std::future::Future;
5use std::sync::{Arc, RwLock};
6
7use anyhow::anyhow;
8use base64::prelude::*;
9use bytes::Bytes;
10use futures::lock::Mutex;
11use http::header::{
12 ACCEPT, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderValue, InvalidHeaderValue, USER_AGENT,
13};
14use http::{Method, Request, StatusCode};
15use routex_api::info::ConnectionInfo;
16#[cfg(feature = "uniffi")]
17use routex_api::info::CountryCode;
18use routex_api::{Authenticated, ConnectionId, Error as ServiceError, Service, ServiceId};
19#[cfg(feature = "error")]
20use routex_api::{
21 PaymentErrorCode, ProviderErrorCode, ServiceBlockedCode, TicketErrorCode,
22 UnsupportedProductReason,
23};
24use routex_settlement::KeySettlement;
25use serde::{Deserialize, Serialize};
26use url::Url;
27use uuid::Uuid;
28
29#[cfg(feature = "reqwest")]
30pub use reqwest;
31
32#[derive(thiserror::Error, Debug)]
33pub enum Error {
34 #[error(transparent)]
35 RequestError(anyhow::Error),
36 #[error("Service error")]
37 ServiceError(ServiceError),
38 #[error("Error response")]
39 ResponseError(Box<Response>),
40 #[error("Resource not found")]
41 NotFound,
42}
43
44#[derive(Clone, Debug)]
45pub struct Response {
46 pub url: Url,
47 pub status: StatusCode,
48 pub headers: HeaderMap,
49 pub body: Bytes,
50}
51
52pub fn json_decode<T: for<'de> Deserialize<'de>>(body: &[u8]) -> Result<T> {
53 serde_json::from_slice(body).map_err(|err| Error::RequestError(err.into()))
54}
55
56impl From<Response> for Error {
57 fn from(response: Response) -> Error {
58 match serde_json::from_slice::<ServiceError>(&response.body) {
59 Ok(payload) => Error::ServiceError(payload),
60 Err(_) => Error::ResponseError(Box::new(response)),
61 }
62 }
63}
64
65pub trait HttpClient {
66 fn execute(&self, req: Request<Vec<u8>>) -> impl Future<Output = Result<Response>>;
67}
68
69#[cfg(feature = "reqwest")]
70impl From<reqwest::Error> for Error {
71 fn from(err: reqwest::Error) -> Self {
72 Self::RequestError(anyhow!(err))
73 }
74}
75
76#[cfg(feature = "reqwest")]
77impl HttpClient for reqwest::Client {
78 async fn execute(&self, req: Request<Vec<u8>>) -> Result<Response> {
79 match self.execute(req.try_into()?).await {
80 Ok(r) => Ok(Response {
81 url: r.url().clone(),
82 status: r.status(),
83 headers: r.headers().clone(),
84 body: r.bytes().await?,
85 }),
86 Err(err) => Err(err.into()),
87 }
88 }
89}
90
91pub type Result<T> = std::result::Result<T, Error>;
92
93#[must_use]
94#[derive(Clone, Debug)]
95pub struct RoutexClientCore<C> {
96 url: Url,
97 user_agent: HeaderValue,
98 http_client: C,
99 keys: Arc<Mutex<HashMap<String, KeySettlement<sealed::RoutexKeySettlementEndpoint<C>>>>>,
100 redirect_uri: Option<HeaderValue>,
101 trace_id: Arc<RwLock<Option<Vec<u8>>>>,
102}
103
104mod sealed {
105 use http::HeaderValue;
106 use routex_settlement::KeySettlementCore;
107 use url::Url;
108 use uuid::Uuid;
109
110 use super::{Error, HttpClient, RequestBuilder};
111
112 #[derive(Debug)]
113 pub struct RoutexKeySettlementEndpoint<C> {
114 pub(super) url: Url,
115 pub(super) user_agent: HeaderValue,
116 pub(super) http_client: C,
117 }
118
119 impl<C> KeySettlementCore for RoutexKeySettlementEndpoint<C>
120 where
121 C: HttpClient,
122 {
123 type Data = Uuid;
124
125 async fn request(
126 &self,
127 public_key: [u8; 32],
128 ticket_id: &Self::Data,
129 ) -> anyhow::Result<routex_api::keys::Response> {
130 let request =
131 RequestBuilder::post(&self.url, &routex_api::keys::Request { public_key })
132 .build(ticket_id, self.user_agent.clone());
133
134 let response = self.http_client.execute(request).await?;
135
136 if response.status.is_client_error() || response.status.is_server_error() {
137 Err(Error::from(response).into())
138 } else {
139 Ok(serde_json::from_slice(&response.body)?)
140 }
141 }
142 }
143}
144
145#[derive(Debug)]
146pub struct RequestBuilder {
147 request: Request<Vec<u8>>,
148}
149
150impl RequestBuilder {
151 fn get(url: &Url) -> Self {
152 let mut request = Request::new(Vec::new());
153
154 *request.method_mut() = Method::GET;
155 *request.uri_mut() = url.as_str().try_into().expect("URL to URI should work");
156
157 Self { request }
158 }
159
160 fn post(url: &Url, json: &impl Serialize) -> Self {
161 let mut request =
162 Request::new(serde_json::to_vec(&json).expect("Serialization should work"));
163
164 *request.method_mut() = Method::POST;
165 *request.uri_mut() = url.as_str().try_into().expect("URL to URI should work");
166
167 request
168 .headers_mut()
169 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
170
171 Self { request }
172 }
173
174 fn build(mut self, ticket_id: &Uuid, user_agent: HeaderValue) -> Request<Vec<u8>> {
175 self.request.headers_mut().extend([
176 (
177 routex_api::headers::TICKET_ID.clone(),
178 HeaderValue::from_str(&ticket_id.to_string()).expect("ASCII"),
179 ),
180 (
181 ACCEPT,
182 HeaderValue::from_static(routex_api::CURRENT_MEDIA_TYPE),
183 ),
184 (USER_AGENT, user_agent),
185 ]);
186
187 self.request
188 }
189}
190
191#[must_use]
192pub struct SearchRequest<S, C>
193where
194 S: Service,
195{
196 inner: routex_api::info::Request,
197 ticket: Authenticated<routex_api::Ticket<S>>,
198 client: RoutexClientCore<C>,
199}
200
201impl<S, C> SearchRequest<S, C>
202where
203 S: Service,
204 C: HttpClient + Clone,
205{
206 pub fn iban_detection(mut self, enable: bool) -> Self {
210 self.inner.iban_detection = enable;
211 self
212 }
213
214 pub fn limit(mut self, limit: impl Into<Option<usize>>) -> Self {
216 self.inner.limit = limit.into();
217 self
218 }
219
220 pub fn details(mut self, details: impl IntoIterator<Item = routex_api::info::Details>) -> Self {
222 self.inner.details = details.into_iter().collect();
223 self
224 }
225
226 pub async fn send(self) -> Result<Vec<ConnectionInfo>> {
232 json_decode(
233 &self
234 .client
235 .execute(
236 &self.ticket,
237 self.client.post(
238 &routex_api::info::search_path().to_url(self.client.url()),
239 &self.inner,
240 ),
241 )
242 .await?,
243 )
244 }
245}
246
247pub const DEFAULT_URL: &str = "https://api.yaxi.tech/";
248
249impl<C> RoutexClientCore<C>
250where
251 C: HttpClient + Clone,
252{
253 pub fn for_distribution(distribution: &str, version: &str, url: Url, http_client: C) -> Self {
259 Self {
260 url,
261 user_agent: format!(
262 "RoutexClient/{} ({})",
263 version,
264 [distribution, std::env::consts::OS, std::env::consts::ARCH]
265 .into_iter()
266 .filter(|s| !s.is_empty())
267 .collect::<Vec<_>>()
268 .join("; "),
269 )
270 .try_into()
271 .expect("Invalid header value"),
272 http_client,
273 keys: Arc::default(),
274 redirect_uri: None,
275 trace_id: Arc::default(),
276 }
277 }
278
279 pub async fn settle_key<S: routex_api::Service>(
290 &self,
291 ticket: &Authenticated<routex_api::Ticket<S>>,
292 ) -> Result<()> {
293 let ticket_id = ticket.to_data().id;
294
295 self.with_key_settlement(ticket_id, async |keys| {
296 keys.settle(&ticket_id).await.map_err(Error::RequestError)
297 })
298 .await
299 }
300
301 pub async fn with_key_settlement<U>(
302 &self,
303 ticket_id: Uuid,
304 f: impl AsyncFnOnce(&mut KeySettlement<sealed::RoutexKeySettlementEndpoint<C>>) -> U,
305 ) -> U {
306 f(self
307 .keys
308 .lock()
309 .await
310 .entry(ticket_id.into())
311 .or_insert_with(|| {
312 KeySettlement::new(sealed::RoutexKeySettlementEndpoint {
313 url: routex_api::keys::settlement_path().to_url(&self.url),
314 user_agent: self.user_agent.clone(),
315 http_client: self.http_client.clone(),
316 })
317 }))
318 .await
319 }
320
321 pub async fn system_version(&self, ticket_id: Uuid) -> Option<String> {
322 self.with_key_settlement(ticket_id, async |keys| keys.system_version().cloned())
323 .await
324 .map(|v| serde_json::to_string(&v).expect("serialization should work"))
325 }
326
327 pub fn search<S: routex_api::Service>(
328 &self,
329 ticket: Authenticated<routex_api::Ticket<S>>,
330 filters: impl IntoIterator<Item = routex_api::info::SearchFilter>,
331 ) -> SearchRequest<S, C> {
332 SearchRequest {
333 inner: routex_api::info::Request::new(filters),
334 ticket,
335 client: self.clone(),
336 }
337 }
338
339 pub async fn info<S: routex_api::Service>(
340 &self,
341 ticket: &Authenticated<routex_api::Ticket<S>>,
342 connection_id: ConnectionId,
343 ) -> Result<ConnectionInfo> {
344 let response = self
345 .execute(
346 ticket,
347 self.get(
348 &routex_api::info::fetch_path(&connection_id.to_string()).to_url(self.url()),
349 ),
350 )
351 .await
352 .map_err(|err| {
353 if let Error::ResponseError(response) = &err
354 && response.status == StatusCode::NOT_FOUND
355 {
356 Error::NotFound
357 } else {
358 err
359 }
360 })?;
361
362 json_decode(&response)
363 }
364
365 pub async fn execute<S: routex_api::Service>(
366 &self,
367 ticket: &Authenticated<routex_api::Ticket<S>>,
368 request: RequestBuilder,
369 ) -> Result<Bytes> {
370 let ticket_id = ticket.to_data().id;
371
372 self.with_key_settlement(ticket_id, async |keys| {
373 let mut request = request.build(&ticket_id, self.user_agent.clone());
374
375 request.headers_mut().insert(
376 routex_api::headers::TICKET.clone(),
377 HeaderValue::from_str(
378 &BASE64_STANDARD.encode(
379 keys.seal(ticket.as_str().as_bytes(), &ticket_id)
380 .await
381 .map_err(Error::RequestError)?,
382 ),
383 )
384 .expect("ASCII"),
385 );
386
387 if let Some(value) = self.redirect_uri.clone() {
388 request
389 .headers_mut()
390 .insert(&routex_api::headers::REDIRECT_URI, value);
391 }
392
393 if !request.body().is_empty() {
394 *request.body_mut() = keys
395 .seal(request.body(), &ticket_id)
396 .await
397 .map_err(Error::RequestError)?;
398 }
399
400 request.headers_mut().insert(
401 &routex_api::headers::SESSION_ID,
402 keys.session_id(&ticket_id)
403 .await
404 .map_err(Error::RequestError)?
405 .clone(),
406 );
407
408 request.headers_mut().remove(&CONTENT_LENGTH);
409
410 let response = self.http_client.execute(request).await?;
411
412 *self.trace_id.write().expect("poisoned") = response
413 .headers
414 .get(&routex_api::headers::TRACE_ID)
415 .and_then(|v| v.to_str().ok())
416 .and_then(|v| BASE64_STANDARD.decode(v).ok())
417 .and_then(|v| keys.unseal(&v).ok());
418
419 if response.status.is_client_error() || response.status.is_server_error() {
420 Err(Response {
421 body: keys
422 .unseal(&response.body)
423 .map_or(response.body, Into::into),
424 ..response
425 }
426 .into())
427 } else {
428 Ok(if response.body.is_empty() {
429 response.body
430 } else {
431 keys.unseal(&response.body)
432 .map_err(|err| Error::RequestError(anyhow!(err)))?
433 .into()
434 })
435 }
436 })
437 .await
438 }
439
440 pub fn get(&self, url: &Url) -> RequestBuilder {
441 RequestBuilder::get(url)
442 }
443
444 pub fn post(&self, url: &Url, json: &impl Serialize) -> RequestBuilder {
445 RequestBuilder::post(url, json)
446 }
447
448 pub fn url(&self) -> &Url {
449 &self.url
450 }
451
452 pub fn trace_id(&self) -> Option<Vec<u8>> {
458 self.trace_id.read().expect("poisoned").clone()
459 }
460
461 pub fn set_redirect_uri(
462 &mut self,
463 redirect_uri: &str,
464 ) -> std::result::Result<(), InvalidHeaderValue> {
465 self.redirect_uri = Some(redirect_uri.try_into()?);
466 Ok(())
467 }
468}
469
470#[must_use]
471pub fn handle_not_found(err: Error) -> Error {
472 if let Error::ResponseError(response) = &err
473 && response.status == StatusCode::NOT_FOUND
474 {
475 Error::NotFound
476 } else {
477 err
478 }
479}
480
481#[derive(serde::Deserialize, Clone)]
483pub struct ServiceOnlyTicket {
484 pub service: ServiceId,
485}
486
487#[macro_export]
488macro_rules! with_any_service {
489 ($ticket:expr, $authenticated:ident, $block:block) => {
490 match $ticket.parse::<routex_api::Authenticated<routex_client_common::ServiceOnlyTicket>>()
491 {
492 Ok($authenticated) => match $authenticated.to_data().service {
493 routex_api::ServiceId::Accounts { .. } => {
494 with_any_service!(
495 $ticket,
496 $authenticated,
497 $block,
498 routex_api::accounts::Service
499 )
500 }
501 routex_api::ServiceId::CollectPayment { .. } => {
502 with_any_service!(
503 $ticket,
504 $authenticated,
505 $block,
506 routex_api::collect_payment::Service
507 )
508 }
509 routex_api::ServiceId::Balances { .. } => {
510 with_any_service!(
511 $ticket,
512 $authenticated,
513 $block,
514 routex_api::balances::Service
515 )
516 }
517 routex_api::ServiceId::Transactions { .. } => {
518 with_any_service!(
519 $ticket,
520 $authenticated,
521 $block,
522 routex_api::transactions::Service
523 )
524 }
525 routex_api::ServiceId::Transfer { .. } => {
526 with_any_service!(
527 $ticket,
528 $authenticated,
529 $block,
530 routex_api::transfer::Service
531 )
532 }
533 },
534 Err(err) => Err(err.into()),
535 }
536 };
537 ($ticket:expr, $authenticated:ident, $block:block, $service:ty) => {
538 match $ticket.parse::<routex_api::Authenticated<routex_api::Ticket<$service>>>() {
539 Ok($authenticated) => $block.map_err(Into::into),
540 Err(err) => Err(err.into()),
541 }
542 };
543}
544
545#[cfg(feature = "uniffi")]
546::uniffi::setup_scaffolding!();
547
548#[cfg(feature = "error")]
549#[allow(clippy::enum_variant_names)]
550#[derive(thiserror::Error, Debug)]
551#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
552pub enum RoutexClientError {
553 #[error("Invalid redirect URI")]
554 InvalidRedirectUri,
555
556 #[error("Request error")]
557 RequestError { error: String },
558
559 #[error("Unexpected service error")]
560 UnexpectedError { user_message: Option<String> },
561
562 #[error("Canceled")]
563 Canceled,
564
565 #[error("Invalid credentials")]
566 InvalidCredentials { user_message: Option<String> },
567
568 #[error("Service blocked")]
569 ServiceBlocked {
570 user_message: Option<String>,
571 code: Option<ServiceBlockedCode>,
572 },
573
574 #[error("Unauthorized")]
575 Unauthorized { user_message: Option<String> },
576
577 #[error("Consent expired")]
578 ConsentExpired { user_message: Option<String> },
579
580 #[error("Access exceeded")]
581 AccessExceeded { user_message: Option<String> },
582
583 #[error("Period out of bounds")]
584 PeriodOutOfBounds { user_message: Option<String> },
585
586 #[error("Unsupported product")]
587 UnsupportedProduct {
588 reason: Option<UnsupportedProductReason>,
589 user_message: Option<String>,
590 },
591
592 #[error("Payment canceled or rejected")]
593 PaymentFailed {
594 code: Option<PaymentErrorCode>,
595 user_message: Option<String>,
596 },
597
598 #[error("Unexpected value")]
599 UnexpectedValue { error: String },
600
601 #[error("{error}")]
602 TicketError {
603 error: String,
604 code: TicketErrorCode,
605 },
606
607 #[error("The account-servicing provider indicated a technical error")]
608 ProviderError {
609 code: Option<ProviderErrorCode>,
610 user_message: Option<String>,
611 },
612
613 #[error("Error response")]
614 ResponseError { response: String },
615
616 #[error("Resource not found")]
617 NotFound,
618
619 #[error("The transaction is not possible without user interaction")]
620 InterruptError,
621}
622
623#[cfg(feature = "error")]
624impl From<Error> for RoutexClientError {
625 fn from(error: Error) -> Self {
626 match error {
627 Error::RequestError(error) => Self::RequestError {
628 error: error.to_string(),
629 },
630 Error::ServiceError(ServiceError::UnexpectedError { user_message, .. }) => {
631 Self::UnexpectedError { user_message }
632 }
633 Error::ServiceError(ServiceError::Canceled { .. }) => Self::Canceled,
634 Error::ServiceError(ServiceError::InvalidCredentials { user_message, .. }) => {
635 Self::InvalidCredentials { user_message }
636 }
637 Error::ServiceError(ServiceError::ServiceBlocked {
638 user_message, code, ..
639 }) => Self::ServiceBlocked { user_message, code },
640 Error::ServiceError(ServiceError::Unauthorized { user_message, .. }) => {
641 Self::Unauthorized { user_message }
642 }
643 Error::ServiceError(ServiceError::ConsentExpired { user_message, .. }) => {
644 Self::ConsentExpired { user_message }
645 }
646 Error::ServiceError(ServiceError::AccessExceeded { user_message, .. }) => {
647 Self::AccessExceeded { user_message }
648 }
649 Error::ServiceError(ServiceError::PeriodOutOfBounds { user_message, .. }) => {
650 Self::PeriodOutOfBounds { user_message }
651 }
652 Error::ServiceError(ServiceError::UnsupportedProduct {
653 reason,
654 user_message,
655 ..
656 }) => Self::UnsupportedProduct {
657 reason,
658 user_message,
659 },
660 Error::ServiceError(ServiceError::PaymentFailed {
661 code, user_message, ..
662 }) => Self::PaymentFailed { code, user_message },
663 Error::ServiceError(ServiceError::UnexpectedValue { error, .. }) => {
664 Self::UnexpectedValue { error }
665 }
666 Error::ServiceError(ServiceError::TicketError { error, code, .. }) => {
667 Self::TicketError { error, code }
668 }
669 Error::ServiceError(ServiceError::ProviderError {
670 code, user_message, ..
671 }) => Self::ProviderError { code, user_message },
672 Error::ServiceError(ServiceError::InterruptError { .. }) => Self::InterruptError,
673 Error::ResponseError(response) => Self::ResponseError {
674 response: format!("{response:?}"),
675 },
676 Error::NotFound => Self::NotFound,
677 }
678 }
679}
680
681#[cfg(feature = "error")]
682impl From<jsonwebtoken::errors::Error> for RoutexClientError {
683 fn from(err: jsonwebtoken::errors::Error) -> Self {
684 RoutexClientError::TicketError {
685 error: err.to_string(),
686 code: routex_api::TicketErrorCode::Invalid,
687 }
688 }
689}
690
691#[cfg(feature = "uniffi")]
692pub struct Ticket(String);
693
694#[cfg(feature = "uniffi")]
695impl Ticket {
696 #[must_use]
697 pub fn as_str(&self) -> &str {
698 &self.0
699 }
700}
701
702#[cfg(feature = "uniffi")]
703uniffi::custom_newtype!(Ticket, String);
704
705#[cfg(feature = "uniffi")]
706uniffi::custom_type!(Url, String, {
707 remote,
708 try_lift: |val| Ok(val.parse()?),
709 lower: |obj| obj.into(),
710});
711
712#[cfg(feature = "uniffi")]
713uniffi::use_remote_type!(routex_api::CountryCode);
714
715#[cfg(feature = "uniffi")]
716#[derive(uniffi::Enum)]
720pub enum SearchFilter {
721 Types {
723 types: Vec<routex_api::info::ConnectionType>,
724 },
725 Countries { countries: Vec<CountryCode> },
727 Name { name: String },
729 Bic { bic: String },
731 BankCode { bank_code: String },
733 Term { term: String },
735}
736
737#[cfg(feature = "uniffi")]
738impl From<SearchFilter> for routex_api::info::SearchFilter {
739 fn from(filter: SearchFilter) -> Self {
740 match filter {
741 SearchFilter::Types { types } => routex_api::info::SearchFilter::Types(types),
742 SearchFilter::Countries { countries } => {
743 routex_api::info::SearchFilter::Countries(countries)
744 }
745 SearchFilter::Name { name } => routex_api::info::SearchFilter::Name(name),
746 SearchFilter::Bic { bic } => routex_api::info::SearchFilter::Bic(bic),
747 SearchFilter::BankCode { bank_code } => {
748 routex_api::info::SearchFilter::BankCode(bank_code)
749 }
750 SearchFilter::Term { term } => routex_api::info::SearchFilter::Term(term),
751 }
752 }
753}
754
755#[cfg(feature = "uniffi")]
756macro_rules! account_filter {
757 {
758 $($field:ident $type:ty)+
759 } => {
760 paste::paste! {
761 #[derive(uniffi::Enum)]
762 pub enum AccountFilter {
763 $(
764 [<$field Eq>] { value: $type },
765 [<$field NotEq>] { value: $type },
766 )+
767 All {
768 filters: Vec<AccountFilter>,
769 },
770 Any {
771 filters: Vec<AccountFilter>,
772 },
773 Supports { service: routex_api::SupportedService },
774 }
775
776 impl From<AccountFilter> for Option<routex_api::Filter<routex_api::accounts::AccountField>> {
777 fn from(filter: AccountFilter) -> Self {
778 match filter {
779 $(
780 AccountFilter::[<$field Eq>] { value } => Some(routex_api::accounts::AccountField::[<$field:snake:upper>].eq(value)),
781 AccountFilter::[<$field NotEq>] { value } => Some(routex_api::accounts::AccountField::[<$field:snake:upper>].not_eq(value)),
782 )+
783 AccountFilter::All { filters } => filters
784 .into_iter()
785 .map(Option::<routex_api::Filter<_>>::from)
786 .flatten()
787 .reduce(routex_api::Filter::and),
788 AccountFilter::Any { filters } => filters
789 .into_iter()
790 .map(Option::<routex_api::Filter<_>>::from)
791 .flatten()
792 .reduce(routex_api::Filter::or),
793 AccountFilter::Supports { service } => {
794 Some(routex_api::Account::supports(service))
795 },
796 }
797 }
798 }
799 }
800 }
801}
802
803#[cfg(feature = "uniffi")]
804account_filter! {
805 Iban Option<String>
806 Number Option<String>
807 Bic Option<String>
808 BankCode Option<String>
809 Currency String
810 Name Option<String>
811 DisplayName Option<String>
812 OwnerName Option<String>
813 ProductName Option<String>
814 Status Option<routex_api::AccountStatus>
815 Type Option<routex_api::AccountType>
816}