1use 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
12const DEFAULT_REST_RATE_LIMIT: u32 = 100;
15const CONNECTIONS_PER_SECOND: u32 = 2;
17
18#[derive(Debug, Clone)]
20#[non_exhaustive]
21pub enum Environment {
22 Practice,
25 Live,
28 Custom {
31 rest: Url,
33 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#[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 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 pub fn builder() -> ClientBuilder {
119 ClientBuilder::default()
120 }
121
122 pub fn datetime_format(&self) -> AcceptDatetimeFormat {
125 self.inner.datetime_format
126 }
127}
128
129#[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 pub fn environment(mut self, environment: Environment) -> Self {
171 self.environment = environment;
172 self
173 }
174
175 pub fn token(mut self, token: impl Into<String>) -> Self {
178 self.token = Some(token.into());
179 self
180 }
181
182 pub fn datetime_format(mut self, format: AcceptDatetimeFormat) -> Self {
186 self.datetime_format = format;
187 self
188 }
189
190 pub fn http_client(mut self, http: reqwest::Client) -> Self {
193 self.http = Some(http);
194 self
195 }
196
197 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
200 self.user_agent = user_agent.into();
201 self
202 }
203
204 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 pub fn rate_limiting(mut self, enabled: bool) -> Self {
219 self.rate_limiting = enabled;
220 self
221 }
222
223 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}