Skip to main content

routex_client/
lib.rs

1use crate::prelude::*;
2use anyhow::anyhow;
3use base64::prelude::*;
4use http::header::InvalidHeaderValue;
5use paste::paste;
6use routex_api::collect_payment::DebtorAccountReference;
7use routex_api::info::{ConnectionInfo, SearchFilter};
8use routex_api::{
9    AccountReference, ConfirmationContext, ConfirmationData, ConnectionId, InputContext,
10    ResponseData, ServiceRequest,
11};
12pub use routex_api::{Error as ServiceError, ServiceId};
13use routex_client_common::{
14    DEFAULT_URL, HttpClient, RoutexClientCore, handle_not_found, json_decode,
15};
16pub use routex_client_common::{Error, Response, Result, SearchRequest};
17use url::Url;
18use uuid::Uuid;
19
20pub mod prelude {
21    pub use super::RoutexClient;
22    pub use routex_api::accounts::{AccountField, Service as AccountsService};
23    pub use routex_api::balances::{
24        Balance, BalanceType, Balances, Decimal, Service as BalancesService,
25    };
26    pub use routex_api::collect_payment::{PaymentInitiation, Service as CollectPaymentService};
27    pub use routex_api::info::{
28        ConnectionInfo, ConnectionType, CountryCode, Details, SearchFilter,
29    };
30    pub use routex_api::transactions::Service as TransactionsService;
31    pub use routex_api::transfer::{
32        ChargeBearer, CreditorAddress, ISODateTimeOrDate, PaymentProduct,
33        Service as TransferService, TransferDetails,
34    };
35    pub use routex_api::{
36        Account, AccountIdentifier, AccountReference, AccountStatus, AccountType, Amount,
37        Authenticated, ConfirmationContext, ConnectionData, ConnectionId, Credentials,
38        CredentialsModel, Dialog, DialogContext, DialogInput, DialogOption, Filter, Image,
39        InputContext, InputType, OBResponse, OBResult, PaymentErrorCode, ProviderErrorCode,
40        Redirect, RedirectHandle, SecrecyLevel, Service, ServiceBlockedCode, Session,
41        SupportedService, Ticket, TicketErrorCode, UnsupportedProductReason,
42    };
43    pub use url::Url;
44}
45
46#[must_use]
47#[derive(Clone, Debug)]
48pub struct RoutexClient<T> {
49    core: RoutexClientCore<T>,
50    recurring_consents: bool,
51}
52
53#[cfg(feature = "reqwest")]
54impl Default for RoutexClient<routex_client_common::reqwest::Client> {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60#[cfg(feature = "reqwest")]
61impl RoutexClient<routex_client_common::reqwest::Client> {
62    #[allow(clippy::needless_pass_by_value, clippy::missing_panics_doc)]
63    /// Create a new client
64    pub fn new() -> Self {
65        Self::with_url(DEFAULT_URL.parse().expect("valid URL"))
66    }
67
68    /// Create a new client with a custom URL
69    pub fn with_url(url: Url) -> Self {
70        Self::with_url_and_client(url, routex_client_common::reqwest::Client::new())
71    }
72
73    /// Create a new client from environment variables
74    ///
75    /// Expected variables:
76    ///
77    /// * `ROUTEX_URL`
78    ///
79    /// # Panics
80    ///
81    /// Panics if one of the variables is not set or if `ROUTEX_URL` is not a valid URL.
82    pub fn from_env() -> Self {
83        Self::with_url(
84            std::env::var("ROUTEX_URL")
85                .expect("ROUTEX_URL not set")
86                .parse()
87                .expect("Proxy URL should be valid"),
88        )
89    }
90}
91
92impl<T> RoutexClient<T>
93where
94    T: HttpClient + Clone,
95{
96    #[allow(clippy::missing_panics_doc)]
97    /// Create a new client
98    pub fn with_client(http_client: T) -> Self {
99        Self::with_url_and_client(DEFAULT_URL.parse().expect("valid URL"), http_client)
100    }
101
102    /// Create a new client with a custom URL
103    pub fn with_url_and_client(url: Url, http_client: T) -> Self {
104        Self::for_distribution("Rust", env!("CARGO_PKG_VERSION"), url, http_client)
105    }
106
107    /// Create a new client for a versioned distribution and with a custom URL
108    pub fn for_distribution(distribution: &str, version: &str, url: Url, http_client: T) -> Self {
109        Self {
110            core: RoutexClientCore::for_distribution(distribution, version, url, http_client),
111            recurring_consents: false,
112        }
113    }
114
115    /// Run a key settlement with the routex service, verifying the attestation report.
116    ///
117    /// Stores a new random secret key and announces its public key to routex.
118    /// In return, routex responds with its own public key and an attestation report.
119    /// The attestation report allows verifying that routex created its corresponding secret key within the TEE
120    /// and that its creation happened in response to our client public key (freshness).
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if the key settlement request fails or the attestation report fails verification.
125    pub async fn settle_key<S: routex_api::Service>(
126        &self,
127        ticket: &Authenticated<Ticket<S>>,
128    ) -> Result<()> {
129        self.core.settle_key(ticket).await
130    }
131
132    /// System version for the established session
133    pub async fn system_version(&self, ticket_id: Uuid) -> Option<String> {
134        self.core.system_version(ticket_id).await
135    }
136}
137
138// Traces
139impl<T> RoutexClient<T>
140where
141    T: HttpClient + Clone,
142{
143    /// Trace identifier returned with the last request
144    ///
145    /// # Panics
146    ///
147    /// Panics of the internal lock is poisoned.
148    pub fn trace_id(&self) -> Option<Vec<u8>> {
149        self.core.trace_id()
150    }
151
152    /// Retrieve trace data
153    ///
154    /// # Errors
155    ///
156    /// * [`Error::RequestError`] if the request fails.
157    /// * [`Error::NotFound`] if the trace is not available.
158    pub async fn trace<S: routex_api::Service>(
159        &self,
160        ticket: &Authenticated<Ticket<S>>,
161        trace_id: &[u8],
162    ) -> Result<String> {
163        let ticket_id = ticket.to_data().id;
164
165        let trace_id = BASE64_URL_SAFE.encode(
166            self.core
167                .with_key_settlement(ticket_id, async |keys| {
168                    keys.seal(trace_id, &ticket_id).await
169                })
170                .await
171                .map_err(Error::RequestError)?,
172        );
173
174        self.core
175            .execute(
176                ticket,
177                self.core
178                    .get(&routex_api::traces::path(&trace_id).to_url(self.core.url())),
179            )
180            .await
181            .map_err(handle_not_found)
182            .and_then(|res| {
183                String::from_utf8(res.to_vec()).map_err(|err| Error::RequestError(anyhow!(err)))
184            })
185    }
186}
187
188// Redirects
189impl<T> RoutexClient<T>
190where
191    T: HttpClient + Clone,
192{
193    /// Set a redirect URI for subsequent service requests.
194    ///
195    /// Redirects will eventually forward to that URI.
196    /// It can be used to redirect back to a web application or to jump
197    /// back into the context of a desktop or mobile application.
198    ///
199    /// If no redirect URI is set, [`RedirectHandle`]s will get returned instead of [`Redirect`]s.
200    ///
201    /// # Errors
202    ///
203    /// Fails if the URI is not a valid header value (probably lacks proper percent encoding).
204    pub fn set_redirect_uri(
205        &mut self,
206        redirect_uri: &str,
207    ) -> std::result::Result<(), InvalidHeaderValue> {
208        self.core.set_redirect_uri(redirect_uri)
209    }
210
211    pub fn set_recurring_consents(&mut self, enabled: bool) {
212        self.recurring_consents = enabled;
213    }
214
215    /// Register a redirect URI for a given redirect handle.
216    ///
217    /// Returns the URL that the user is meant to get sent to.
218    ///
219    /// # Errors
220    ///
221    /// Fails if the request fails or the response data cannot get parsed as expected.
222    pub async fn register_redirect_uri<S: routex_api::Service>(
223        &self,
224        ticket: &Authenticated<Ticket<S>>,
225        handle: impl Into<String>,
226        redirect_uri: impl Into<String>,
227    ) -> Result<Url> {
228        Ok(json_decode::<routex_api::redirects::Response>(
229            &self
230                .core
231                .execute(
232                    ticket,
233                    self.core.post(
234                        &routex_api::redirects::path().to_url(self.core.url()),
235                        &routex_api::redirects::Request {
236                            handle: handle.into(),
237                            redirect_uri: redirect_uri.into(),
238                        },
239                    ),
240                )
241                .await?,
242        )?
243        .redirect_url)
244    }
245}
246
247// Info
248impl<T> RoutexClient<T>
249where
250    T: HttpClient + Clone,
251{
252    /// Search for service connections (banks and other providers)
253    ///
254    /// The result is a list of connections that match all the [`SearchFilter`]s.
255    ///
256    /// # Errors
257    ///
258    /// Fails if the request fails.
259    ///
260    /// # Example
261    ///
262    /// ```no_run
263    /// # tokio_test::block_on(async {
264    /// # let client = RoutexClient::from_env();
265    /// # let user_input = "my bank";
266    /// # let ticket: Authenticated<Ticket<AccountsService>> = unimplemented!();
267    /// use routex_client::prelude::*;
268    ///
269    /// // Split input at whitespace for improved name matching
270    /// let mut filters: Vec<_> = user_input
271    ///     .split_whitespace()
272    ///     .map(|term| SearchFilter::Term(term.to_string()))
273    ///     .collect();
274    ///
275    /// filters.push(SearchFilter::Types(vec![ConnectionType::Production]));
276    ///
277    /// let connection_infos = client
278    ///     .search(ticket, filters)
279    ///     .iban_detection(true)
280    ///     .limit(20)
281    ///     .send()
282    ///     .await?;
283    /// # Ok::<_, routex_client::Error>(())
284    /// # });
285    /// ```
286    pub fn search<S: routex_api::Service>(
287        &self,
288        ticket: Authenticated<Ticket<S>>,
289        filters: impl IntoIterator<Item = SearchFilter>,
290    ) -> SearchRequest<S, T> {
291        self.core.search(ticket, filters)
292    }
293
294    /// Get information for a service connection
295    ///
296    /// # Errors
297    ///
298    /// * [`Error::RequestError`] if the request fails.
299    /// * [`Error::NotFound`] if the connection ID is not known.
300    ///
301    /// # Examples
302    ///
303    /// ```no_run
304    /// # tokio_test::block_on(async {
305    /// # use routex_client::prelude::*;
306    /// # let client = RoutexClient::from_env();
307    /// # let ticket: &Authenticated<Ticket<AccountsService>> = unimplemented!();
308    /// # let connection_id = unimplemented!();
309    /// let connection_info = client.info(ticket, connection_id).await?;
310    /// # Ok::<_, routex_client::Error>(())
311    /// # });
312    /// ```
313    pub async fn info<S: routex_api::Service>(
314        &self,
315        ticket: &Authenticated<Ticket<S>>,
316        connection_id: ConnectionId,
317    ) -> Result<ConnectionInfo> {
318        self.core.info(ticket, connection_id).await
319    }
320}
321
322#[must_use]
323pub struct Request<S, C>
324where
325    S: Service,
326{
327    inner: ServiceRequest<S>,
328    ticket: Authenticated<Ticket<S>>,
329    client: RoutexClientCore<C>,
330}
331
332impl<S, C> Request<S, C>
333where
334    S: Service,
335    C: HttpClient + Clone,
336{
337    /// Set a session to use
338    pub fn session(mut self, session: Session) -> Self {
339        self.inner.session = Some(session);
340        self
341    }
342
343    /// Enable the use of recurring consenst
344    pub fn recurring_consents(mut self, enabled: bool) -> Self {
345        self.inner.recurring_consents = enabled;
346        self
347    }
348
349    /// Send the request.
350    ///
351    /// # Errors
352    ///
353    /// Fails if the request fails.
354    pub async fn send(self) -> Result<OBResponse<S>> {
355        json_decode(
356            &self
357                .client
358                .execute(
359                    &self.ticket,
360                    self.client.post(
361                        &<S as Service>::path().to_url(self.client.url()),
362                        &self.inner,
363                    ),
364                )
365                .await
366                .map_err(handle_not_found)?,
367        )
368    }
369}
370
371macro_rules! service {
372    {
373        $(#[$meta:meta])+
374        $service:ident
375        $(($arg:ident: $($type:tt)+))*
376        $({$($values:tt)*})?
377    } => {
378        paste! {
379            impl<T> RoutexClient<T>
380            where
381                T: HttpClient + Clone,
382            {
383                $(#[$meta])+
384                pub fn [<$service:snake>](
385                    &self,
386                    credentials: Credentials,
387                    ticket: &Authenticated<Ticket<[<$service Service>]>>,
388                    $($arg: $($type)+,)*
389                ) -> Request<[<$service Service>], T> {
390                    Request {
391                        inner: ServiceRequest::<[<$service Service>]> {
392                            credentials,
393                            session: None,
394                            recurring_consents: self.recurring_consents,
395                            data: routex_api::[<$service:snake>]::RequestData {
396                                $($($values)*)?
397                            },
398                        },
399                        ticket: ticket.clone(),
400                        client: self.core.clone(),
401                    }
402                }
403
404                #[doc = "Respond to [`Dialog`] returned by [`" $service:snake "`](Self::" $service:snake "), [`respond_" $service:snake "`](Self::respond_" $service:snake "), or [`confirm_" $service:snake "`](Self::confirm_" $service:snake ")"]
405                ///
406                /// # Errors
407                ///
408                /// Fails if the request fails.
409                pub async fn [<respond_$service:snake>](
410                    &self,
411                    ticket: &Authenticated<Ticket<[<$service Service>]>>,
412                    context: InputContext<[<$service Service>]>,
413                    response: impl Into<String>,
414                ) -> Result<OBResponse<[<$service Service>]>> {
415                    json_decode(
416                        &self.core
417                            .execute(
418                                ticket,
419                                self.core
420                                    .post(
421                                        &[<$service Service>]::response_path().to_url(self.core.url()),
422                                        &ResponseData { context, response: response.into() },
423                                    ),
424                            )
425                            .await
426                            .map_err(handle_not_found)?
427                    )
428                }
429
430                #[doc = "Confirm [`Dialog`] or [`Redirect`] returned by [`" $service:snake "`](Self::" $service:snake "), [`respond_" $service:snake "`](Self::respond_" $service:snake "), or [`confirm_" $service:snake "`](Self::confirm_" $service:snake ")"]
431                ///
432                /// # Errors
433                ///
434                /// Fails if the request fails.
435                pub async fn [<confirm_$service:snake>](
436                    &self,
437                    ticket: &Authenticated<Ticket<[<$service Service>]>>,
438                    context: ConfirmationContext<[<$service Service>]>,
439                ) -> Result<OBResponse<[<$service Service>]>> {
440                    json_decode(
441                        &self.core
442                            .execute(
443                                ticket,
444                                self.core
445                                    .post(
446                                        &[<$service Service>]::confirmation_path().to_url(self.core.url()),
447                                        &ConfirmationData { context },
448                                    ),
449                            )
450                            .await
451                            .map_err(handle_not_found)?
452                    )
453                }
454            }
455        }
456    }
457}
458
459service! {
460    /// [Accounts service](https://docs.yaxi.tech/accounts.html)
461    ///
462    /// # Example
463    ///
464    /// ```no_run
465    /// # tokio_test::block_on(async {
466    /// # use routex_client::prelude::*;
467    /// # let client = RoutexClient::from_env();
468    /// # let connection_id = unimplemented!();
469    /// # let user_id = None;
470    /// # let password = None;
471    /// # let connection_data = None;
472    /// # let accounts_ticket = unimplemented!();
473    /// let credentials = Credentials {
474    ///     connection_id,
475    ///     user_id,
476    ///     password,
477    ///     connection_data,
478    /// };
479    ///
480    /// let response = client
481    ///     .accounts(
482    ///         credentials,
483    ///         accounts_ticket,
484    ///         [
485    ///             AccountField::Iban,
486    ///             AccountField::Bic,
487    ///             AccountField::Name,
488    ///             AccountField::DisplayName,
489    ///             AccountField::OwnerName,
490    ///             AccountField::Currency,
491    ///         ],
492    ///         Some(
493    ///             AccountField::IBAN.not_eq(None)
494    ///                 .and(
495    ///                     AccountField::TYPE.eq(Some(AccountType::Current))
496    ///                         .or(AccountField::TYPE.eq(None))
497    ///                 )
498    ///                 .and(Account::supports(SupportedService::CollectPayment)),
499    ///         ),
500    ///     )
501    ///     .send()
502    ///     .await?;
503    ///
504    /// let OBResponse::Result(result, session, connection_data) = response else {
505    ///     todo!("Handle interrupts");
506    /// };
507    /// # Ok::<_, routex_client::Error>(())
508    /// # });
509    /// ```
510    Accounts
511    (fields: impl IntoIterator<Item = AccountField>)
512    (filter: Option<Filter<AccountField>>)
513    {
514        fields: fields.into_iter().collect(),
515        filter,
516    }
517}
518
519service! {
520    /// [Balances service](https://docs.yaxi.tech/balances.html)
521    ///
522    /// # Errors
523    ///
524    /// Fails if the request fails.
525    Balances
526    (accounts: impl IntoIterator<Item = AccountReference>)
527    {
528        accounts: accounts.into_iter().collect(),
529    }
530}
531
532service! {
533    /// [Transactions service](https://docs.yaxi.tech/transactions.html)
534    Transactions
535}
536
537service! {
538    /// [Collect Payment service](https://docs.yaxi.tech/collect-payment.html)
539    ///
540    /// # Example
541    ///
542    /// ```no_run
543    /// # tokio_test::block_on(async {
544    /// # use routex_client::prelude::*;
545    /// # use routex_api::collect_payment::DebtorAccountIdentifier as AccountIdentifier;
546    /// # use routex_api::collect_payment::DebtorAccountReference as AccountReference;
547    /// # let client = RoutexClient::from_env();
548    /// # let credentials = unimplemented!();
549    /// # let session = vec![].into();
550    /// # let payment_ticket = unimplemented!();
551    /// # let selected_iban = String::new();
552    /// let response = client
553    ///     .collect_payment(
554    ///         credentials,
555    ///         payment_ticket,
556    ///         Some(AccountReference {
557    ///             id: AccountIdentifier::Iban(selected_iban),
558    ///             currency: None,
559    ///         }),
560    ///     )
561    ///     .session(session)
562    ///     .send()
563    ///     .await?;
564    /// # Ok::<_, routex_client::Error>(())
565    /// # });
566    /// ```
567    CollectPayment
568    (account: Option<DebtorAccountReference>)
569    {
570        account
571    }
572}
573
574service! {
575    #[allow(clippy::too_many_arguments)]
576    /// [Transfer service](https://docs.yaxi.tech/transfer.html)
577    Transfer
578    (product: PaymentProduct)
579    (debtor_account: Option<AccountReference>)
580    (debtor_name: Option<String>)
581    (requested_execution_date: Option<ISODateTimeOrDate>)
582    (details: impl IntoIterator<Item = TransferDetails>)
583    {
584        product,
585        debtor_account,
586        debtor_name,
587        requested_execution_date,
588        details: details.into_iter().collect(),
589    }
590}