Skip to main content

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}