Skip to main content

brokapi_bcs_protocol/
config.rs

1//! Client configuration: the refresh token, the token kind, the timeout and the base URLs.
2
3use std::time::Duration;
4
5use crate::http::client::{HTTP_BASE_URL, WS_BASE_URL};
6
7/// Configuration of the HTTP and WebSocket clients.
8///
9/// The one secret in it is the refresh token, which you issue in the BCS web cabinet (section "API
10/// tokens" of the account). The broker issues two kinds of refresh tokens:
11///
12/// * a read token: market data, portfolio, orders and trades history, no trading,
13/// * a write token: everything the read token does, plus placing, changing and cancelling orders.
14///
15/// A token belongs to one brokerage account and lives 90 days by the broker docs. The token is shown
16/// once, at issue. [`BcsConfig::is_write`] must say which kind you hold, because it picks the
17/// `client_id` of the token request (`trade-api-write` or `trade-api-read`).
18///
19/// The `Debug` output of this struct hides the refresh token (`<redacted>`), so a config can be logged.
20///
21/// # Examples
22///
23/// ```
24/// use brokapi_bcs_protocol::config::BcsConfig;
25///
26/// // A read-only configuration, then a trading one.
27/// let read = BcsConfig::new("refresh-token-of-a-read-token".to_string());
28/// assert!(!read.is_write);
29///
30/// let trade = BcsConfig::new("refresh-token-of-a-write-token".to_string()).with_write(true);
31/// assert!(trade.is_write);
32/// assert_eq!(trade.request_timeout.as_secs(), 30);
33/// ```
34#[derive(Clone)]
35pub struct BcsConfig {
36    /// The long-lived refresh token from the BCS web cabinet.
37    ///
38    /// The HTTP client exchanges it for short-lived access tokens. If the broker returns a new refresh
39    /// token with an access token (rotation), the client keeps the new one in memory only. It does not
40    /// write it back to this struct or to disk, so a restart starts again from the token you supply.
41    pub refresh_token: String,
42    /// Timeout of one REST request, from sending to the end of the body.
43    ///
44    /// Default 30 seconds. It does not apply to WebSocket sessions. A request that times out ends with
45    /// [`BcsApiError::HttpError`](crate::error::BcsApiError::HttpError) and no status. For an order
46    /// placement that is an unknown outcome: ask the broker for the order before you send it again.
47    pub request_timeout: Duration,
48    /// `true` if [`BcsConfig::refresh_token`] is a write (trading) token, `false` for a read token.
49    ///
50    /// It selects `client_id = "trade-api-write"` or `"trade-api-read"` in the token request, as the
51    /// broker docs describe. What the broker does when the flag and the token kind disagree is not
52    /// verified against the live API. A read token cannot place orders.
53    pub is_write: bool,
54    /// Base URL of the REST API, without a trailing slash.
55    ///
56    /// Default is the production host [`HTTP_BASE_URL`]. The path of each endpoint is appended to it.
57    pub http_base_url: String,
58    /// Base URL of the WebSocket API, without a trailing slash.
59    ///
60    /// Default is the production host [`WS_BASE_URL`]. The path of each channel is appended to it.
61    pub ws_base_url: String,
62}
63
64impl std::fmt::Debug for BcsConfig {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("BcsConfig")
67            .field("refresh_token", &"<redacted>")
68            .field("request_timeout", &self.request_timeout)
69            .field("is_write", &self.is_write)
70            .field("http_base_url", &self.http_base_url)
71            .field("ws_base_url", &self.ws_base_url)
72            .finish()
73    }
74}
75
76const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
77
78impl BcsConfig {
79    /// Reads the configuration from the environment (and from a `.env` file, if there is one).
80    ///
81    /// It reads two variables:
82    ///
83    /// * `BCS_REFRESH_TOKEN`: required, the refresh token.
84    /// * `BCS_IS_WRITE`: optional. The exact text `true` means a write token. Any other value, or no
85    ///   variable, means a read token.
86    ///
87    /// A `.env` file in the working directory is loaded first, without overriding variables that are
88    /// already set. Parent directories are not searched (see [`BcsConfig::from_env_file`] for another
89    /// path). The URLs and the timeout are the defaults (production host,
90    /// 30 seconds). No network call is made.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`std::env::VarError::NotPresent`] when `BCS_REFRESH_TOKEN` is not set, and
95    /// [`std::env::VarError::NotUnicode`] when it is not valid Unicode. A wrong or expired token is not
96    /// detected here. It shows up as [`BcsApiError::AuthError`](crate::error::BcsApiError::AuthError)
97    /// at the first request.
98    ///
99    /// # Examples
100    ///
101    /// The example does not need a token: it handles both outcomes, as a program would.
102    ///
103    /// ```
104    /// use brokapi_bcs_protocol::config::BcsConfig;
105    ///
106    /// match BcsConfig::from_env() {
107    ///     Ok(cfg) => assert_eq!(cfg.request_timeout.as_secs(), 30),
108    ///     Err(std::env::VarError::NotPresent) => { /* BCS_REFRESH_TOKEN is not set */ }
109    ///     Err(other) => panic!("unexpected: {other}"),
110    /// }
111    /// ```
112    pub fn from_env() -> Result<Self, std::env::VarError> {
113        // Only `.env` of the working directory: `dotenvy::dotenv` would also look in every parent.
114        dotenvy::from_path(".env").ok();
115        Self::from_vars()
116    }
117
118    /// Like [`BcsConfig::from_env`], but loads the `.env` file at `path` (a file that is not there is
119    /// skipped, variables that are already set are kept).
120    ///
121    /// # Errors
122    ///
123    /// The same as [`BcsConfig::from_env`].
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use brokapi_bcs_protocol::config::BcsConfig;
129    ///
130    /// // No such file: only the environment is read.
131    /// let _ = BcsConfig::from_env_file("/no/such/.env");
132    /// ```
133    pub fn from_env_file(path: impl AsRef<std::path::Path>) -> Result<Self, std::env::VarError> {
134        dotenvy::from_path(path.as_ref()).ok();
135        Self::from_vars()
136    }
137
138    fn from_vars() -> Result<Self, std::env::VarError> {
139        Ok(Self {
140            refresh_token: std::env::var("BCS_REFRESH_TOKEN")?,
141            request_timeout: DEFAULT_REQUEST_TIMEOUT,
142            is_write: std::env::var("BCS_IS_WRITE")
143                .map(|v| v == "true")
144                .unwrap_or(false),
145            http_base_url: HTTP_BASE_URL.to_string(),
146            ws_base_url: WS_BASE_URL.to_string(),
147        })
148    }
149
150    /// Builds a read-only configuration for the production host from a refresh token.
151    ///
152    /// Chain [`BcsConfig::with_write`] if the token is a write token.
153    ///
154    /// # Examples
155    ///
156    /// ```
157    /// use brokapi_bcs_protocol::config::BcsConfig;
158    ///
159    /// let cfg = BcsConfig::new("my-refresh-token".to_string());
160    /// assert_eq!(cfg.refresh_token, "my-refresh-token");
161    /// assert!(!cfg.is_write);
162    /// assert!(cfg.http_base_url.starts_with("https://"));
163    /// assert!(cfg.ws_base_url.starts_with("wss://"));
164    /// ```
165    pub fn new(refresh_token: String) -> Self {
166        Self {
167            refresh_token,
168            request_timeout: DEFAULT_REQUEST_TIMEOUT,
169            is_write: false,
170            http_base_url: HTTP_BASE_URL.to_string(),
171            ws_base_url: WS_BASE_URL.to_string(),
172        }
173    }
174
175    /// Points both the REST and the WebSocket client at a local server, for tests and examples.
176    ///
177    /// `http_base_url` is the base of the local server (for example `http://127.0.0.1:34567`). The
178    /// WebSocket base is derived from it by swapping the scheme (`http` to `ws`, `https` to `wss`).
179    /// The token is only a label for the imitation, it is never sent to the real broker. The result is
180    /// a read configuration, add [`BcsConfig::with_write`] for the trading calls.
181    ///
182    /// # Examples
183    ///
184    /// ```
185    /// use brokapi_bcs_protocol::config::BcsConfig;
186    ///
187    /// let cfg = BcsConfig::for_mock("mock-refresh-token", "http://127.0.0.1:9000").with_write(true);
188    /// assert_eq!(cfg.http_base_url, "http://127.0.0.1:9000");
189    /// assert_eq!(cfg.ws_base_url, "ws://127.0.0.1:9000");
190    /// assert!(cfg.is_write);
191    /// ```
192    pub fn for_mock(refresh_token: impl Into<String>, http_base_url: impl Into<String>) -> Self {
193        let http = http_base_url.into();
194        let ws = http
195            .replace("https://", "wss://")
196            .replace("http://", "ws://");
197        Self {
198            refresh_token: refresh_token.into(),
199            request_timeout: DEFAULT_REQUEST_TIMEOUT,
200            is_write: false,
201            http_base_url: http,
202            ws_base_url: ws,
203        }
204    }
205
206    /// Sets whether the refresh token is a write (trading) token. Returns the changed config.
207    ///
208    /// Pass `true` only when the token really is a write token. See [`BcsConfig::is_write`].
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use brokapi_bcs_protocol::config::BcsConfig;
214    ///
215    /// let cfg = BcsConfig::new("t".to_string()).with_write(true);
216    /// assert!(cfg.is_write);
217    /// ```
218    pub fn with_write(mut self, is_write: bool) -> Self {
219        self.is_write = is_write;
220        self
221    }
222}