use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use serde::de::DeserializeOwned;
use crate::api::balances::BalancesApi;
use crate::api::configuration::ConfigurationApi;
use crate::api::market_data::MarketDataApi;
use crate::api::orders::OrdersApi;
use crate::api::trades::TradesApi;
use crate::auth::{Ed25519Signer, Signer};
use crate::error::{Error, Result, classify_error_response, preview};
use crate::transport::{LocalExecutor, RequestExecutor, RequestSpec};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Environment {
Production,
Dev,
}
impl Environment {
pub const fn base_url(self) -> &'static str {
match self {
Self::Production => "https://revx.revolut.com/api/1.0",
Self::Dev => "https://revx.revolut.codes/api/1.0",
}
}
}
#[derive(Clone)]
pub struct RevolutXClient {
executor: Arc<dyn RequestExecutor>,
}
impl RevolutXClient {
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn with_executor(executor: Arc<dyn RequestExecutor>) -> Self {
Self { executor }
}
#[must_use]
pub fn executor(&self) -> Arc<dyn RequestExecutor> {
Arc::clone(&self.executor)
}
pub fn base_url(&self) -> &str {
self.executor.base_url()
}
pub fn is_authenticated(&self) -> bool {
self.executor.is_authenticated()
}
pub const fn balances(&self) -> BalancesApi<'_> {
BalancesApi::new(self)
}
pub const fn configuration(&self) -> ConfigurationApi<'_> {
ConfigurationApi::new(self)
}
pub const fn market_data(&self) -> MarketDataApi<'_> {
MarketDataApi::new(self)
}
pub const fn orders(&self) -> OrdersApi<'_> {
OrdersApi::new(self)
}
pub const fn trades(&self) -> TradesApi<'_> {
TradesApi::new(self)
}
pub(crate) async fn send_json<T: DeserializeOwned>(&self, spec: RequestSpec) -> Result<T> {
let method = spec.method().as_str().to_owned();
let path = spec.path().to_owned();
let response = self.executor.execute(spec).await?;
if (200..300).contains(&response.status) {
serde_json::from_slice::<T>(&response.body).map_err(|source| Error::Deserialize {
method,
path,
source,
body: preview(&response.body),
})
} else {
Err(classify_error_response(
response.status,
response.retry_after,
&response.body,
))
}
}
pub(crate) async fn send_no_content(&self, spec: RequestSpec) -> Result<()> {
let response = self.executor.execute(spec).await?;
if (200..300).contains(&response.status) {
Ok(())
} else {
Err(classify_error_response(
response.status,
response.retry_after,
&response.body,
))
}
}
}
impl fmt::Debug for RevolutXClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RevolutXClient")
.field("base_url", &self.executor.base_url())
.field("authenticated", &self.executor.is_authenticated())
.finish()
}
}
#[derive(Clone)]
enum KeySource {
Pem(String),
Seed([u8; 32]),
}
#[derive(Default)]
pub struct ClientBuilder {
api_key: Option<String>,
key: Option<KeySource>,
signer: Option<Arc<dyn Signer>>,
executor: Option<Arc<dyn RequestExecutor>>,
environment: Option<Environment>,
base_url: Option<String>,
timeout: Option<Duration>,
http_client: Option<reqwest::Client>,
}
impl ClientBuilder {
#[must_use]
pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
#[must_use]
pub fn private_key_pem(mut self, pem: impl Into<String>) -> Self {
self.key = Some(KeySource::Pem(pem.into()));
self
}
#[must_use]
pub fn private_key_bytes(mut self, seed: [u8; 32]) -> Self {
self.key = Some(KeySource::Seed(seed));
self
}
#[must_use]
pub fn signer(mut self, signer: Arc<dyn Signer>) -> Self {
self.signer = Some(signer);
self
}
#[must_use]
pub fn executor(mut self, executor: Arc<dyn RequestExecutor>) -> Self {
self.executor = Some(executor);
self
}
#[must_use]
pub const fn environment(mut self, environment: Environment) -> Self {
self.environment = Some(environment);
self
}
#[must_use]
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
#[must_use]
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
#[must_use]
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = Some(client);
self
}
pub fn selected_environment(&self) -> Environment {
self.environment.unwrap_or(Environment::Production)
}
pub fn build(self) -> Result<RevolutXClient> {
let Self {
api_key,
key,
signer,
executor,
environment,
base_url,
timeout,
http_client,
} = self;
if let Some(executor) = executor {
return Ok(RevolutXClient { executor });
}
let environment = environment.unwrap_or(Environment::Production);
let base_url = base_url.unwrap_or_else(|| environment.base_url().to_owned());
if base_url.trim().is_empty() {
return Err(Error::configuration("base URL must not be empty"));
}
let signer: Option<Arc<dyn Signer>> = match signer {
Some(signer) => Some(signer),
None => match (api_key, key) {
(Some(api_key), Some(KeySource::Pem(pem))) => {
Some(Arc::new(Ed25519Signer::from_pem(api_key, &pem)?))
}
(Some(api_key), Some(KeySource::Seed(seed))) => {
Some(Arc::new(Ed25519Signer::from_seed(api_key, seed)))
}
(None, None) => None,
(Some(_), None) => {
return Err(Error::configuration(
"an API key was provided but no private key; call private_key_pem, private_key_bytes, or signer",
));
}
(None, Some(_)) => {
return Err(Error::configuration(
"a private key was provided but no API key; call api_key",
));
}
},
};
let http_client = match http_client {
Some(client) => client,
None => reqwest::Client::builder()
.timeout(timeout.unwrap_or(DEFAULT_TIMEOUT))
.build()
.map_err(|e| Error::configuration(format!("could not build HTTP client: {e}")))?,
};
let executor = LocalExecutor::new(&base_url, http_client, signer)?;
Ok(RevolutXClient {
executor: Arc::new(executor),
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
const TEST_SEED: [u8; 32] = [7u8; 32];
#[test]
fn default_environment_is_production() {
let client = RevolutXClient::builder().build().unwrap();
assert_eq!(client.base_url(), Environment::Production.base_url());
assert!(!client.is_authenticated());
}
#[test]
fn dev_environment_uses_dev_base_url() {
let client = RevolutXClient::builder()
.environment(Environment::Dev)
.build()
.unwrap();
assert_eq!(client.base_url(), Environment::Dev.base_url());
}
#[test]
fn custom_base_url_overrides_environment() {
let client = RevolutXClient::builder()
.environment(Environment::Dev)
.base_url("http://127.0.0.1:8080/api/1.0")
.build()
.unwrap();
assert_eq!(client.base_url(), "http://127.0.0.1:8080/api/1.0");
}
#[test]
fn credentials_make_client_authenticated() {
let client = RevolutXClient::builder()
.api_key("key")
.private_key_bytes(TEST_SEED)
.build()
.unwrap();
assert!(client.is_authenticated());
}
#[test]
fn custom_signer_makes_client_authenticated() {
let signer = Arc::new(Ed25519Signer::from_seed("key", TEST_SEED));
let client = RevolutXClient::builder().signer(signer).build().unwrap();
assert!(client.is_authenticated());
}
#[test]
fn partial_credentials_are_rejected() {
let err = RevolutXClient::builder()
.api_key("key")
.build()
.unwrap_err();
assert!(matches!(err, Error::Configuration { .. }));
let err = RevolutXClient::builder()
.private_key_bytes(TEST_SEED)
.build()
.unwrap_err();
assert!(matches!(err, Error::Configuration { .. }));
}
#[test]
fn empty_custom_base_url_is_rejected() {
let err = RevolutXClient::builder()
.base_url(" ")
.build()
.unwrap_err();
assert!(matches!(err, Error::Configuration { .. }));
}
#[test]
fn invalid_pem_surfaces_key_error() {
let err = RevolutXClient::builder()
.api_key("key")
.private_key_pem("not a pem")
.build()
.unwrap_err();
assert!(matches!(err, Error::Key { .. }));
}
}