Skip to main content

brokapi_finam_protocol/
client.rs

1//! Channel setup and authorized service clients.
2//!
3//! [`FinamApi`] is the entry point: one lazily connecting channel plus one shared
4//! [`TokenManager`]. Every service client it hands out puts the current JWT
5//! into the `authorization` header of each call, so a client never holds an expired token.
6//!
7//! # Examples
8//!
9//! ```
10//! use brokapi_finam_protocol::pb::grpc::tradeapi::v1::marketdata::QuoteRequest;
11//! use brokapi_finam_protocol::{ApiEndpoint, FinamApi, start_token_manager};
12//! # use brokapi_mocks::finam::{MockServer, SECRET};
13//!
14//! # #[tokio::main(flavor = "current_thread")]
15//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
16//! # let server = MockServer::start().await;
17//! # let endpoint = ApiEndpoint::new(server.url.clone());
18//! # let secret = SECRET;
19//! // `endpoint` is `ApiEndpoint::default()` for the real broker, `secret` is your API secret.
20//! let tokens = start_token_manager(&endpoint, secret, "my-app").await?;
21//! let api = FinamApi::connect(&endpoint, tokens, false)?;
22//!
23//! let quote = api
24//!     .market_data()
25//!     .last_quote(QuoteRequest { symbol: "SBER@MISX".into() })
26//!     .await?
27//!     .into_inner();
28//! assert_eq!(quote.symbol, "SBER@MISX");
29//! # Ok(())
30//! # }
31//! ```
32
33use std::sync::Arc;
34use std::time::Duration;
35
36use tonic::metadata::MetadataValue;
37use tonic::service::Interceptor;
38use tonic::service::interceptor::InterceptedService;
39use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
40use tonic::{Request, Status};
41
42use crate::auth::{GrpcTokenApi, TokenManager};
43use crate::error::FinamError;
44use crate::pb::grpc::tradeapi::v1::accounts::accounts_service_client::AccountsServiceClient;
45use crate::pb::grpc::tradeapi::v1::assets::assets_service_client::AssetsServiceClient;
46use crate::pb::grpc::tradeapi::v1::auth::auth_service_client::AuthServiceClient;
47use crate::pb::grpc::tradeapi::v1::corporateactions::corporate_actions_service_client::CorporateActionsServiceClient;
48use crate::pb::grpc::tradeapi::v1::marketdata::market_data_service_client::MarketDataServiceClient;
49use crate::pb::grpc::tradeapi::v1::metrics::usage_metrics_service_client::UsageMetricsServiceClient;
50use crate::pb::grpc::tradeapi::v1::orders::orders_service_client::OrdersServiceClient;
51use crate::pb::grpc::tradeapi::v1::reports::reports_service_client::ReportsServiceClient;
52
53/// Address of the production Trade API (`https://api.finam.ru:443`, TLS).
54pub const DEFAULT_ENDPOINT: &str = "https://api.finam.ru:443";
55
56/// Where and how to connect: the address and the timeouts of the channel.
57///
58/// # Examples
59///
60/// ```
61/// use std::time::Duration;
62/// use brokapi_finam_protocol::{ApiEndpoint, DEFAULT_ENDPOINT};
63///
64/// // Production: the default.
65/// assert_eq!(ApiEndpoint::default().url, DEFAULT_ENDPOINT);
66///
67/// // A proxy or a local imitation: plain `http://`, a shorter connect timeout.
68/// let mut ep = ApiEndpoint::new("http://127.0.0.1:50051");
69/// ep.connect_timeout = Duration::from_secs(2);
70/// assert_eq!(ep.keepalive_interval, Duration::from_secs(30));
71/// ```
72#[derive(Debug, Clone)]
73pub struct ApiEndpoint {
74    /// `https://host:port` (TLS, webpki roots) or `http://host:port` (plaintext, tests/proxies).
75    pub url: String,
76    /// How long to wait for the TCP and TLS handshake. Default 10 seconds.
77    pub connect_timeout: Duration,
78    /// HTTP/2 PING interval, detects dead connections under long-lived streams. Default 30 seconds.
79    ///
80    /// Pings are also sent while the channel is idle.
81    pub keepalive_interval: Duration,
82    /// How long to wait for the answer to a PING before the connection is declared dead.
83    /// Default 10 seconds.
84    pub keepalive_timeout: Duration,
85}
86
87impl ApiEndpoint {
88    /// Endpoint for `url` with the default timeouts (connect 10 s, keepalive 30 s and 10 s).
89    ///
90    /// The URL is not checked here. A bad URL is reported by [`connect_lazy`] as
91    /// [`FinamError::Config`].
92    pub fn new(url: impl Into<String>) -> Self {
93        Self {
94            url: url.into(),
95            connect_timeout: Duration::from_secs(10),
96            keepalive_interval: Duration::from_secs(30),
97            keepalive_timeout: Duration::from_secs(10),
98        }
99    }
100}
101
102impl Default for ApiEndpoint {
103    fn default() -> Self {
104        Self::new(DEFAULT_ENDPOINT)
105    }
106}
107
108/// Builds a lazily connecting channel to `ep`.
109///
110/// Nothing is sent here. Connection errors surface on the first call, and tonic reconnects on its
111/// own after a drop. TLS (webpki roots) is switched on when the URL starts with `https://`.
112///
113/// # Errors
114///
115/// [`FinamError::Config`] when the URL is not a valid endpoint or the TLS settings are refused.
116///
117/// # Examples
118///
119/// ```
120/// use brokapi_finam_protocol::client::connect_lazy;
121/// use brokapi_finam_protocol::{ApiEndpoint, FinamError};
122///
123/// # #[tokio::main(flavor = "current_thread")]
124/// # async fn main() {
125/// // A valid address gives a channel without touching the network.
126/// assert!(connect_lazy(&ApiEndpoint::new("http://127.0.0.1:1")).is_ok());
127/// // Garbage is a configuration error.
128/// let err = connect_lazy(&ApiEndpoint::new("not a url")).unwrap_err();
129/// assert!(matches!(err, FinamError::Config(_)));
130/// # }
131/// ```
132pub fn connect_lazy(ep: &ApiEndpoint) -> Result<Channel, FinamError> {
133    let mut endpoint = Endpoint::from_shared(ep.url.clone())
134        .map_err(|e| FinamError::Config(format!("bad endpoint {:?}: {e}", ep.url)))?
135        .connect_timeout(ep.connect_timeout)
136        .tcp_nodelay(true)
137        .http2_keep_alive_interval(ep.keepalive_interval)
138        .keep_alive_timeout(ep.keepalive_timeout)
139        .keep_alive_while_idle(true);
140    if ep.url.starts_with("https://") {
141        endpoint = endpoint
142            .tls_config(ClientTlsConfig::new().with_webpki_roots())
143            .map_err(|e| FinamError::Config(format!("tls: {e}")))?;
144    }
145    Ok(endpoint.connect_lazy())
146}
147
148/// Starts a [`TokenManager`] for `secret` on a fresh channel to `ep`.
149///
150/// Logs in once (`AuthService.Auth`) and starts the background refresher. Put the returned `Arc` into
151/// every [`FinamApi`] that uses this secret: one secret needs one manager.
152///
153/// `source_app_id` is sent with `Auth` as the application id. An empty string is allowed.
154///
155/// # Errors
156///
157/// - [`FinamError::Config`]: the secret is empty or the endpoint URL is invalid.
158/// - [`FinamError::Auth`]: the server returned an empty token.
159/// - [`FinamError::Status`]: the server refused the secret (code `UNAUTHENTICATED`) or could not
160///   be reached.
161///
162/// # Examples
163///
164/// ```
165/// use brokapi_finam_protocol::{ApiEndpoint, FinamError, start_token_manager};
166/// # use brokapi_mocks::finam::{MockServer, SECRET};
167///
168/// # #[tokio::main(flavor = "current_thread")]
169/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
170/// # let server = MockServer::start().await;
171/// # let endpoint = ApiEndpoint::new(server.url.clone());
172/// # let secret = SECRET;
173/// let tokens = start_token_manager(&endpoint, secret, "my-app").await?;
174/// assert!(!tokens.current().is_empty());
175///
176/// // A wrong secret is refused by the server.
177/// let err = start_token_manager(&endpoint, "wrong-secret", "my-app").await.unwrap_err();
178/// assert!(err.is_unauthenticated());
179/// // An empty secret never leaves the process.
180/// let err = start_token_manager(&endpoint, " ", "my-app").await.unwrap_err();
181/// assert!(matches!(err, FinamError::Config(_)));
182/// # Ok(())
183/// # }
184/// ```
185pub async fn start_token_manager(
186    ep: &ApiEndpoint,
187    secret: &str,
188    source_app_id: &str,
189) -> Result<Arc<TokenManager>, FinamError> {
190    let channel = connect_lazy(ep)?;
191    TokenManager::start(Arc::new(GrpcTokenApi::new(channel)), secret, source_app_id).await
192}
193
194/// Tonic interceptor that puts the current JWT into the `authorization` metadata of every call.
195///
196/// Built inside [`FinamApi`], not by hand. It asks the [`TokenManager`] for the token on each call,
197/// so a renewed token is used at once. A token that cannot be written as header text fails the
198/// call with status `UNAUTHENTICATED` before anything is sent.
199#[derive(Clone)]
200pub struct AuthInterceptor {
201    tokens: Arc<TokenManager>,
202    bearer: bool,
203}
204
205impl Interceptor for AuthInterceptor {
206    fn call(&mut self, mut req: Request<()>) -> Result<Request<()>, Status> {
207        let token = self.tokens.current();
208        // Official SDKs send the bare JWT; some doc examples show `Bearer <jwt>`.
209        let value = if self.bearer {
210            format!("Bearer {token}")
211        } else {
212            token.to_string()
213        };
214        let mut value = MetadataValue::try_from(value.as_str())
215            .map_err(|_| Status::unauthenticated("token is not valid metadata"))?;
216        // Sensitive: the JWT does not show in the Debug output of the request or in HTTP/2 traces.
217        value.set_sensitive(true);
218        req.metadata_mut().insert("authorization", value);
219        Ok(req)
220    }
221}
222
223/// Channel with the [`AuthInterceptor`] on it: the transport type of every client of [`FinamApi`].
224pub type Authorized = InterceptedService<Channel, AuthInterceptor>;
225
226/// One channel plus a shared token manager. Hands out service clients that attach the JWT to each call.
227///
228/// Cheap to clone (the channel is reference counted). Each accessor builds a new client over the same
229/// channel, so there is no need to keep clients around.
230///
231/// A streaming call keeps the JWT it was opened with. Open it again after the token is renewed
232/// (the connectors do this for their own streams).
233///
234/// See the [module docs](self) for a complete example.
235#[derive(Clone)]
236pub struct FinamApi {
237    channel: Channel,
238    tokens: Arc<TokenManager>,
239    bearer: bool,
240}
241
242impl FinamApi {
243    /// Opens a lazy channel to `ep` and uses `tokens` for authorization.
244    ///
245    /// `bearer` selects the header format. `false` sends the bare JWT, which is what the official
246    /// SDKs do. `true` sends `Bearer <jwt>`, which some examples in the broker documentation show.
247    /// Which of the two the live broker accepts is not verified against the live API here.
248    ///
249    /// # Errors
250    ///
251    /// [`FinamError::Config`] when the endpoint URL is invalid (see [`connect_lazy`]).
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// use brokapi_finam_protocol::{ApiEndpoint, FinamApi, FinamError, start_token_manager};
257    /// # use brokapi_mocks::finam::{MockServer, SECRET};
258    ///
259    /// # #[tokio::main(flavor = "current_thread")]
260    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
261    /// # let server = MockServer::start().await;
262    /// # let endpoint = ApiEndpoint::new(server.url.clone());
263    /// # let tokens = start_token_manager(&endpoint, SECRET, "").await?;
264    /// let api = FinamApi::connect(&endpoint, tokens.clone(), false)?;
265    /// assert!(std::sync::Arc::ptr_eq(api.tokens(), &tokens));
266    ///
267    /// let bad = FinamApi::connect(&ApiEndpoint::new("not a url"), tokens, false);
268    /// assert!(matches!(bad, Err(FinamError::Config(_))));
269    /// # Ok(())
270    /// # }
271    /// ```
272    pub fn connect(
273        ep: &ApiEndpoint,
274        tokens: Arc<TokenManager>,
275        bearer: bool,
276    ) -> Result<Self, FinamError> {
277        Ok(Self {
278            channel: connect_lazy(ep)?,
279            tokens,
280            bearer,
281        })
282    }
283
284    /// Wraps a channel you built yourself (for example with custom TLS or a proxy).
285    ///
286    /// Nothing is checked. `bearer` has the same meaning as in [`FinamApi::connect`].
287    pub fn from_channel(channel: Channel, tokens: Arc<TokenManager>, bearer: bool) -> Self {
288        Self {
289            channel,
290            tokens,
291            bearer,
292        }
293    }
294
295    /// The token manager used by this client.
296    ///
297    /// Use it to read the account list ([`TokenManager::account_ids`]) or to ask for a renewal.
298    pub fn tokens(&self) -> &Arc<TokenManager> {
299        &self.tokens
300    }
301
302    fn svc(&self) -> Authorized {
303        InterceptedService::new(
304            self.channel.clone(),
305            AuthInterceptor {
306                tokens: Arc::clone(&self.tokens),
307                bearer: self.bearer,
308            },
309        )
310    }
311
312    /// A generic gRPC client over the same channel and token, for a call that is not made through a
313    /// generated client: the method is named by a path (`/package.Service/Method`) and the messages are
314    /// whatever codec you give it. With [`FILE_DESCRIPTOR_SET`](crate::FILE_DESCRIPTOR_SET) it calls any
315    /// method of the API by name.
316    pub fn grpc(&self) -> tonic::client::Grpc<Authorized> {
317        tonic::client::Grpc::new(self.svc())
318    }
319
320    /// `OrdersService`: place, cancel and read orders, order and trade streams.
321    pub fn orders(&self) -> OrdersServiceClient<Authorized> {
322        OrdersServiceClient::new(self.svc())
323    }
324
325    /// `AccountsService`: account state, own trades, account stream.
326    pub fn accounts(&self) -> AccountsServiceClient<Authorized> {
327        AccountsServiceClient::new(self.svc())
328    }
329
330    /// `MarketDataService`: quotes, order books, trades, bars and their streams.
331    pub fn market_data(&self) -> MarketDataServiceClient<Authorized> {
332        MarketDataServiceClient::new(self.svc())
333    }
334
335    /// `AssetsService`: instrument directory, schedules, exchange clock.
336    pub fn assets(&self) -> AssetsServiceClient<Authorized> {
337        AssetsServiceClient::new(self.svc())
338    }
339
340    /// `UsageMetricsService`: how much of the request quota is left.
341    pub fn usage_metrics(&self) -> UsageMetricsServiceClient<Authorized> {
342        UsageMetricsServiceClient::new(self.svc())
343    }
344
345    /// `AuthService` with the current JWT attached. `Auth` and `SubscribeJwtRenewal` are driven by the
346    /// [`TokenManager`]; `TokenDetails` is reachable here.
347    pub fn auth(&self) -> AuthServiceClient<Authorized> {
348        AuthServiceClient::new(self.svc())
349    }
350
351    /// `ReportsService`: account reports.
352    pub fn reports(&self) -> ReportsServiceClient<Authorized> {
353        ReportsServiceClient::new(self.svc())
354    }
355
356    /// `CorporateActionsService`: splits, dividends and bond events.
357    pub fn corporate_actions(&self) -> CorporateActionsServiceClient<Authorized> {
358        CorporateActionsServiceClient::new(self.svc())
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use std::sync::Arc;
365
366    use async_trait::async_trait;
367
368    use super::*;
369    use crate::auth::{TokenApi, TokenInfo};
370    use crate::error::FinamError;
371
372    struct Fixed;
373
374    #[async_trait]
375    impl TokenApi for Fixed {
376        async fn auth(&self, _s: &str, _a: &str) -> Result<String, FinamError> {
377            Ok("the-jwt".into())
378        }
379        async fn token_details(&self, _t: &str) -> Result<TokenInfo, FinamError> {
380            Ok(TokenInfo::default())
381        }
382    }
383
384    #[tokio::test]
385    async fn the_jwt_header_is_sensitive_and_is_sent_bare_or_with_bearer() {
386        let tokens = crate::auth::TokenManager::start(Arc::new(Fixed), "secret", "")
387            .await
388            .unwrap();
389        for (bearer, want) in [(false, "the-jwt"), (true, "Bearer the-jwt")] {
390            let mut interceptor = AuthInterceptor {
391                tokens: Arc::clone(&tokens),
392                bearer,
393            };
394            let req = interceptor.call(Request::new(())).unwrap();
395            let value = req.metadata().get("authorization").unwrap();
396            assert_eq!(value, want);
397            // The value does not show in `Debug` output.
398            assert!(value.is_sensitive());
399            assert!(!format!("{req:?}").contains("the-jwt"), "{req:?}");
400        }
401    }
402}