brokapi_tbank_protocol/config.rs
1//! Client configuration: endpoint, token, account.
2//!
3//! # Examples
4//!
5//! ```
6//! use std::time::Duration;
7//! use brokapi_tbank_protocol::{TbankConfig, SANDBOX_ENDPOINT};
8//!
9//! let cfg = TbankConfig::sandbox("t.my-token")
10//! .with_account("2000123456")
11//! .with_app_name("me.my-robot")
12//! .with_request_timeout(Duration::from_secs(10));
13//! assert_eq!(cfg.endpoint, SANDBOX_ENDPOINT);
14//! assert!(cfg.sandbox);
15//! // The token never shows in `Debug` output.
16//! assert!(!format!("{cfg:?}").contains("t.my-token"));
17//! ```
18
19use std::fmt;
20use std::time::Duration;
21
22use crate::error::ProtocolError;
23
24/// Production gRPC endpoint.
25pub const PROD_ENDPOINT: &str = "https://invest-public-api.tbank.ru:443";
26/// Sandbox gRPC endpoint (same services as production, virtual accounts).
27pub const SANDBOX_ENDPOINT: &str = "https://sandbox-invest-public-api.tbank.ru:443";
28
29const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
30const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
31
32/// gRPC client configuration. The token is never printed by `Debug`.
33///
34/// One config describes one token and one endpoint. Several accounts of the same token share the
35/// token and differ in [`account_id`](Self::account_id): build one config per account with
36/// [`with_account`](Self::with_account).
37///
38/// # Examples
39///
40/// ```
41/// use brokapi_tbank_protocol::{TbankConfig, PROD_ENDPOINT};
42///
43/// let prod = TbankConfig::production("t.my-token");
44/// assert_eq!(prod.endpoint, PROD_ENDPOINT);
45/// assert!(!prod.sandbox);
46///
47/// // Two accounts of one token: same token, different `account_id`.
48/// let main = prod.clone().with_account("acc-main");
49/// let second = prod.with_account("acc-second");
50/// assert_eq!(main.token, second.token);
51/// assert_ne!(main.account_id, second.account_id);
52/// ```
53#[derive(Clone)]
54pub struct TbankConfig {
55 /// API token, issued in the T-Bank Invest settings (a sandbox token for the sandbox endpoint).
56 ///
57 /// It is sent in the `authorization: Bearer` header of every request. Treat it as a secret:
58 /// keep it out of logs and source control. `Debug` prints `<redacted>` instead of it.
59 pub token: String,
60 /// Full URL, `https://host:443` (TLS) or `http://host:port` (plain, for mocks).
61 pub endpoint: String,
62 /// Sandbox flag. Selects the sandbox endpoint in `new`, and tells the connector the account is virtual.
63 pub sandbox: bool,
64 /// Brokerage account id. When `None` the connector picks the first open account of the token.
65 pub account_id: Option<String>,
66 /// `x-app-name` header (`<github nick>.<repo>` is the T-Bank convention).
67 pub app_name: Option<String>,
68 /// Deadline of one unary call. Default 30 s.
69 ///
70 /// [`TbankClient`](crate::TbankClient) does not apply it. The connectors in `brokapi-tbank` cut
71 /// every call at this deadline. For a command (place, cancel, replace) a timeout means the outcome
72 /// is unknown, because the broker may have acted. See the connector docs.
73 pub request_timeout: Duration,
74 /// How long the channel waits for the TCP and TLS connection to be set up. Default 10 s.
75 ///
76 /// It applies to every (re)connect of the lazy channel. There is no builder method, set the field.
77 pub connect_timeout: Duration,
78 /// An extra root certificate (PEM) the TLS connection trusts besides the bundled web PKI roots. `None`
79 /// by default.
80 ///
81 /// Set it when the broker's endpoint is served with a certificate of a national root (the Russian
82 /// Ministry of Digital Development one) that the bundled roots do not know. Not verified against the live
83 /// endpoint which certificate it presents. See [`with_root_certificate_pem`](Self::with_root_certificate_pem).
84 pub root_certificate_pem: Option<Vec<u8>>,
85}
86
87impl TbankConfig {
88 /// Production or sandbox endpoint chosen by `sandbox`.
89 ///
90 /// Timeouts take their defaults (30 s per request, 10 s to connect). No account is chosen and no
91 /// app name is set.
92 ///
93 /// # Examples
94 ///
95 /// ```
96 /// use brokapi_tbank_protocol::{TbankConfig, PROD_ENDPOINT, SANDBOX_ENDPOINT};
97 ///
98 /// assert_eq!(TbankConfig::new("t.token", true).endpoint, SANDBOX_ENDPOINT);
99 /// assert_eq!(TbankConfig::new("t.token", false).endpoint, PROD_ENDPOINT);
100 /// ```
101 pub fn new(token: impl Into<String>, sandbox: bool) -> Self {
102 Self {
103 token: token.into(),
104 endpoint: if sandbox {
105 SANDBOX_ENDPOINT
106 } else {
107 PROD_ENDPOINT
108 }
109 .to_string(),
110 sandbox,
111 account_id: None,
112 app_name: None,
113 request_timeout: DEFAULT_REQUEST_TIMEOUT,
114 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
115 root_certificate_pem: None,
116 }
117 }
118
119 /// Sandbox endpoint ([`SANDBOX_ENDPOINT`]). Accounts there are virtual. Use a sandbox token.
120 ///
121 /// # Examples
122 ///
123 /// ```
124 /// let cfg = brokapi_tbank_protocol::TbankConfig::sandbox("t.sandbox-token");
125 /// assert!(cfg.sandbox);
126 /// ```
127 pub fn sandbox(token: impl Into<String>) -> Self {
128 Self::new(token, true)
129 }
130
131 /// Production endpoint ([`PROD_ENDPOINT`]). Orders placed through it are real and cost money.
132 ///
133 /// # Examples
134 ///
135 /// ```
136 /// let cfg = brokapi_tbank_protocol::TbankConfig::production("t.token");
137 /// assert!(!cfg.sandbox);
138 /// ```
139 pub fn production(token: impl Into<String>) -> Self {
140 Self::new(token, false)
141 }
142
143 /// Plain-HTTP endpoint of a local mock server (tests and examples).
144 ///
145 /// `endpoint` is a full URL such as `http://127.0.0.1:50051`. The config is marked as sandbox, so
146 /// a connector built from it reports `sandbox: true` in its capabilities. Do not use it with a
147 /// real token.
148 ///
149 /// # Examples
150 ///
151 /// ```
152 /// # use brokapi_mocks::tbank::{MockServer, TOKEN};
153 /// use brokapi_tbank_protocol::TbankConfig;
154 ///
155 /// # #[tokio::main(flavor = "current_thread")]
156 /// # async fn main() {
157 /// # let server = MockServer::start(Default::default()).await;
158 /// let cfg = TbankConfig::for_mock(TOKEN, &server.endpoint);
159 /// assert!(cfg.endpoint.starts_with("http://"));
160 /// assert!(cfg.sandbox);
161 /// # }
162 /// ```
163 pub fn for_mock(token: impl Into<String>, endpoint: impl Into<String>) -> Self {
164 Self {
165 endpoint: endpoint.into(),
166 ..Self::new(token, true)
167 }
168 }
169
170 /// Reads the config from the environment. Loads a `.env` file of the **working directory** first, if
171 /// there is one (parent directories are not searched, see [`from_env_file`](Self::from_env_file) for
172 /// another path).
173 ///
174 /// Variables: `TBANK_TOKEN` (required), `TBANK_SANDBOX` (`true`, `1` or `yes`, default `false`, so
175 /// production), `TBANK_ENDPOINT` (overrides the endpoint when not empty, must start with
176 /// `https://`), `TBANK_ACCOUNT_ID` and `TBANK_APP_NAME` (both optional, empty means not set).
177 ///
178 /// Note the default: without `TBANK_SANDBOX` the config points at production.
179 ///
180 /// # Errors
181 ///
182 /// [`ProtocolError::Config`] when `TBANK_TOKEN` is not set or is not valid unicode, or when
183 /// `TBANK_ENDPOINT` is set and is not an `https://` address (a token must not go over plain text
184 /// because of a stray variable). The address of a local imitation is set in code, with
185 /// [`for_mock`](Self::for_mock).
186 ///
187 /// # Examples
188 ///
189 /// ```
190 /// use brokapi_tbank_protocol::{TbankConfig, SANDBOX_ENDPOINT};
191 ///
192 /// # // Doctests run in their own process, so setting the environment here is contained.
193 /// # unsafe {
194 /// # std::env::set_var("TBANK_TOKEN", "t.my-token");
195 /// # std::env::set_var("TBANK_SANDBOX", "true");
196 /// # std::env::set_var("TBANK_ACCOUNT_ID", "acc-main");
197 /// # std::env::remove_var("TBANK_ENDPOINT");
198 /// # std::env::remove_var("TBANK_APP_NAME");
199 /// # }
200 /// let cfg = TbankConfig::from_env()?;
201 /// assert_eq!(cfg.endpoint, SANDBOX_ENDPOINT);
202 /// assert_eq!(cfg.account_id.as_deref(), Some("acc-main"));
203 ///
204 /// // A plain-text endpoint in the environment is refused.
205 /// # unsafe { std::env::set_var("TBANK_ENDPOINT", "http://evil.example:80") };
206 /// assert!(TbankConfig::from_env().is_err());
207 /// # unsafe { std::env::remove_var("TBANK_ENDPOINT") };
208 ///
209 /// # unsafe { std::env::remove_var("TBANK_TOKEN") };
210 /// // Without a token the call fails, it never falls back to a default.
211 /// assert!(TbankConfig::from_env().is_err());
212 /// # Ok::<(), brokapi_tbank_protocol::ProtocolError>(())
213 /// ```
214 pub fn from_env() -> Result<Self, ProtocolError> {
215 // Only `.env` of the working directory: `dotenvy::dotenv` would also look in every parent.
216 dotenvy::from_path(".env").ok();
217 Self::from_vars()
218 }
219
220 /// Like [`from_env`](Self::from_env), but loads the `.env` file at `path` (a file that is not there
221 /// is skipped, variables that are already set are kept).
222 ///
223 /// # Errors
224 ///
225 /// As [`from_env`](Self::from_env).
226 ///
227 /// # Examples
228 ///
229 /// ```
230 /// use brokapi_tbank_protocol::TbankConfig;
231 ///
232 /// // No such file: only the environment is read, and it has no token here.
233 /// # unsafe { std::env::remove_var("TBANK_TOKEN") };
234 /// assert!(TbankConfig::from_env_file("/no/such/.env").is_err());
235 /// ```
236 pub fn from_env_file(path: impl AsRef<std::path::Path>) -> Result<Self, ProtocolError> {
237 dotenvy::from_path(path.as_ref()).ok();
238 Self::from_vars()
239 }
240
241 fn from_vars() -> Result<Self, ProtocolError> {
242 let token = std::env::var("TBANK_TOKEN")
243 .map_err(|_| ProtocolError::Config("TBANK_TOKEN is not set".into()))?;
244 let sandbox = std::env::var("TBANK_SANDBOX")
245 .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes"))
246 .unwrap_or(false);
247 let mut cfg = Self::new(token, sandbox);
248 if let Ok(endpoint) = std::env::var("TBANK_ENDPOINT")
249 && !endpoint.trim().is_empty()
250 {
251 if !endpoint.trim().starts_with("https://") {
252 return Err(ProtocolError::Config(
253 "TBANK_ENDPOINT must start with https://".into(),
254 ));
255 }
256 cfg.endpoint = endpoint.trim().to_string();
257 }
258 cfg.account_id = std::env::var("TBANK_ACCOUNT_ID")
259 .ok()
260 .filter(|s| !s.trim().is_empty());
261 cfg.app_name = std::env::var("TBANK_APP_NAME")
262 .ok()
263 .filter(|s| !s.trim().is_empty());
264 Ok(cfg)
265 }
266
267 /// Pins the brokerage account this config works with.
268 ///
269 /// A user data connector trades on exactly this account. Without it the connector takes the
270 /// token's only open account and refuses to start when there are several. Use
271 /// [`TbankClient::list_accounts`](crate::TbankClient::list_accounts) to see the ids.
272 ///
273 /// # Examples
274 ///
275 /// ```
276 /// let cfg = brokapi_tbank_protocol::TbankConfig::sandbox("t.token").with_account("acc-main");
277 /// assert_eq!(cfg.account_id.as_deref(), Some("acc-main"));
278 /// ```
279 pub fn with_account(mut self, account_id: impl Into<String>) -> Self {
280 self.account_id = Some(account_id.into());
281 self
282 }
283
284 /// Sets the `x-app-name` header sent with every request.
285 ///
286 /// The T-Bank convention is `<github nick>.<repo>`. The name must be a valid header value,
287 /// otherwise [`TbankClient::new`](crate::TbankClient::new) returns
288 /// [`ProtocolError::Config`].
289 ///
290 /// # Examples
291 ///
292 /// ```
293 /// let cfg = brokapi_tbank_protocol::TbankConfig::sandbox("t.token").with_app_name("me.my-robot");
294 /// assert_eq!(cfg.app_name.as_deref(), Some("me.my-robot"));
295 /// ```
296 pub fn with_app_name(mut self, app_name: impl Into<String>) -> Self {
297 self.app_name = Some(app_name.into());
298 self
299 }
300
301 /// Trusts one more root certificate for the TLS connection, in PEM form (the text between
302 /// `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`).
303 ///
304 /// The bundled web PKI roots stay trusted. Use it when the endpoint's certificate chains to a root
305 /// they do not contain (for example the national root certificate). [`TbankClient::new`](crate::TbankClient::new)
306 /// refuses a PEM text that holds no certificate.
307 ///
308 /// # Examples
309 ///
310 /// ```
311 /// let cfg = brokapi_tbank_protocol::TbankConfig::production("t.token")
312 /// .with_root_certificate_pem(b"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n".to_vec());
313 /// assert!(cfg.root_certificate_pem.is_some());
314 /// ```
315 pub fn with_root_certificate_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
316 self.root_certificate_pem = Some(pem.into());
317 self
318 }
319
320 /// Sets the deadline of one unary call, see [`request_timeout`](Self::request_timeout).
321 ///
322 /// # Examples
323 ///
324 /// ```
325 /// use std::time::Duration;
326 ///
327 /// let cfg = brokapi_tbank_protocol::TbankConfig::sandbox("t.token").with_request_timeout(Duration::from_secs(5));
328 /// assert_eq!(cfg.request_timeout, Duration::from_secs(5));
329 /// ```
330 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
331 self.request_timeout = timeout;
332 self
333 }
334}
335
336impl fmt::Debug for TbankConfig {
337 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338 f.debug_struct("TbankConfig")
339 .field("token", &"<redacted>")
340 .field("endpoint", &self.endpoint)
341 .field("sandbox", &self.sandbox)
342 .field("account_id", &self.account_id)
343 .field("app_name", &self.app_name)
344 .field("request_timeout", &self.request_timeout)
345 .field("root_certificate", &self.root_certificate_pem.is_some())
346 .finish()
347 }
348}