#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
use std::collections::HashMap;
use std::future::Future;
use std::sync::{Arc, RwLock};
use anyhow::anyhow;
use base64::prelude::*;
use bytes::Bytes;
use futures::lock::Mutex;
use http::header::{
ACCEPT, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderValue, InvalidHeaderValue, USER_AGENT,
};
use http::{Method, Request, StatusCode};
use routex_api::info::ConnectionInfo;
#[cfg(feature = "uniffi")]
use routex_api::info::CountryCode;
use routex_api::{Authenticated, ConnectionId, Error as ServiceError, Service, ServiceId};
#[cfg(feature = "error")]
use routex_api::{
PaymentErrorCode, ProviderErrorCode, ServiceBlockedCode, TicketErrorCode,
UnsupportedProductReason,
};
use routex_settlement::KeySettlement;
use serde::{Deserialize, Serialize};
use url::Url;
use uuid::Uuid;
#[cfg(feature = "reqwest")]
pub use reqwest;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
RequestError(anyhow::Error),
#[error("Service error")]
ServiceError(ServiceError),
#[error("Error response")]
ResponseError(Box<Response>),
#[error("Resource not found")]
NotFound,
}
#[derive(Clone, Debug)]
pub struct Response {
pub url: Url,
pub status: StatusCode,
pub headers: HeaderMap,
pub body: Bytes,
}
pub fn json_decode<T: for<'de> Deserialize<'de>>(body: &[u8]) -> Result<T> {
serde_json::from_slice(body).map_err(|err| Error::RequestError(err.into()))
}
impl From<Response> for Error {
fn from(response: Response) -> Error {
match serde_json::from_slice::<ServiceError>(&response.body) {
Ok(payload) => Error::ServiceError(payload),
Err(_) => Error::ResponseError(Box::new(response)),
}
}
}
pub trait HttpClient {
fn execute(&self, req: Request<Vec<u8>>) -> impl Future<Output = Result<Response>>;
}
#[cfg(feature = "reqwest")]
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
Self::RequestError(anyhow!(err))
}
}
#[cfg(feature = "reqwest")]
impl HttpClient for reqwest::Client {
async fn execute(&self, req: Request<Vec<u8>>) -> Result<Response> {
match self.execute(req.try_into()?).await {
Ok(r) => Ok(Response {
url: r.url().clone(),
status: r.status(),
headers: r.headers().clone(),
body: r.bytes().await?,
}),
Err(err) => Err(err.into()),
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[must_use]
#[derive(Clone, Debug)]
pub struct RoutexClientCore<C> {
url: Url,
user_agent: HeaderValue,
http_client: C,
keys: Arc<Mutex<HashMap<String, KeySettlement<sealed::RoutexKeySettlementEndpoint<C>>>>>,
redirect_uri: Option<HeaderValue>,
trace_id: Arc<RwLock<Option<Vec<u8>>>>,
}
mod sealed {
use http::HeaderValue;
use routex_settlement::KeySettlementCore;
use url::Url;
use uuid::Uuid;
use super::{Error, HttpClient, RequestBuilder};
#[derive(Debug)]
pub struct RoutexKeySettlementEndpoint<C> {
pub(super) url: Url,
pub(super) user_agent: HeaderValue,
pub(super) http_client: C,
}
impl<C> KeySettlementCore for RoutexKeySettlementEndpoint<C>
where
C: HttpClient,
{
type Data = Uuid;
async fn request(
&self,
public_key: [u8; 32],
ticket_id: &Self::Data,
) -> anyhow::Result<routex_api::keys::Response> {
let request =
RequestBuilder::post(&self.url, &routex_api::keys::Request { public_key })
.build(ticket_id, self.user_agent.clone());
let response = self.http_client.execute(request).await?;
if response.status.is_client_error() || response.status.is_server_error() {
Err(Error::from(response).into())
} else {
Ok(serde_json::from_slice(&response.body)?)
}
}
}
}
#[derive(Debug)]
pub struct RequestBuilder {
request: Request<Vec<u8>>,
}
impl RequestBuilder {
fn get(url: &Url) -> Self {
let mut request = Request::new(Vec::new());
*request.method_mut() = Method::GET;
*request.uri_mut() = url.as_str().try_into().expect("URL to URI should work");
Self { request }
}
fn post(url: &Url, json: &impl Serialize) -> Self {
let mut request =
Request::new(serde_json::to_vec(&json).expect("Serialization should work"));
*request.method_mut() = Method::POST;
*request.uri_mut() = url.as_str().try_into().expect("URL to URI should work");
request
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Self { request }
}
fn build(mut self, ticket_id: &Uuid, user_agent: HeaderValue) -> Request<Vec<u8>> {
self.request.headers_mut().extend([
(
routex_api::headers::TICKET_ID.clone(),
HeaderValue::from_str(&ticket_id.to_string()).expect("ASCII"),
),
(
ACCEPT,
HeaderValue::from_static(routex_api::CURRENT_MEDIA_TYPE),
),
(USER_AGENT, user_agent),
]);
self.request
}
}
#[must_use]
pub struct SearchRequest<S, C>
where
S: Service,
{
inner: routex_api::info::Request,
ticket: Authenticated<routex_api::Ticket<S>>,
client: RoutexClientCore<C>,
}
impl<S, C> SearchRequest<S, C>
where
S: Service,
C: HttpClient + Clone,
{
pub fn iban_detection(mut self, enable: bool) -> Self {
self.inner.iban_detection = enable;
self
}
pub fn limit(mut self, limit: impl Into<Option<usize>>) -> Self {
self.inner.limit = limit.into();
self
}
pub fn details(mut self, details: impl IntoIterator<Item = routex_api::info::Details>) -> Self {
self.inner.details = details.into_iter().collect();
self
}
pub async fn send(self) -> Result<Vec<ConnectionInfo>> {
json_decode(
&self
.client
.execute(
&self.ticket,
self.client.post(
&routex_api::info::search_path().to_url(self.client.url()),
&self.inner,
),
)
.await?,
)
}
}
pub const DEFAULT_URL: &str = "https://api.yaxi.tech/";
impl<C> RoutexClientCore<C>
where
C: HttpClient + Clone,
{
pub fn for_distribution(distribution: &str, version: &str, url: Url, http_client: C) -> Self {
Self {
url,
user_agent: format!(
"RoutexClient/{} ({})",
version,
[distribution, std::env::consts::OS, std::env::consts::ARCH]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("; "),
)
.try_into()
.expect("Invalid header value"),
http_client,
keys: Arc::default(),
redirect_uri: None,
trace_id: Arc::default(),
}
}
pub async fn settle_key<S: routex_api::Service>(
&self,
ticket: &Authenticated<routex_api::Ticket<S>>,
) -> Result<()> {
let ticket_id = ticket.to_data().id;
self.with_key_settlement(ticket_id, async |keys| {
keys.settle(&ticket_id).await.map_err(Error::RequestError)
})
.await
}
pub async fn with_key_settlement<U>(
&self,
ticket_id: Uuid,
f: impl AsyncFnOnce(&mut KeySettlement<sealed::RoutexKeySettlementEndpoint<C>>) -> U,
) -> U {
f(self
.keys
.lock()
.await
.entry(ticket_id.into())
.or_insert_with(|| {
KeySettlement::new(sealed::RoutexKeySettlementEndpoint {
url: routex_api::keys::settlement_path().to_url(&self.url),
user_agent: self.user_agent.clone(),
http_client: self.http_client.clone(),
})
}))
.await
}
pub async fn system_version(&self, ticket_id: Uuid) -> Option<String> {
self.with_key_settlement(ticket_id, async |keys| keys.system_version().cloned())
.await
.map(|v| serde_json::to_string(&v).expect("serialization should work"))
}
pub fn search<S: routex_api::Service>(
&self,
ticket: Authenticated<routex_api::Ticket<S>>,
filters: impl IntoIterator<Item = routex_api::info::SearchFilter>,
) -> SearchRequest<S, C> {
SearchRequest {
inner: routex_api::info::Request::new(filters),
ticket,
client: self.clone(),
}
}
pub async fn info<S: routex_api::Service>(
&self,
ticket: &Authenticated<routex_api::Ticket<S>>,
connection_id: ConnectionId,
) -> Result<ConnectionInfo> {
let response = self
.execute(
ticket,
self.get(
&routex_api::info::fetch_path(&connection_id.to_string()).to_url(self.url()),
),
)
.await
.map_err(|err| {
if let Error::ResponseError(response) = &err
&& response.status == StatusCode::NOT_FOUND
{
Error::NotFound
} else {
err
}
})?;
json_decode(&response)
}
pub async fn execute<S: routex_api::Service>(
&self,
ticket: &Authenticated<routex_api::Ticket<S>>,
request: RequestBuilder,
) -> Result<Bytes> {
let ticket_id = ticket.to_data().id;
self.with_key_settlement(ticket_id, async |keys| {
let mut request = request.build(&ticket_id, self.user_agent.clone());
request.headers_mut().insert(
routex_api::headers::TICKET.clone(),
HeaderValue::from_str(
&BASE64_STANDARD.encode(
keys.seal(ticket.as_str().as_bytes(), &ticket_id)
.await
.map_err(Error::RequestError)?,
),
)
.expect("ASCII"),
);
if let Some(value) = self.redirect_uri.clone() {
request
.headers_mut()
.insert(&routex_api::headers::REDIRECT_URI, value);
}
*request.body_mut() = keys
.seal(request.body(), &ticket_id)
.await
.map_err(Error::RequestError)?;
request.headers_mut().insert(
&routex_api::headers::SESSION_ID,
keys.session_id(&ticket_id)
.await
.map_err(Error::RequestError)?
.clone(),
);
request.headers_mut().remove(&CONTENT_LENGTH);
let response = self.http_client.execute(request).await?;
*self.trace_id.write().expect("poisoned") = response
.headers
.get(&routex_api::headers::TRACE_ID)
.and_then(|v| v.to_str().ok())
.and_then(|v| BASE64_STANDARD.decode(v).ok())
.and_then(|v| keys.unseal(&v).ok());
if response.status.is_client_error() || response.status.is_server_error() {
Err(Response {
body: keys
.unseal(&response.body)
.map_or(response.body, Into::into),
..response
}
.into())
} else {
Ok(if response.body.is_empty() {
response.body
} else {
keys.unseal(&response.body)
.map_err(|err| Error::RequestError(anyhow!(err)))?
.into()
})
}
})
.await
}
pub fn get(&self, url: &Url) -> RequestBuilder {
RequestBuilder::get(url)
}
pub fn post(&self, url: &Url, json: &impl Serialize) -> RequestBuilder {
RequestBuilder::post(url, json)
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn trace_id(&self) -> Option<Vec<u8>> {
self.trace_id.read().expect("poisoned").clone()
}
pub fn set_redirect_uri(
&mut self,
redirect_uri: &str,
) -> std::result::Result<(), InvalidHeaderValue> {
self.redirect_uri = Some(redirect_uri.try_into()?);
Ok(())
}
}
#[must_use]
pub fn handle_not_found(err: Error) -> Error {
if let Error::ResponseError(response) = &err
&& response.status == StatusCode::NOT_FOUND
{
Error::NotFound
} else {
err
}
}
#[derive(serde::Deserialize, Clone)]
pub struct ServiceOnlyTicket {
pub service: ServiceId,
}
#[macro_export]
macro_rules! with_any_service {
($ticket:expr, $authenticated:ident, $block:block) => {
match $ticket.parse::<routex_api::Authenticated<routex_client_common::ServiceOnlyTicket>>()
{
Ok($authenticated) => match $authenticated.to_data().service {
routex_api::ServiceId::Accounts { .. } => {
with_any_service!(
$ticket,
$authenticated,
$block,
routex_api::accounts::Service
)
}
routex_api::ServiceId::CollectPayment { .. } => {
with_any_service!(
$ticket,
$authenticated,
$block,
routex_api::collect_payment::Service
)
}
routex_api::ServiceId::Balances { .. } => {
with_any_service!(
$ticket,
$authenticated,
$block,
routex_api::balances::Service
)
}
routex_api::ServiceId::Transactions { .. } => {
with_any_service!(
$ticket,
$authenticated,
$block,
routex_api::transactions::Service
)
}
routex_api::ServiceId::Transfer { .. } => {
with_any_service!(
$ticket,
$authenticated,
$block,
routex_api::transfer::Service
)
}
},
Err(err) => Err(err.into()),
}
};
($ticket:expr, $authenticated:ident, $block:block, $service:ty) => {
match $ticket.parse::<routex_api::Authenticated<routex_api::Ticket<$service>>>() {
Ok($authenticated) => $block.map_err(Into::into),
Err(err) => Err(err.into()),
}
};
}
#[cfg(feature = "uniffi")]
::uniffi::setup_scaffolding!();
#[cfg(feature = "error")]
#[allow(clippy::enum_variant_names)]
#[derive(thiserror::Error, Debug)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
pub enum RoutexClientError {
#[error("Invalid redirect URI")]
InvalidRedirectUri,
#[error("Request error")]
RequestError { error: String },
#[error("Unexpected service error")]
UnexpectedError { user_message: Option<String> },
#[error("Canceled")]
Canceled,
#[error("Invalid credentials")]
InvalidCredentials { user_message: Option<String> },
#[error("Service blocked")]
ServiceBlocked {
user_message: Option<String>,
code: Option<ServiceBlockedCode>,
},
#[error("Unauthorized")]
Unauthorized { user_message: Option<String> },
#[error("Consent expired")]
ConsentExpired { user_message: Option<String> },
#[error("Access exceeded")]
AccessExceeded { user_message: Option<String> },
#[error("Period out of bounds")]
PeriodOutOfBounds { user_message: Option<String> },
#[error("Unsupported product")]
UnsupportedProduct {
reason: Option<UnsupportedProductReason>,
user_message: Option<String>,
},
#[error("Payment canceled or rejected")]
PaymentFailed {
code: Option<PaymentErrorCode>,
user_message: Option<String>,
},
#[error("Unexpected value")]
UnexpectedValue { error: String },
#[error("{error}")]
TicketError {
error: String,
code: TicketErrorCode,
},
#[error("The account-servicing provider indicated a technical error")]
ProviderError {
code: Option<ProviderErrorCode>,
user_message: Option<String>,
},
#[error("Error response")]
ResponseError { response: String },
#[error("Resource not found")]
NotFound,
#[error("The transaction is not possible without user interaction")]
InterruptError,
}
#[cfg(feature = "error")]
impl From<Error> for RoutexClientError {
fn from(error: Error) -> Self {
match error {
Error::RequestError(error) => Self::RequestError {
error: error.to_string(),
},
Error::ServiceError(ServiceError::UnexpectedError { user_message, .. }) => {
Self::UnexpectedError { user_message }
}
Error::ServiceError(ServiceError::Canceled { .. }) => Self::Canceled,
Error::ServiceError(ServiceError::InvalidCredentials { user_message, .. }) => {
Self::InvalidCredentials { user_message }
}
Error::ServiceError(ServiceError::ServiceBlocked {
user_message, code, ..
}) => Self::ServiceBlocked { user_message, code },
Error::ServiceError(ServiceError::Unauthorized { user_message, .. }) => {
Self::Unauthorized { user_message }
}
Error::ServiceError(ServiceError::ConsentExpired { user_message, .. }) => {
Self::ConsentExpired { user_message }
}
Error::ServiceError(ServiceError::AccessExceeded { user_message, .. }) => {
Self::AccessExceeded { user_message }
}
Error::ServiceError(ServiceError::PeriodOutOfBounds { user_message, .. }) => {
Self::PeriodOutOfBounds { user_message }
}
Error::ServiceError(ServiceError::UnsupportedProduct {
reason,
user_message,
..
}) => Self::UnsupportedProduct {
reason,
user_message,
},
Error::ServiceError(ServiceError::PaymentFailed {
code, user_message, ..
}) => Self::PaymentFailed { code, user_message },
Error::ServiceError(ServiceError::UnexpectedValue { error, .. }) => {
Self::UnexpectedValue { error }
}
Error::ServiceError(ServiceError::TicketError { error, code, .. }) => {
Self::TicketError { error, code }
}
Error::ServiceError(ServiceError::ProviderError {
code, user_message, ..
}) => Self::ProviderError { code, user_message },
Error::ServiceError(ServiceError::InterruptError { .. }) => Self::InterruptError,
Error::ResponseError(response) => Self::ResponseError {
response: format!("{response:?}"),
},
Error::NotFound => Self::NotFound,
}
}
}
#[cfg(feature = "error")]
impl From<jsonwebtoken::errors::Error> for RoutexClientError {
fn from(err: jsonwebtoken::errors::Error) -> Self {
RoutexClientError::TicketError {
error: err.to_string(),
code: routex_api::TicketErrorCode::Invalid,
}
}
}
#[cfg(feature = "uniffi")]
pub struct Ticket(String);
#[cfg(feature = "uniffi")]
impl Ticket {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[cfg(feature = "uniffi")]
uniffi::custom_newtype!(Ticket, String);
#[cfg(feature = "uniffi")]
uniffi::custom_type!(Url, String, {
remote,
try_lift: |val| Ok(val.parse()?),
lower: |obj| obj.into(),
});
#[cfg(feature = "uniffi")]
uniffi::use_remote_type!(routex_api::CountryCode);
#[cfg(feature = "uniffi")]
#[derive(uniffi::Enum)]
pub enum SearchFilter {
Types {
types: Vec<routex_api::info::ConnectionType>,
},
Countries { countries: Vec<CountryCode> },
Name { name: String },
Bic { bic: String },
BankCode { bank_code: String },
Term { term: String },
}
#[cfg(feature = "uniffi")]
impl From<SearchFilter> for routex_api::info::SearchFilter {
fn from(filter: SearchFilter) -> Self {
match filter {
SearchFilter::Types { types } => routex_api::info::SearchFilter::Types(types),
SearchFilter::Countries { countries } => {
routex_api::info::SearchFilter::Countries(countries)
}
SearchFilter::Name { name } => routex_api::info::SearchFilter::Name(name),
SearchFilter::Bic { bic } => routex_api::info::SearchFilter::Bic(bic),
SearchFilter::BankCode { bank_code } => {
routex_api::info::SearchFilter::BankCode(bank_code)
}
SearchFilter::Term { term } => routex_api::info::SearchFilter::Term(term),
}
}
}
#[cfg(feature = "uniffi")]
macro_rules! account_filter {
{
$($field:ident $type:ty)+
} => {
paste::paste! {
#[derive(uniffi::Enum)]
pub enum AccountFilter {
$(
[<$field Eq>] { value: $type },
[<$field NotEq>] { value: $type },
)+
All {
filters: Vec<AccountFilter>,
},
Any {
filters: Vec<AccountFilter>,
},
Supports { service: routex_api::SupportedService },
}
impl From<AccountFilter> for Option<routex_api::Filter<routex_api::accounts::AccountField>> {
fn from(filter: AccountFilter) -> Self {
match filter {
$(
AccountFilter::[<$field Eq>] { value } => Some(routex_api::accounts::AccountField::[<$field:snake:upper>].eq(value)),
AccountFilter::[<$field NotEq>] { value } => Some(routex_api::accounts::AccountField::[<$field:snake:upper>].not_eq(value)),
)+
AccountFilter::All { filters } => filters
.into_iter()
.map(Option::<routex_api::Filter<_>>::from)
.flatten()
.reduce(routex_api::Filter::and),
AccountFilter::Any { filters } => filters
.into_iter()
.map(Option::<routex_api::Filter<_>>::from)
.flatten()
.reduce(routex_api::Filter::or),
AccountFilter::Supports { service } => {
Some(routex_api::Account::supports(service))
},
}
}
}
}
}
}
#[cfg(feature = "uniffi")]
account_filter! {
Iban Option<String>
Number Option<String>
Bic Option<String>
BankCode Option<String>
Currency String
Name Option<String>
DisplayName Option<String>
OwnerName Option<String>
ProductName Option<String>
Status Option<routex_api::AccountStatus>
Type Option<routex_api::AccountType>
}