Skip to main content

brokapi_alor_protocol/
config.rs

1//! Client configuration: the refresh token, the contour, the timeout and the base URLs.
2
3use std::time::Duration;
4
5/// The production contour: real exchanges, real money. Orders go to the exchange.
6pub const PROD_HTTP_BASE_URL: &str = "https://api.alor.ru";
7/// The authorization server of the production contour (the access token is issued here).
8pub const PROD_AUTH_BASE_URL: &str = "https://oauth.alor.ru";
9/// The WebSocket host of the production contour.
10pub const PROD_WS_BASE_URL: &str = "wss://api.alor.ru";
11/// The test contour: simulated data and a test account, no effect on the market.
12pub const TEST_HTTP_BASE_URL: &str = "https://apidev.alor.ru";
13/// The authorization server of the test contour.
14pub const TEST_AUTH_BASE_URL: &str = "https://oauthdev.alor.ru";
15/// The WebSocket host of the test contour.
16pub const TEST_WS_BASE_URL: &str = "wss://apidev.alor.ru";
17
18/// Which contour of the broker a client talks to. The two are isolated: a token of one is useless in the other.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum AlorEnv {
21    /// The production contour (real money).
22    Production,
23    /// The test contour (a test account on request from the broker, data is simulated).
24    Test,
25}
26
27/// Configuration of the HTTP and WebSocket clients.
28///
29/// The one secret in it is the refresh token, which you issue on the developer portal after binding a trade
30/// account (it lives a year in the production contour and a month in the test one). The broker has no
31/// separate read and trade tokens: the access token made from it reaches everything the account may do.
32///
33/// The `Debug` output hides the refresh token (`<redacted>`), so a config can be logged.
34///
35/// # Examples
36///
37/// ```
38/// use brokapi_alor_protocol::config::{AlorConfig, AlorEnv};
39///
40/// let cfg = AlorConfig::new("my-refresh-token".to_string()).with_portfolio("D12345");
41/// assert_eq!(cfg.env, AlorEnv::Production);
42/// assert_eq!(cfg.portfolio.as_deref(), Some("D12345"));
43/// assert_eq!(cfg.exchange, "MOEX");
44/// assert_eq!(cfg.request_timeout.as_secs(), 30);
45///
46/// // The test contour switches all three addresses at once.
47/// let test = cfg.with_env(AlorEnv::Test);
48/// assert_eq!(test.http_base_url, "https://apidev.alor.ru");
49/// assert_eq!(test.auth_base_url, "https://oauthdev.alor.ru");
50/// ```
51#[derive(Clone)]
52pub struct AlorConfig {
53    /// The long-lived refresh token from the developer portal.
54    pub refresh_token: String,
55    /// Timeout of one REST request, from sending to the end of the body (default 30 seconds). A request
56    /// that times out ends with [`AlorApiError::Http`](crate::error::AlorApiError::Http) and no status.
57    /// For an order command that is an unknown outcome: ask the broker before you send it again.
58    pub request_timeout: Duration,
59    /// The contour. Set by [`AlorConfig::with_env`], which also sets the three addresses below.
60    pub env: AlorEnv,
61    /// Base URL of the REST API, without a trailing slash.
62    pub http_base_url: String,
63    /// Base URL of the authorization server, without a trailing slash.
64    pub auth_base_url: String,
65    /// Base URL of the WebSocket API, without a trailing slash. The channels are `/ws` and `/cws`.
66    pub ws_base_url: String,
67    /// The portfolio the user data connector works with (for example `D12345`). If it is empty, the connector
68    /// takes the only portfolio named in the access token, and refuses to start when the token names several.
69    pub portfolio: Option<String>,
70    /// The exchange of the portfolio: `MOEX` (default) or `SPBX`. Orders, positions and trades are read per
71    /// exchange, so one connector works with one.
72    pub exchange: String,
73}
74
75impl std::fmt::Debug for AlorConfig {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("AlorConfig")
78            .field("refresh_token", &"<redacted>")
79            .field("request_timeout", &self.request_timeout)
80            .field("env", &self.env)
81            .field("http_base_url", &self.http_base_url)
82            .field("auth_base_url", &self.auth_base_url)
83            .field("ws_base_url", &self.ws_base_url)
84            .field("portfolio", &self.portfolio)
85            .field("exchange", &self.exchange)
86            .finish()
87    }
88}
89
90const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
91
92impl AlorConfig {
93    /// A configuration for the production contour from a refresh token.
94    ///
95    /// # Examples
96    ///
97    /// ```
98    /// use brokapi_alor_protocol::config::AlorConfig;
99    ///
100    /// let cfg = AlorConfig::new("t".to_string());
101    /// assert!(cfg.http_base_url.starts_with("https://"));
102    /// assert!(cfg.ws_base_url.starts_with("wss://"));
103    /// ```
104    pub fn new(refresh_token: String) -> Self {
105        Self {
106            refresh_token,
107            request_timeout: DEFAULT_REQUEST_TIMEOUT,
108            env: AlorEnv::Production,
109            http_base_url: PROD_HTTP_BASE_URL.to_string(),
110            auth_base_url: PROD_AUTH_BASE_URL.to_string(),
111            ws_base_url: PROD_WS_BASE_URL.to_string(),
112            portfolio: None,
113            exchange: "MOEX".to_string(),
114        }
115    }
116
117    /// Reads the configuration from the environment (and from a `.env` file of the working directory, if
118    /// there is one; variables that are already set win).
119    ///
120    /// * `ALOR_REFRESH_TOKEN`: required.
121    /// * `ALOR_PORTFOLIO`: optional, see [`AlorConfig::portfolio`].
122    /// * `ALOR_EXCHANGE`: optional, `MOEX` (default) or `SPBX`.
123    /// * `ALOR_ENV`: optional, the exact text `test` selects the test contour.
124    ///
125    /// No network call is made.
126    ///
127    /// # Errors
128    ///
129    /// [`std::env::VarError::NotPresent`] when `ALOR_REFRESH_TOKEN` is not set. A wrong or expired token shows
130    /// up as [`AlorApiError::Auth`](crate::error::AlorApiError::Auth) at the first request.
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// use brokapi_alor_protocol::config::AlorConfig;
136    ///
137    /// match AlorConfig::from_env() {
138    ///     Ok(cfg) => assert_eq!(cfg.request_timeout.as_secs(), 30),
139    ///     Err(std::env::VarError::NotPresent) => { /* ALOR_REFRESH_TOKEN is not set */ }
140    ///     Err(other) => panic!("unexpected: {other}"),
141    /// }
142    /// ```
143    pub fn from_env() -> Result<Self, std::env::VarError> {
144        dotenvy::from_path(".env").ok();
145        Self::from_vars()
146    }
147
148    /// Like [`AlorConfig::from_env`], but loads the `.env` file at `path` (a file that is not there is skipped).
149    ///
150    /// # Errors
151    ///
152    /// The same as [`AlorConfig::from_env`].
153    pub fn from_env_file(path: impl AsRef<std::path::Path>) -> Result<Self, std::env::VarError> {
154        dotenvy::from_path(path.as_ref()).ok();
155        Self::from_vars()
156    }
157
158    fn from_vars() -> Result<Self, std::env::VarError> {
159        let mut cfg = Self::new(std::env::var("ALOR_REFRESH_TOKEN")?);
160        cfg.portfolio = std::env::var("ALOR_PORTFOLIO")
161            .ok()
162            .filter(|p| !p.is_empty());
163        if let Ok(ex) = std::env::var("ALOR_EXCHANGE")
164            && !ex.is_empty()
165        {
166            cfg.exchange = ex;
167        }
168        if std::env::var("ALOR_ENV").is_ok_and(|v| v == "test") {
169            cfg = cfg.with_env(AlorEnv::Test);
170        }
171        Ok(cfg)
172    }
173
174    /// Points all three addresses at the production or the test contour. Returns the changed config.
175    pub fn with_env(mut self, env: AlorEnv) -> Self {
176        let (http, auth, ws) = match env {
177            AlorEnv::Production => (PROD_HTTP_BASE_URL, PROD_AUTH_BASE_URL, PROD_WS_BASE_URL),
178            AlorEnv::Test => (TEST_HTTP_BASE_URL, TEST_AUTH_BASE_URL, TEST_WS_BASE_URL),
179        };
180        self.env = env;
181        self.http_base_url = http.to_string();
182        self.auth_base_url = auth.to_string();
183        self.ws_base_url = ws.to_string();
184        self
185    }
186
187    /// Sets the portfolio (see [`AlorConfig::portfolio`]). Returns the changed config.
188    pub fn with_portfolio(mut self, portfolio: impl Into<String>) -> Self {
189        self.portfolio = Some(portfolio.into());
190        self
191    }
192
193    /// Sets the exchange, `MOEX` or `SPBX` (see [`AlorConfig::exchange`]). Returns the changed config.
194    pub fn with_exchange(mut self, exchange: impl Into<String>) -> Self {
195        self.exchange = exchange.into();
196        self
197    }
198
199    /// Points the REST, authorization and WebSocket clients at one local server, for tests and examples.
200    ///
201    /// `http_base_url` is the base of the local server (for example `http://127.0.0.1:34567`). The
202    /// authorization server is the same host, and the WebSocket base is derived by swapping the scheme. The token
203    /// is only a label for the imitation, it is never sent to the real broker. The portfolio is `D00001`.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use brokapi_alor_protocol::config::AlorConfig;
209    ///
210    /// let cfg = AlorConfig::for_mock("mock-refresh-token", "http://127.0.0.1:9000");
211    /// assert_eq!(cfg.auth_base_url, "http://127.0.0.1:9000");
212    /// assert_eq!(cfg.ws_base_url, "ws://127.0.0.1:9000");
213    /// ```
214    pub fn for_mock(refresh_token: impl Into<String>, http_base_url: impl Into<String>) -> Self {
215        let http = http_base_url.into();
216        let ws = http
217            .replace("https://", "wss://")
218            .replace("http://", "ws://");
219        let mut cfg = Self::new(refresh_token.into());
220        cfg.auth_base_url = http.clone();
221        cfg.http_base_url = http;
222        cfg.ws_base_url = ws;
223        cfg.portfolio = Some("D00001".to_string());
224        cfg
225    }
226}