Skip to main content

oanda_rs/
client.rs

1//! The OANDA API client.
2
3use std::fmt;
4use std::sync::Arc;
5
6use reqwest::Url;
7
8use crate::error::Error;
9use crate::models::AcceptDatetimeFormat;
10use crate::rate_limit::RateLimiter;
11
12/// Default REST rate limit (requests/second). OANDA rejects above 120/s per
13/// IP; the default keeps comfortable headroom.
14const DEFAULT_REST_RATE_LIMIT: u32 = 100;
15/// OANDA allows at most 2 new connections per second per IP.
16const CONNECTIONS_PER_SECOND: u32 = 2;
17
18/// The OANDA environment (host pair) a [`Client`] talks to.
19#[derive(Debug, Clone)]
20#[non_exhaustive]
21pub enum Environment {
22    /// The fxTrade Practice (demo) environment:
23    /// `api-fxpractice.oanda.com` / `stream-fxpractice.oanda.com`.
24    Practice,
25    /// The fxTrade live environment:
26    /// `api-fxtrade.oanda.com` / `stream-fxtrade.oanda.com`.
27    Live,
28    /// Custom host pair, mainly for tests and proxies. Both URLs are used
29    /// as-is (the `/v3` prefix is **not** appended).
30    Custom {
31        /// Base URL for REST requests.
32        rest: Url,
33        /// Base URL for the pricing/transactions streams.
34        stream: Url,
35    },
36}
37
38impl Environment {
39    fn rest_base(&self) -> Url {
40        match self {
41            Environment::Practice => Url::parse("https://api-fxpractice.oanda.com/v3").unwrap(),
42            Environment::Live => Url::parse("https://api-fxtrade.oanda.com/v3").unwrap(),
43            Environment::Custom { rest, .. } => rest.clone(),
44        }
45    }
46
47    fn stream_base(&self) -> Url {
48        match self {
49            Environment::Practice => Url::parse("https://stream-fxpractice.oanda.com/v3").unwrap(),
50            Environment::Live => Url::parse("https://stream-fxtrade.oanda.com/v3").unwrap(),
51            Environment::Custom { stream, .. } => stream.clone(),
52        }
53    }
54}
55
56pub(crate) struct Inner {
57    pub(crate) http: reqwest::Client,
58    pub(crate) rest_base: Url,
59    pub(crate) stream_base: Url,
60    pub(crate) token: String,
61    pub(crate) datetime_format: AcceptDatetimeFormat,
62    pub(crate) rest_limiter: Option<RateLimiter>,
63    pub(crate) conn_limiter: Option<RateLimiter>,
64}
65
66/// An asynchronous OANDA v20 API client.
67///
68/// The client is cheap to clone (all clones share one connection pool and
69/// one rate limiter) and is `Send + Sync`, so a single instance can be
70/// shared freely between concurrent tokio tasks:
71///
72/// ```no_run
73/// use oanda_rs::{Client, Environment};
74///
75/// let client = Client::new(Environment::Practice, "my-token");
76/// let for_task = client.clone(); // shares pool + rate limiter
77/// ```
78///
79/// Rate limiting is built in and enabled by default (100 REST requests/s,
80/// 2 stream connections/s — under OANDA's 120/s and 2/s caps). See
81/// [`Client::builder`] for tuning.
82#[derive(Clone)]
83pub struct Client {
84    pub(crate) inner: Arc<Inner>,
85}
86
87impl fmt::Debug for Client {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.debug_struct("Client")
90            .field("rest_base", &self.inner.rest_base.as_str())
91            .field("stream_base", &self.inner.stream_base.as_str())
92            .field("token", &"<redacted>")
93            .field("datetime_format", &self.inner.datetime_format)
94            .finish()
95    }
96}
97
98impl Client {
99    /// Creates a client for `environment` authenticating with the given
100    /// personal access token, using default settings.
101    ///
102    /// Use [`Client::builder`] to customize the datetime format, rate
103    /// limits, or the underlying HTTP client.
104    ///
105    /// # Panics
106    ///
107    /// Panics if the system TLS backend cannot be initialized (same
108    /// condition as [`reqwest::Client::new`]).
109    pub fn new(environment: Environment, token: impl Into<String>) -> Client {
110        Client::builder()
111            .environment(environment)
112            .token(token)
113            .build()
114            .expect("default client configuration is valid")
115    }
116
117    /// Starts building a client with custom configuration.
118    pub fn builder() -> ClientBuilder {
119        ClientBuilder::default()
120    }
121
122    /// The datetime wire format this client requests via the
123    /// `Accept-Datetime-Format` header.
124    pub fn datetime_format(&self) -> AcceptDatetimeFormat {
125        self.inner.datetime_format
126    }
127}
128
129/// Configures and builds a [`Client`].
130///
131/// ```no_run
132/// use oanda_rs::{Client, Environment};
133/// use oanda_rs::models::AcceptDatetimeFormat;
134///
135/// let client = Client::builder()
136///     .environment(Environment::Practice)
137///     .token("my-token")
138///     .datetime_format(AcceptDatetimeFormat::Unix)
139///     .rest_rate_limit(50)
140///     .build()
141///     .unwrap();
142/// ```
143#[derive(Debug)]
144pub struct ClientBuilder {
145    environment: Environment,
146    token: Option<String>,
147    datetime_format: AcceptDatetimeFormat,
148    http: Option<reqwest::Client>,
149    user_agent: String,
150    rest_rate_limit: u32,
151    rate_limiting: bool,
152}
153
154impl Default for ClientBuilder {
155    fn default() -> Self {
156        ClientBuilder {
157            environment: Environment::Practice,
158            token: None,
159            datetime_format: AcceptDatetimeFormat::Rfc3339,
160            http: None,
161            user_agent: concat!("oanda-rs/", env!("CARGO_PKG_VERSION")).to_owned(),
162            rest_rate_limit: DEFAULT_REST_RATE_LIMIT,
163            rate_limiting: true,
164        }
165    }
166}
167
168impl ClientBuilder {
169    /// Selects the OANDA environment. Defaults to [`Environment::Practice`].
170    pub fn environment(mut self, environment: Environment) -> Self {
171        self.environment = environment;
172        self
173    }
174
175    /// Sets the personal access token used as the bearer token on every
176    /// request. Required.
177    pub fn token(mut self, token: impl Into<String>) -> Self {
178        self.token = Some(token.into());
179        self
180    }
181
182    /// Selects the datetime wire format requested via the
183    /// `Accept-Datetime-Format` header. Defaults to
184    /// [`AcceptDatetimeFormat::Rfc3339`].
185    pub fn datetime_format(mut self, format: AcceptDatetimeFormat) -> Self {
186        self.datetime_format = format;
187        self
188    }
189
190    /// Supplies a pre-configured [`reqwest::Client`] (proxies, timeouts,
191    /// custom TLS). When set, [`ClientBuilder::user_agent`] is ignored.
192    pub fn http_client(mut self, http: reqwest::Client) -> Self {
193        self.http = Some(http);
194        self
195    }
196
197    /// Overrides the `User-Agent` header of the default HTTP client.
198    /// Defaults to `oanda-rs/<version>`.
199    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
200        self.user_agent = user_agent.into();
201        self
202    }
203
204    /// Sets the client-side REST rate limit in requests per second.
205    /// Defaults to 100 (OANDA rejects above 120/s per IP).
206    ///
207    /// Note that OANDA's limits apply per IP address: if several processes
208    /// or [`Client`] instances share one IP, their combined rate matters.
209    /// Within one process, share a single `Client` (it is cheap to clone).
210    pub fn rest_rate_limit(mut self, requests_per_second: u32) -> Self {
211        self.rest_rate_limit = requests_per_second;
212        self
213    }
214
215    /// Enables or disables built-in rate limiting entirely (REST requests
216    /// and stream connections). Enabled by default; disable only when you
217    /// provide your own throttling.
218    pub fn rate_limiting(mut self, enabled: bool) -> Self {
219        self.rate_limiting = enabled;
220        self
221    }
222
223    /// Builds the [`Client`].
224    ///
225    /// # Errors
226    ///
227    /// Returns [`Error::Config`] when no token was provided, the token is
228    /// empty, or the HTTP client cannot be constructed.
229    pub fn build(self) -> Result<Client, Error> {
230        let token = match self.token {
231            Some(t) if !t.trim().is_empty() => t,
232            _ => return Err(Error::Config("a non-empty API token is required".into())),
233        };
234        if self.rate_limiting && self.rest_rate_limit == 0 {
235            return Err(Error::Config(
236                "rest_rate_limit must be at least 1 request per second".into(),
237            ));
238        }
239        let http = match self.http {
240            Some(http) => http,
241            None => reqwest::Client::builder()
242                .user_agent(&self.user_agent)
243                .build()
244                .map_err(|e| Error::Config(format!("failed to build HTTP client: {e}")))?,
245        };
246        let (rest_limiter, conn_limiter) = if self.rate_limiting {
247            (
248                Some(RateLimiter::per_second(self.rest_rate_limit)),
249                Some(RateLimiter::per_second(CONNECTIONS_PER_SECOND)),
250            )
251        } else {
252            (None, None)
253        };
254        Ok(Client {
255            inner: Arc::new(Inner {
256                http,
257                rest_base: self.environment.rest_base(),
258                stream_base: self.environment.stream_base(),
259                token,
260                datetime_format: self.datetime_format,
261                rest_limiter,
262                conn_limiter,
263            }),
264        })
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn client_is_send_sync_and_clone() {
274        fn assert_traits<T: Send + Sync + Clone + 'static>() {}
275        assert_traits::<Client>();
276    }
277
278    #[test]
279    fn debug_redacts_token() {
280        let client = Client::new(Environment::Practice, "super-secret");
281        let debug = format!("{client:?}");
282        assert!(!debug.contains("super-secret"));
283        assert!(debug.contains("<redacted>"));
284    }
285
286    #[test]
287    fn builder_requires_token() {
288        assert!(matches!(Client::builder().build(), Err(Error::Config(_))));
289        assert!(matches!(
290            Client::builder().token("  ").build(),
291            Err(Error::Config(_))
292        ));
293    }
294
295    #[test]
296    fn environment_hosts() {
297        assert_eq!(
298            Environment::Practice.rest_base().as_str(),
299            "https://api-fxpractice.oanda.com/v3"
300        );
301        assert_eq!(
302            Environment::Live.stream_base().as_str(),
303            "https://stream-fxtrade.oanda.com/v3"
304        );
305    }
306}