#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use anyhow::anyhow;
use base64::prelude::*;
use bytes::Bytes;
use futures::lock::Mutex;
use reqwest::header::{CONTENT_LENGTH, HeaderMap, InvalidHeaderValue};
use reqwest::{
Client,
header::{ACCEPT, HeaderValue},
};
use reqwest::{ClientBuilder, RequestBuilder, StatusCode};
use routex_api::{Authenticated, Error as ServiceError, ServiceId};
#[cfg(feature = "error")]
use routex_api::{
PaymentErrorCode, ProviderErrorCode, ServiceBlockedCode, TicketErrorCode,
UnsupportedProductReason,
};
use routex_settlement::KeySettlement;
use serde::Deserialize;
use url::Url;
#[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)),
}
}
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
Self::RequestError(anyhow!(err))
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[must_use]
#[derive(Clone, Debug)]
pub struct RoutexClientCore {
url: Url,
client: Client,
keys: Arc<Mutex<HashMap<String, KeySettlement<sealed::RoutexKeySettlementEndpoint>>>>,
redirect_uri: Option<HeaderValue>,
trace_id: Arc<RwLock<Option<Vec<u8>>>>,
}
mod sealed {
use reqwest::{Client, header::ACCEPT};
use routex_settlement::KeySettlementCore;
use url::Url;
use super::{Error, Response};
#[derive(Debug)]
pub struct RoutexKeySettlementEndpoint {
pub(super) url: Url,
pub(super) client: Client,
}
impl KeySettlementCore for RoutexKeySettlementEndpoint {
type Data = str;
async fn request(
&self,
public_key: [u8; 32],
ticket_id: &Self::Data,
) -> anyhow::Result<routex_api::keys::Response> {
let response = self
.client
.post(self.url.clone())
.header(&routex_api::headers::TICKET_ID, ticket_id)
.header(ACCEPT, routex_api::CURRENT_MEDIA_TYPE)
.json(&routex_api::keys::Request { public_key })
.send()
.await?;
if response.status().is_client_error() || response.status().is_server_error() {
Err(Error::from(Response {
url: response.url().clone(),
status: response.status(),
headers: response.headers().clone(),
body: response.bytes().await?,
})
.into())
} else {
Ok(response.json().await?)
}
}
}
}
pub const DEFAULT_URL: &str = "https://api.yaxi.tech/";
impl RoutexClientCore {
pub fn for_distribution(distribution: &str, version: &str, url: Url) -> Self {
let client = ClientBuilder::new()
.user_agent(format!(
"RoutexClient/{} ({})",
version,
[distribution, std::env::consts::OS, std::env::consts::ARCH]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("; "),
))
.build()
.unwrap();
Self {
url,
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: impl Into<String>,
f: impl AsyncFnOnce(&mut KeySettlement<sealed::RoutexKeySettlementEndpoint>) -> 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),
client: self.client.clone(),
})
}))
.await
}
pub async fn system_version(&self, ticket_id: impl Into<String>) -> 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 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
.header(&routex_api::headers::TICKET_ID, &ticket_id)
.header(
&routex_api::headers::TICKET,
BASE64_STANDARD.encode(
keys.seal(ticket.as_str().as_bytes(), &ticket_id)
.await
.map_err(Error::RequestError)?,
),
)
.header(ACCEPT, routex_api::CURRENT_MEDIA_TYPE)
.build()
.expect("build should work");
if let Some(value) = self.redirect_uri.clone() {
request
.headers_mut()
.insert(&routex_api::headers::REDIRECT_URI, value);
}
if let Some(body) = request.body_mut() {
*body = keys
.seal(
body.as_bytes().expect("Body should be reusable"),
&ticket_id,
)
.await
.map_err(Error::RequestError)?
.into();
}
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.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() {
let url = response.url().clone();
let status = response.status();
let headers = response.headers().clone();
let body = response.bytes().await?;
Err(Error::from(Response {
url,
status,
headers,
body: keys.unseal(&body).map_or(body, Into::into),
}))
} else {
let body = response.bytes().await?;
Ok(if body.is_empty() {
body
} else {
keys.unseal(&body)
.map_err(|err| Error::RequestError(anyhow!(err)))?
.into()
})
}
})
.await
}
pub fn client(&self) -> &Client {
&self.client
}
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(),
});