Skip to main content

brokapi_bcs_protocol/http/
client.rs

1//! The authorized HTTP client: token handling, the 401 retry and the mapping of error replies.
2//!
3//! The methods in this file are generic ([`BcsHttpClient::get`], [`BcsHttpClient::post`] and so on).
4//! The endpoint-specific methods (`get_order_book`, `create_order`, ...) are in [`super::endpoints`].
5
6use reqwest::Client;
7use std::sync::Arc;
8use std::time::Duration;
9use tokio::sync::{Mutex, RwLock};
10use tracing::{debug, error, warn};
11
12use crate::config::BcsConfig;
13use crate::error::{ApiErrorResponse, AuthErrorResponse, BcsApiError};
14use crate::http::models::{TokenRequest, TokenResponse};
15
16/// Path of the token endpoint on the REST host (`POST`, form-encoded).
17///
18/// The client calls it to trade the refresh token for an access token.
19pub const AUTH_TOKEN_PATH: &str =
20    "/trade-api-keycloak/realms/tradeapi/protocol/openid-connect/token";
21/// Production base URL of the REST API. [`BcsConfig::new`] and [`BcsConfig::from_env`] use it.
22pub const HTTP_BASE_URL: &str = "https://be.broker.ru";
23/// Production base URL of the WebSocket API. [`BcsConfig::new`] and [`BcsConfig::from_env`] use it.
24pub const WS_BASE_URL: &str = "wss://ws.broker.ru";
25/// A request timeout of 30 seconds.
26///
27/// The client does not read this constant. The timeout of a client is
28/// [`BcsConfig::request_timeout`], whose default has the same value.
29pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
30
31/// The access token and the moment after which the client does not trust it any more.
32#[derive(Clone)]
33struct CachedToken {
34    token: String,
35    /// 90% of `expires_in` after the token was issued.
36    valid_until: Option<tokio::time::Instant>,
37}
38
39/// The authorized HTTP client of the BCS Trade API.
40///
41/// It holds the refresh token and the current access token, adds `Authorization: Bearer ...` to every
42/// call, and handles the token life cycle for you:
43///
44/// * The first call trades the refresh token for an access token. The access token is cached.
45/// * The client tracks the expiry time: a token past 90% of its `expires_in` is renewed before the next
46///   call.
47/// * When the broker answers 401 to a **read**, the client gets a new access token and repeats the
48///   **same request once**. If the second answer is 401 again, you get the error of that answer.
49/// * A **command** (placing, changing or cancelling an order) is never repeated, not even after a 401:
50///   the client renews the token for the next call and returns the 401 as an error, see
51///   [`BcsHttpClient::post_command`].
52/// * A transport error or a timeout is never repeated. This matters for commands: see
53///   [`BcsApiError::HttpError`].
54/// * If the token service returns a new refresh token (rotation), the client keeps it in memory. See
55///   [`BcsHttpClient::current_refresh_token`].
56///
57/// The client is cheap to clone. Clones share the tokens and the connection pool. It can be used from
58/// many tasks at once.
59///
60/// The endpoint methods (one per REST call) are documented on this type too, they are in
61/// [`super::endpoints`]. The broker limits calls per account (about 10 per second for most services,
62/// 3 per second for non-trade operations, per the broker docs). The client does not throttle, see
63/// [`crate::utils::rate_limiter::RateLimiter`].
64///
65/// # Examples
66///
67/// ```
68/// # #[tokio::main(flavor = "current_thread")]
69/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
70/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
71/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
72/// use brokapi_bcs_protocol::http::client::BcsHttpClient;
73/// use brokapi_bcs_protocol::http::models::OrderBookRequest;
74///
75/// let client = BcsHttpClient::new(cfg)?;
76/// let book = client
77///     .get_order_book(&OrderBookRequest { ticker: "SBER".into(), class_code: "TQBR".into(), depth: 5 })
78///     .await?;
79/// println!("{} levels of bids", book.bids.len());
80/// # Ok(())
81/// # }
82/// ```
83#[derive(Clone)]
84pub struct BcsHttpClient {
85    client: Client,
86    access_token: Arc<RwLock<Option<CachedToken>>>,
87    refresh_token: Arc<RwLock<String>>,
88    refresh_mutex: Arc<Mutex<()>>,
89    client_id: String,
90    http_base_url: Arc<str>,
91    ws_base_url: Arc<str>,
92}
93
94impl BcsHttpClient {
95    /// Creates a client from a configuration. No network call is made.
96    ///
97    /// The HTTP connection pool is created with the timeout of [`BcsConfig::request_timeout`] and
98    /// gzip enabled. The `client_id` of later token requests is `trade-api-write` when
99    /// [`BcsConfig::is_write`] is set, otherwise `trade-api-read`.
100    ///
101    /// # Errors
102    ///
103    /// [`BcsApiError::HttpError`] (no status) if the HTTP stack cannot be built, for example when the
104    /// TLS backend fails to initialise.
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// use brokapi_bcs_protocol::config::BcsConfig;
110    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
111    ///
112    /// let client = BcsHttpClient::new(BcsConfig::new("refresh-token".to_string()))?;
113    /// assert_eq!(client.base_url(), "https://be.broker.ru");
114    /// # Ok::<(), brokapi_bcs_protocol::error::BcsApiError>(())
115    /// ```
116    pub fn new(config: BcsConfig) -> Result<Self, BcsApiError> {
117        let client = Client::builder()
118            .timeout(config.request_timeout)
119            .gzip(true)
120            .build()?;
121
122        let refresh_token = config.refresh_token.clone();
123        let client_id = if config.is_write {
124            "trade-api-write".to_string()
125        } else {
126            "trade-api-read".to_string()
127        };
128        Ok(Self {
129            client,
130            access_token: Arc::new(RwLock::new(None)),
131            refresh_token: Arc::new(RwLock::new(refresh_token)),
132            refresh_mutex: Arc::new(Mutex::new(())),
133            client_id,
134            http_base_url: Arc::from(config.http_base_url.as_str()),
135            ws_base_url: Arc::from(config.ws_base_url.as_str()),
136        })
137    }
138
139    /// Returns the cached access token, or gets a new one if there is none yet.
140    ///
141    /// A token that is past 90% of its `expires_in` is renewed here, before it is used, so a command does
142    /// not meet a 401 after a day of work. A 401 on a read is still repaired by one refresh and one
143    /// repeat. The WebSocket session calls this at every (re)connect, and forces a refresh itself when
144    /// the broker refuses the connection.
145    ///
146    /// # Errors
147    ///
148    /// As [`BcsHttpClient::refresh_access_token`], and only when there is no cached token or it is
149    /// (nearly) expired.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// # #[tokio::main(flavor = "current_thread")]
155    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
156    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
157    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
158    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
159    ///
160    /// let client = BcsHttpClient::new(cfg)?;
161    /// let first = client.get_access_token().await?;
162    /// let second = client.get_access_token().await?; // from the cache, no second token request
163    /// assert_eq!(first, second);
164    /// # Ok(())
165    /// # }
166    /// ```
167    pub async fn get_access_token(&self) -> Result<String, BcsApiError> {
168        {
169            let cached = self.access_token.read().await;
170            if let Some(c) = cached.as_ref()
171                && c.valid_until
172                    .is_none_or(|until| tokio::time::Instant::now() < until)
173            {
174                return Ok(c.token.clone());
175            }
176        }
177        self.refresh_access_token().await
178    }
179
180    /// Trades the refresh token for a new access token and stores it. Always calls the token service.
181    ///
182    /// Sends `grant_type=refresh_token`, the refresh token and the `client_id` as a form to
183    /// [`AUTH_TOKEN_PATH`]. On success the new access token is cached. If the reply carries a new
184    /// refresh token it replaces the old one in memory.
185    ///
186    /// Refreshes are serialised by a lock, so two tasks never talk to the token service at the same
187    /// moment. The lock does not merge them: each caller still makes its own request, one after the
188    /// other.
189    ///
190    /// # Errors
191    ///
192    /// * [`BcsApiError::AuthError`]: the token service refused the refresh token. `message` is its
193    ///   `error_description` (or `error`).
194    /// * [`BcsApiError::HttpError`]: a transport failure, an unreadable success body, or an error
195    ///   status whose body is not the token service's JSON error.
196    ///
197    /// # Examples
198    ///
199    /// ```
200    /// # #[tokio::main(flavor = "current_thread")]
201    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
202    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
203    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
204    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
205    ///
206    /// let client = BcsHttpClient::new(cfg)?;
207    /// let token = client.refresh_access_token().await?;
208    /// assert!(!token.is_empty());
209    /// # Ok(())
210    /// # }
211    /// ```
212    pub async fn refresh_access_token(&self) -> Result<String, BcsApiError> {
213        let _guard = self.refresh_mutex.lock().await; // только один refresh одновременно!
214
215        let refresh = {
216            let rt = self.refresh_token.read().await;
217            rt.clone()
218        };
219
220        let url = format!("{}{}", self.http_base_url, AUTH_TOKEN_PATH);
221        let form = TokenRequest {
222            grant_type: "refresh_token".to_string(),
223            refresh_token: refresh,
224            client_id: self.client_id.clone(),
225        };
226
227        debug!("Refreshing access + refresh token...");
228
229        let resp = self.client.post(&url).form(&form).send().await?;
230
231        if resp.status().is_success() {
232            let token_resp: TokenResponse = resp.json().await?;
233            let access = token_resp.access_token.clone();
234
235            // Обновляем refresh_token если пришел новый (Keycloak rotation)
236            if let Some(new_refresh) = token_resp.refresh_token {
237                {
238                    let mut rt = self.refresh_token.write().await;
239                    *rt = new_refresh;
240                }
241                debug!("✅ New refresh_token received and saved from Keycloak");
242            } else {
243                debug!("Server did not return new refresh_token (keeping current)");
244            }
245
246            {
247                let mut at = self.access_token.write().await;
248                let lifetime = Duration::from_secs(token_resp.expires_in).mul_f64(0.9);
249                *at = Some(CachedToken {
250                    token: access.clone(),
251                    valid_until: Some(tokio::time::Instant::now() + lifetime),
252                });
253            }
254
255            debug!("Access token refreshed successfully");
256            Ok(access)
257        } else {
258            let status = resp.status().as_u16();
259            let body = resp.text().await.unwrap_or_default();
260            error!("Token refresh failed: {} {}", status, body);
261
262            if let Ok(err) = serde_json::from_str::<AuthErrorResponse>(&body) {
263                Err(BcsApiError::AuthError {
264                    message: err.error_description.unwrap_or(err.error),
265                })
266            } else {
267                Err(BcsApiError::HttpError {
268                    status: Some(status),
269                    message: body,
270                    trace_id: None,
271                })
272            }
273        }
274    }
275
276    /// `GET`s a path and parses the JSON reply into `T`.
277    ///
278    /// `path` starts with `/` and is added to the base URL, for example
279    /// `/trade-api-bff-portfolio/api/v1/portfolio`. Use it for an endpoint that has no typed method.
280    /// The typed methods of [`super::endpoints`] call it.
281    ///
282    /// A 401 is answered with one token refresh and one repeat. Other failures are not retried.
283    ///
284    /// # Errors
285    ///
286    /// * [`BcsApiError::ValidationError`] and [`BcsApiError::NotFound`]: the broker said
287    ///   `VALIDATION_ERROR` or `NOT_FOUND` in its JSON error body.
288    /// * [`BcsApiError::RateLimitExceeded`]: HTTP 429 without the JSON error body.
289    /// * [`BcsApiError::HttpError`]: any other non-success status, or a transport failure.
290    /// * [`BcsApiError::ParseError`]: the reply is a success but is not the JSON that `T` expects.
291    /// * [`BcsApiError::AuthError`]: the token refresh after a 401 failed.
292    ///
293    /// # Examples
294    ///
295    /// ```
296    /// # #[tokio::main(flavor = "current_thread")]
297    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
298    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
299    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
300    /// use brokapi_bcs_protocol::error::BcsApiError;
301    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
302    ///
303    /// let client = BcsHttpClient::new(cfg)?;
304    /// let portfolio: serde_json::Value = client.get("/trade-api-bff-portfolio/api/v1/portfolio").await?;
305    /// assert!(portfolio.is_array());
306    ///
307    /// // A path the broker does not know ends in an HttpError that carries the status.
308    /// let err = client.get::<serde_json::Value>("/no/such/path").await.unwrap_err();
309    /// assert!(matches!(err, BcsApiError::HttpError { status: Some(404), .. }));
310    /// # Ok(())
311    /// # }
312    /// ```
313    pub async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, BcsApiError> {
314        self.request_with_retry(reqwest::Method::GET, path, None::<&()>)
315            .await
316    }
317
318    /// `GET`s a path with query parameters and parses the JSON reply into `T`.
319    ///
320    /// `query` is anything `serde` can turn into a URL query: a struct, a map, or a slice of pairs.
321    /// Retry and errors are the same as for [`BcsHttpClient::get`].
322    ///
323    /// # Errors
324    ///
325    /// The same as [`BcsHttpClient::get`].
326    ///
327    /// # Examples
328    ///
329    /// ```
330    /// # #[tokio::main(flavor = "current_thread")]
331    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
332    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
333    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
334    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
335    ///
336    /// let client = BcsHttpClient::new(cfg)?;
337    /// let book: serde_json::Value = client
338    ///     .get_with_query(
339    ///         "/trade-api-market-data-connector/api/v1/order-book",
340    ///         &[("ticker", "SBER"), ("classCode", "TQBR"), ("depth", "5")],
341    ///     )
342    ///     .await?;
343    /// assert_eq!(book["ticker"], "SBER");
344    /// # Ok(())
345    /// # }
346    /// ```
347    pub async fn get_with_query<T, Q>(&self, path: &str, query: &Q) -> Result<T, BcsApiError>
348    where
349        T: serde::de::DeserializeOwned,
350        Q: serde::Serialize,
351    {
352        let url = format!("{}{}", self.http_base_url, path);
353        let token = self.get_access_token().await?;
354
355        let resp = self
356            .client
357            .get(&url)
358            .bearer_auth(&token)
359            .query(query)
360            .send()
361            .await?;
362
363        if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
364            warn!("Got 401, refreshing token and retrying...");
365            let new_token = self.refresh_access_token().await?;
366            let resp = self
367                .client
368                .get(&url)
369                .bearer_auth(&new_token)
370                .query(query)
371                .send()
372                .await?;
373            self.handle_response(resp).await
374        } else {
375            self.handle_response(resp).await
376        }
377    }
378
379    /// `GET`s a path and returns the status and the body text, without parsing and without mapping
380    /// errors.
381    ///
382    /// An error status is **not** an `Err` here: you get it as the first element of the pair. Use it
383    /// to look at what the broker really sent. Only a 401 is handled (one refresh, one repeat).
384    ///
385    /// # Errors
386    ///
387    /// [`BcsApiError::HttpError`] (no status) on a transport failure, and the errors of
388    /// [`BcsHttpClient::refresh_access_token`] when a 401 needs a new token.
389    ///
390    /// # Examples
391    ///
392    /// ```
393    /// # #[tokio::main(flavor = "current_thread")]
394    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
395    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
396    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
397    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
398    ///
399    /// let client = BcsHttpClient::new(cfg)?;
400    /// let (status, body) = client
401    ///     .get_raw("/trade-api-market-data-connector/api/v1/order-book?ticker=SBER&classCode=TQBR")
402    ///     .await?;
403    /// assert!(status.is_success());
404    /// assert!(body.contains("bids"));
405    /// # Ok(())
406    /// # }
407    /// ```
408    pub async fn get_raw(&self, path: &str) -> Result<(reqwest::StatusCode, String), BcsApiError> {
409        let url = format!("{}{}", self.http_base_url, path);
410        let token = self.get_access_token().await?;
411
412        let resp = self.client.get(&url).bearer_auth(&token).send().await?;
413
414        let status = resp.status();
415
416        // 401 - обновляем токен и пробуем снова
417        if status == reqwest::StatusCode::UNAUTHORIZED {
418            warn!("Got 401, refreshing token and retrying...");
419            let new_token = self.refresh_access_token().await?;
420            let resp = self.client.get(&url).bearer_auth(&new_token).send().await?;
421
422            let status = resp.status();
423            let body = resp.text().await?;
424            return Ok((status, body));
425        }
426
427        let body = resp.text().await?;
428        Ok((status, body))
429    }
430
431    /// `POST`s a JSON body to a path and parses the JSON reply into `T`.
432    ///
433    /// A 401 is answered with one token refresh and **one repeat of the same body**. Use this for
434    /// requests that only read (a `POST` that searches or asks for quotes). For a command use
435    /// [`BcsHttpClient::post_command`], which never repeats. A transport error or a timeout is returned
436    /// as it is and is never repeated.
437    ///
438    /// # Errors
439    ///
440    /// The same as [`BcsHttpClient::get`].
441    ///
442    /// # Examples
443    ///
444    /// ```
445    /// # #[tokio::main(flavor = "current_thread")]
446    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
447    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
448    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
449    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
450    ///
451    /// let client = BcsHttpClient::new(cfg)?;
452    /// let body = serde_json::json!({ "instruments": [{ "ticker": "SBER", "classCode": "TQBR" }] });
453    /// let quotes: serde_json::Value = client
454    ///     .post("/trade-api-market-data-connector/api/v1/quotes", &body)
455    ///     .await?;
456    /// assert!(quotes["records"].is_array());
457    /// # Ok(())
458    /// # }
459    /// ```
460    pub async fn post<T, B>(&self, path: &str, body: &B) -> Result<T, BcsApiError>
461    where
462        T: serde::de::DeserializeOwned,
463        B: serde::Serialize,
464    {
465        self.request_with_retry(reqwest::Method::POST, path, Some(body))
466            .await
467    }
468
469    /// `POST`s a command (place, change or cancel an order) and parses the JSON reply into `T`. **Never
470    /// repeats the request.**
471    ///
472    /// A repeat of a command is the one way to place an order twice, so the client sends it once. If the
473    /// broker answers 401, the client renews the access token (so the next call works) and returns the
474    /// 401 as [`BcsApiError::HttpError`] with `status: Some(401)`. A 401 means the broker did not act, so
475    /// it is safe to send the command again with the same client order id, if you want to. A transport
476    /// error or a timeout is an unknown outcome, as for every command.
477    ///
478    /// # Errors
479    ///
480    /// The same as [`BcsHttpClient::post`].
481    pub async fn post_command<T, B>(&self, path: &str, body: &B) -> Result<T, BcsApiError>
482    where
483        T: serde::de::DeserializeOwned,
484        B: serde::Serialize,
485    {
486        let url = format!("{}{}", self.http_base_url, path);
487        let token = self.get_access_token().await?;
488        let resp = self
489            .client
490            .post(&url)
491            .bearer_auth(&token)
492            .json(body)
493            .send()
494            .await?;
495        if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
496            warn!("Got 401 on a command: renewing the token, the command is not repeated");
497            // The next call needs a fresh token. A failure of the renewal does not hide the 401.
498            let _ = self.refresh_access_token().await;
499        }
500        self.handle_response(resp).await
501    }
502
503    /// One request with a single repeat after a 401.
504    async fn request_with_retry<T, B>(
505        &self,
506        method: reqwest::Method,
507        path: &str,
508        body: Option<&B>,
509    ) -> Result<T, BcsApiError>
510    where
511        T: serde::de::DeserializeOwned,
512        B: serde::Serialize,
513    {
514        let url = format!("{}{}", self.http_base_url, path);
515        let token = self.get_access_token().await?;
516
517        let mut req = self
518            .client
519            .request(method.clone(), &url)
520            .bearer_auth(&token);
521        if let Some(b) = body {
522            req = req.json(b);
523        }
524        let resp = req.send().await?;
525
526        if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
527            warn!("Got 401, refreshing token and retrying...");
528            let new_token = self.refresh_access_token().await?;
529            let mut req = self.client.request(method, &url).bearer_auth(&new_token);
530            if let Some(b) = body {
531                req = req.json(b);
532            }
533            let resp = req.send().await?;
534            self.handle_response(resp).await
535        } else {
536            self.handle_response(resp).await
537        }
538    }
539
540    /// Turns a reply into `T` or into the matching [`BcsApiError`].
541    async fn handle_response<T: serde::de::DeserializeOwned>(
542        &self,
543        resp: reqwest::Response,
544    ) -> Result<T, BcsApiError> {
545        let status = resp.status();
546
547        if status.is_success() {
548            let body = resp.text().await?;
549            serde_json::from_str(&body).map_err(|e| {
550                BcsApiError::ParseError(format!(
551                    "Failed to parse response: {} | body: {}",
552                    e,
553                    truncate_utf8(&body, 200)
554                ))
555            })
556        } else {
557            let status_code = status.as_u16();
558            let body = resp.text().await.unwrap_or_default();
559
560            if let Ok(api_err) = serde_json::from_str::<ApiErrorResponse>(&body) {
561                match api_err.error_type.as_str() {
562                    "VALIDATION_ERROR" => Err(BcsApiError::ValidationError {
563                        trace_id: api_err.trace_id,
564                        errors: api_err.errors,
565                    }),
566                    "NOT_FOUND" => Err(BcsApiError::NotFound {
567                        trace_id: api_err.trace_id,
568                    }),
569                    _ => Err(BcsApiError::HttpError {
570                        status: Some(status_code),
571                        message: api_err.error_type,
572                        trace_id: api_err.trace_id,
573                    }),
574                }
575            } else if status_code == 429 {
576                Err(BcsApiError::RateLimitExceeded)
577            } else {
578                Err(BcsApiError::HttpError {
579                    status: Some(status_code),
580                    message: body,
581                    trace_id: None,
582                })
583            }
584        }
585    }
586
587    /// The underlying `reqwest` client, for calls this crate does not wrap.
588    ///
589    /// It has the timeout and gzip setting of the configuration but carries **no** authorization.
590    /// Add the token yourself with [`BcsHttpClient::get_access_token`].
591    ///
592    /// # Examples
593    ///
594    /// ```
595    /// use brokapi_bcs_protocol::config::BcsConfig;
596    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
597    ///
598    /// let client = BcsHttpClient::new(BcsConfig::new("t".to_string()))?;
599    /// let _http: &reqwest::Client = client.inner_client();
600    /// # Ok::<(), brokapi_bcs_protocol::error::BcsApiError>(())
601    /// ```
602    pub fn inner_client(&self) -> &Client {
603        &self.client
604    }
605
606    /// The refresh token the client holds now.
607    ///
608    /// It is the one from the configuration until the token service returns a rotated one, then it is
609    /// the new one. Persist it yourself if you want a restart to use the rotated token (the
610    /// configuration is not updated). Treat it as a secret.
611    ///
612    /// # Examples
613    ///
614    /// ```
615    /// # #[tokio::main(flavor = "current_thread")]
616    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
617    /// use brokapi_bcs_protocol::config::BcsConfig;
618    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
619    ///
620    /// let client = BcsHttpClient::new(BcsConfig::new("my-refresh-token".to_string()))?;
621    /// assert_eq!(client.current_refresh_token().await, "my-refresh-token");
622    /// # Ok(())
623    /// # }
624    /// ```
625    pub async fn current_refresh_token(&self) -> String {
626        let rt = self.refresh_token.read().await;
627        rt.clone()
628    }
629
630    /// The REST base URL this client calls, without a trailing slash.
631    ///
632    /// # Examples
633    ///
634    /// ```
635    /// use brokapi_bcs_protocol::config::BcsConfig;
636    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
637    ///
638    /// let client = BcsHttpClient::new(BcsConfig::for_mock("t", "http://127.0.0.1:1"))?;
639    /// assert_eq!(client.base_url(), "http://127.0.0.1:1");
640    /// # Ok::<(), brokapi_bcs_protocol::error::BcsApiError>(())
641    /// ```
642    pub fn base_url(&self) -> &str {
643        &self.http_base_url
644    }
645
646    /// The WebSocket base URL that sessions started from this client connect to.
647    ///
648    /// # Examples
649    ///
650    /// ```
651    /// use brokapi_bcs_protocol::config::BcsConfig;
652    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
653    ///
654    /// let client = BcsHttpClient::new(BcsConfig::for_mock("t", "http://127.0.0.1:1"))?;
655    /// assert_eq!(client.ws_base_url(), "ws://127.0.0.1:1");
656    /// # Ok::<(), brokapi_bcs_protocol::error::BcsApiError>(())
657    /// ```
658    pub fn ws_base_url(&self) -> &str {
659        &self.ws_base_url
660    }
661}
662
663/// The first `max` bytes of `s`, cut back to a character boundary so the cut never splits a character.
664fn truncate_utf8(s: &str, max: usize) -> &str {
665    if s.len() <= max {
666        return s;
667    }
668    let mut end = max;
669    while !s.is_char_boundary(end) {
670        end -= 1;
671    }
672    &s[..end]
673}
674
675#[cfg(test)]
676mod tests {
677    use super::truncate_utf8;
678
679    #[test]
680    fn truncation_never_splits_a_character() {
681        // 150 two-byte characters: byte 200 falls inside a character only if the cut is naive.
682        let s = "ж".repeat(150);
683        let cut = truncate_utf8(&s, 199);
684        assert_eq!(cut.len(), 198);
685        assert!(cut.chars().all(|c| c == 'ж'));
686        assert_eq!(truncate_utf8("short", 200), "short");
687    }
688}