use std::fmt;
use std::sync::Arc;
use reqwest::Url;
use crate::error::Error;
use crate::models::AcceptDatetimeFormat;
use crate::rate_limit::RateLimiter;
const DEFAULT_REST_RATE_LIMIT: u32 = 100;
const CONNECTIONS_PER_SECOND: u32 = 2;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Environment {
Practice,
Live,
Custom {
rest: Url,
stream: Url,
},
}
impl Environment {
fn rest_base(&self) -> Url {
match self {
Environment::Practice => Url::parse("https://api-fxpractice.oanda.com/v3").unwrap(),
Environment::Live => Url::parse("https://api-fxtrade.oanda.com/v3").unwrap(),
Environment::Custom { rest, .. } => rest.clone(),
}
}
fn stream_base(&self) -> Url {
match self {
Environment::Practice => Url::parse("https://stream-fxpractice.oanda.com/v3").unwrap(),
Environment::Live => Url::parse("https://stream-fxtrade.oanda.com/v3").unwrap(),
Environment::Custom { stream, .. } => stream.clone(),
}
}
}
pub(crate) struct Inner {
pub(crate) http: reqwest::Client,
pub(crate) rest_base: Url,
pub(crate) stream_base: Url,
pub(crate) token: String,
pub(crate) datetime_format: AcceptDatetimeFormat,
pub(crate) rest_limiter: Option<RateLimiter>,
pub(crate) conn_limiter: Option<RateLimiter>,
}
#[derive(Clone)]
pub struct Client {
pub(crate) inner: Arc<Inner>,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Client")
.field("rest_base", &self.inner.rest_base.as_str())
.field("stream_base", &self.inner.stream_base.as_str())
.field("token", &"<redacted>")
.field("datetime_format", &self.inner.datetime_format)
.finish()
}
}
impl Client {
pub fn new(environment: Environment, token: impl Into<String>) -> Client {
Client::builder()
.environment(environment)
.token(token)
.build()
.expect("default client configuration is valid")
}
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn datetime_format(&self) -> AcceptDatetimeFormat {
self.inner.datetime_format
}
}
#[derive(Debug)]
pub struct ClientBuilder {
environment: Environment,
token: Option<String>,
datetime_format: AcceptDatetimeFormat,
http: Option<reqwest::Client>,
user_agent: String,
rest_rate_limit: u32,
rate_limiting: bool,
}
impl Default for ClientBuilder {
fn default() -> Self {
ClientBuilder {
environment: Environment::Practice,
token: None,
datetime_format: AcceptDatetimeFormat::Rfc3339,
http: None,
user_agent: concat!("oanda-rs/", env!("CARGO_PKG_VERSION")).to_owned(),
rest_rate_limit: DEFAULT_REST_RATE_LIMIT,
rate_limiting: true,
}
}
}
impl ClientBuilder {
pub fn environment(mut self, environment: Environment) -> Self {
self.environment = environment;
self
}
pub fn token(mut self, token: impl Into<String>) -> Self {
self.token = Some(token.into());
self
}
pub fn datetime_format(mut self, format: AcceptDatetimeFormat) -> Self {
self.datetime_format = format;
self
}
pub fn http_client(mut self, http: reqwest::Client) -> Self {
self.http = Some(http);
self
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
pub fn rest_rate_limit(mut self, requests_per_second: u32) -> Self {
self.rest_rate_limit = requests_per_second;
self
}
pub fn rate_limiting(mut self, enabled: bool) -> Self {
self.rate_limiting = enabled;
self
}
pub fn build(self) -> Result<Client, Error> {
let token = match self.token {
Some(t) if !t.trim().is_empty() => t,
_ => return Err(Error::Config("a non-empty API token is required".into())),
};
if self.rate_limiting && self.rest_rate_limit == 0 {
return Err(Error::Config(
"rest_rate_limit must be at least 1 request per second".into(),
));
}
let http = match self.http {
Some(http) => http,
None => reqwest::Client::builder()
.user_agent(&self.user_agent)
.build()
.map_err(|e| Error::Config(format!("failed to build HTTP client: {e}")))?,
};
let (rest_limiter, conn_limiter) = if self.rate_limiting {
(
Some(RateLimiter::per_second(self.rest_rate_limit)),
Some(RateLimiter::per_second(CONNECTIONS_PER_SECOND)),
)
} else {
(None, None)
};
Ok(Client {
inner: Arc::new(Inner {
http,
rest_base: self.environment.rest_base(),
stream_base: self.environment.stream_base(),
token,
datetime_format: self.datetime_format,
rest_limiter,
conn_limiter,
}),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_is_send_sync_and_clone() {
fn assert_traits<T: Send + Sync + Clone + 'static>() {}
assert_traits::<Client>();
}
#[test]
fn debug_redacts_token() {
let client = Client::new(Environment::Practice, "super-secret");
let debug = format!("{client:?}");
assert!(!debug.contains("super-secret"));
assert!(debug.contains("<redacted>"));
}
#[test]
fn builder_requires_token() {
assert!(matches!(Client::builder().build(), Err(Error::Config(_))));
assert!(matches!(
Client::builder().token(" ").build(),
Err(Error::Config(_))
));
}
#[test]
fn environment_hosts() {
assert_eq!(
Environment::Practice.rest_base().as_str(),
"https://api-fxpractice.oanda.com/v3"
);
assert_eq!(
Environment::Live.stream_base().as_str(),
"https://stream-fxtrade.oanda.com/v3"
);
}
}