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}