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}