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}