Skip to main content

brokapi_tbank_protocol/
client.rs

1//! gRPC channel with Bearer-token authorization.
2
3use std::time::Duration;
4
5use tonic::metadata::{Ascii, MetadataValue};
6use tonic::service::Interceptor;
7use tonic::service::interceptor::InterceptedService;
8use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
9use tonic::{Request, Status};
10
11use crate::config::TbankConfig;
12use crate::error::ProtocolError;
13use crate::v1::{
14    instruments_service_client::InstrumentsServiceClient,
15    market_data_service_client::MarketDataServiceClient,
16    market_data_stream_service_client::MarketDataStreamServiceClient,
17    operations_service_client::OperationsServiceClient,
18    operations_stream_service_client::OperationsStreamServiceClient,
19    orders_service_client::OrdersServiceClient,
20    orders_stream_service_client::OrdersStreamServiceClient,
21    sandbox_service_client::SandboxServiceClient, signal_service_client::SignalServiceClient,
22    stop_orders_service_client::StopOrdersServiceClient, users_service_client::UsersServiceClient,
23};
24
25/// How often an HTTP/2 PING goes out while a call or a stream is open.
26const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(300);
27
28/// Adds `authorization: Bearer <token>` (and `x-app-name`) to every request.
29///
30/// The authorization value is marked sensitive, so tonic does not print it in debug output. One
31/// interceptor serves one token. [`TbankClient`] builds it from the config, build it yourself only
32/// when you wrap your own channel.
33///
34/// # Examples
35///
36/// ```
37/// use brokapi_tbank_protocol::AuthInterceptor;
38/// use tonic::service::Interceptor;
39///
40/// let mut auth = AuthInterceptor::new("t.my-token", Some("me.my-robot"))?;
41/// let request = auth.call(tonic::Request::new(())).expect("the interceptor never refuses");
42/// assert_eq!(request.metadata().get("authorization").unwrap(), "Bearer t.my-token");
43/// assert_eq!(request.metadata().get("x-app-name").unwrap(), "me.my-robot");
44///
45/// // A token a header cannot carry is a configuration error, not a panic.
46/// assert!(AuthInterceptor::new("line\nbreak", None).is_err());
47/// # Ok::<(), brokapi_tbank_protocol::ProtocolError>(())
48/// ```
49#[derive(Clone)]
50pub struct AuthInterceptor {
51    auth: MetadataValue<Ascii>,
52    app_name: Option<MetadataValue<Ascii>>,
53}
54
55impl AuthInterceptor {
56    /// Prepares the headers for `token` and the optional `app_name`.
57    ///
58    /// `app_name` becomes the `x-app-name` header (the T-Bank convention is `<github nick>.<repo>`).
59    ///
60    /// # Errors
61    ///
62    /// [`ProtocolError::Config`] when the token or the app name contains characters a header value
63    /// cannot carry (control characters such as a line break). The message does not repeat the token.
64    pub fn new(token: &str, app_name: Option<&str>) -> Result<Self, ProtocolError> {
65        let mut auth: MetadataValue<Ascii> = format!("Bearer {token}").parse().map_err(|_| {
66            ProtocolError::Config("token contains characters not allowed in a header".into())
67        })?;
68        auth.set_sensitive(true);
69        let app_name = app_name
70            .map(|n| {
71                n.parse::<MetadataValue<Ascii>>().map_err(|_| {
72                    ProtocolError::Config("app_name is not a valid header value".into())
73                })
74            })
75            .transpose()?;
76        Ok(Self { auth, app_name })
77    }
78}
79
80impl Interceptor for AuthInterceptor {
81    fn call(&mut self, mut req: Request<()>) -> Result<Request<()>, Status> {
82        let md = req.metadata_mut();
83        md.insert("authorization", self.auth.clone());
84        if let Some(name) = &self.app_name {
85            md.insert("x-app-name", name.clone());
86        }
87        Ok(req)
88    }
89}
90
91/// Channel type every generated client is parameterised with.
92pub type Authed = InterceptedService<Channel, AuthInterceptor>;
93
94/// Shared HTTP/2 channel. Cheap to clone; each accessor returns a fresh generated client.
95///
96/// All generated clients made from one `TbankClient` share one connection and one token. The
97/// channel connects lazily and reconnects on failure, so [`TbankClient::new`] never blocks and never
98/// fails because the server is down.
99///
100/// This type does not limit the request rate and does not apply `request_timeout`. The connectors in
101/// `brokapi-tbank` do both. If you call the generated clients directly, you must keep to the broker's
102/// rate limits yourself.
103///
104/// # Examples
105///
106/// ```
107/// use brokapi_tbank_protocol::v1::GetAccountsRequest;
108/// use brokapi_tbank_protocol::{TbankClient, TbankConfig};
109/// # use brokapi_mocks::tbank::{MockServer, TOKEN};
110///
111/// # #[tokio::main(flavor = "current_thread")]
112/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
113/// # let server = MockServer::start(Default::default()).await;
114/// # let cfg = TbankConfig::for_mock(TOKEN, &server.endpoint);
115/// let client = TbankClient::new(&cfg)?;
116/// // Any method of the API is reachable through the generated clients.
117/// let reply = client.users().get_accounts(GetAccountsRequest { status: None }).await?;
118/// assert!(!reply.into_inner().accounts.is_empty());
119/// # Ok(())
120/// # }
121/// ```
122#[derive(Clone)]
123pub struct TbankClient {
124    channel: Channel,
125    interceptor: AuthInterceptor,
126}
127
128impl TbankClient {
129    /// Builds the channel without touching the network (connects on first use, reconnects on failure).
130    ///
131    /// An `https://` endpoint gets TLS with the bundled web PKI roots, and the extra root certificate of
132    /// [`TbankConfig::root_certificate_pem`] if there is one. An `http://` endpoint is plain text and meant
133    /// for a local mock. HTTP/2 keep-alive pings run every 5 minutes and only while a call or a stream is
134    /// open: a server counts pings on an idle connection as abuse and closes it (`too_many_pings`), so none
135    /// are sent when nothing is open. A dead stream is found by the silence limit of the connector.
136    ///
137    /// # Errors
138    ///
139    /// - [`ProtocolError::Config`] when the token or app name is not a valid header value, or the
140    ///   endpoint is not a valid URL.
141    /// - [`ProtocolError::Transport`] when the TLS configuration is refused.
142    ///
143    /// # Examples
144    ///
145    /// ```
146    /// use brokapi_tbank_protocol::{ProtocolError, TbankClient, TbankConfig};
147    ///
148    /// # #[tokio::main(flavor = "current_thread")]
149    /// # async fn main() {
150    /// // The sandbox endpoint is not contacted here: the channel is lazy.
151    /// assert!(TbankClient::new(&TbankConfig::sandbox("t.my-token")).is_ok());
152    ///
153    /// // A bad endpoint is reported at once.
154    /// let mut cfg = TbankConfig::sandbox("t.my-token");
155    /// cfg.endpoint = "not a url".into();
156    /// assert!(matches!(TbankClient::new(&cfg), Err(ProtocolError::Config(_))));
157    /// # }
158    /// ```
159    pub fn new(cfg: &TbankConfig) -> Result<Self, ProtocolError> {
160        let interceptor = AuthInterceptor::new(&cfg.token, cfg.app_name.as_deref())?;
161        let mut endpoint = Endpoint::from_shared(cfg.endpoint.clone())
162            .map_err(|e| ProtocolError::Config(format!("endpoint {}: {e}", cfg.endpoint)))?
163            .connect_timeout(cfg.connect_timeout)
164            .tcp_nodelay(true)
165            .http2_keep_alive_interval(KEEP_ALIVE_INTERVAL)
166            .keep_alive_timeout(Duration::from_secs(20))
167            .keep_alive_while_idle(false);
168        if cfg.endpoint.starts_with("https://") {
169            let mut tls = ClientTlsConfig::new().with_webpki_roots();
170            if let Some(pem) = &cfg.root_certificate_pem {
171                if !pem.windows(11).any(|w| w == b"-----BEGIN ") {
172                    return Err(ProtocolError::Config(
173                        "root_certificate_pem holds no PEM certificate".into(),
174                    ));
175                }
176                tls = tls.ca_certificate(tonic::transport::Certificate::from_pem(pem));
177            }
178            endpoint = endpoint.tls_config(tls)?;
179        }
180        Ok(Self {
181            channel: endpoint.connect_lazy(),
182            interceptor,
183        })
184    }
185
186    fn with_auth(&self) -> Authed {
187        InterceptedService::new(self.channel.clone(), self.interceptor.clone())
188    }
189
190    /// A generic gRPC client over the same channel and token, for a call that is not made through a
191    /// generated client: the method is named by a path (`/package.Service/Method`) and the messages are
192    /// whatever codec you give it. With [`FILE_DESCRIPTOR_SET`](crate::FILE_DESCRIPTOR_SET) it calls any
193    /// method of the API by name.
194    pub fn grpc(&self) -> tonic::client::Grpc<Authed> {
195        tonic::client::Grpc::new(self.with_auth())
196    }
197
198    /// Client of `MarketDataService`: unary market data: order book snapshot, last trades, candles, trading status.
199    ///
200    /// Returns a fresh generated client on the shared channel. It is cheap, create one per call.
201    pub fn market_data(&self) -> MarketDataServiceClient<Authed> {
202        MarketDataServiceClient::new(self.with_auth())
203    }
204
205    /// Client of `MarketDataStreamService`: the bidirectional market data stream (order book, trades, candles subscriptions).
206    ///
207    /// Returns a fresh generated client on the shared channel. It is cheap, create one per call.
208    pub fn market_data_stream(&self) -> MarketDataStreamServiceClient<Authed> {
209        MarketDataStreamServiceClient::new(self.with_auth())
210    }
211
212    /// Client of `OrdersService`: orders: place, cancel, replace, order state, active orders.
213    ///
214    /// Returns a fresh generated client on the shared channel. It is cheap, create one per call.
215    pub fn orders(&self) -> OrdersServiceClient<Authed> {
216        OrdersServiceClient::new(self.with_auth())
217    }
218
219    /// Client of `OrdersStreamService`: server streams of own order states and own trades.
220    ///
221    /// Returns a fresh generated client on the shared channel. It is cheap, create one per call.
222    pub fn orders_stream(&self) -> OrdersStreamServiceClient<Authed> {
223        OrdersStreamServiceClient::new(self.with_auth())
224    }
225
226    /// Client of `OperationsService`: portfolio, positions, operation history.
227    ///
228    /// Returns a fresh generated client on the shared channel. It is cheap, create one per call.
229    pub fn operations(&self) -> OperationsServiceClient<Authed> {
230        OperationsServiceClient::new(self.with_auth())
231    }
232
233    /// Client of `OperationsStreamService`: server streams of portfolio and positions changes.
234    ///
235    /// Returns a fresh generated client on the shared channel. It is cheap, create one per call.
236    pub fn operations_stream(&self) -> OperationsStreamServiceClient<Authed> {
237        OperationsStreamServiceClient::new(self.with_auth())
238    }
239
240    /// Client of `UsersService`: accounts, user info, tariff and limits.
241    ///
242    /// Returns a fresh generated client on the shared channel. It is cheap, create one per call.
243    pub fn users(&self) -> UsersServiceClient<Authed> {
244        UsersServiceClient::new(self.with_auth())
245    }
246
247    /// Client of `InstrumentsService`: the instrument directory: search, listings, schedules, reference data.
248    ///
249    /// Returns a fresh generated client on the shared channel. It is cheap, create one per call.
250    pub fn instruments(&self) -> InstrumentsServiceClient<Authed> {
251        InstrumentsServiceClient::new(self.with_auth())
252    }
253
254    /// Client of `SandboxService`: sandbox methods: open virtual accounts, top up virtual money (meant for the sandbox endpoint).
255    ///
256    /// Returns a fresh generated client on the shared channel. It is cheap, create one per call.
257    pub fn sandbox(&self) -> SandboxServiceClient<Authed> {
258        SandboxServiceClient::new(self.with_auth())
259    }
260
261    /// Client of `StopOrdersService`: stop orders: place, cancel, list (unary only, no stream).
262    ///
263    /// Returns a fresh generated client on the shared channel. It is cheap, create one per call.
264    pub fn stop_orders(&self) -> StopOrdersServiceClient<Authed> {
265        StopOrdersServiceClient::new(self.with_auth())
266    }
267
268    /// Client of `SignalService`: signals and strategies.
269    ///
270    /// Returns a fresh generated client on the shared channel. It is cheap, create one per call.
271    pub fn signals(&self) -> SignalServiceClient<Authed> {
272        SignalServiceClient::new(self.with_auth())
273    }
274}
275
276impl TbankClient {
277    /// `UsersService/GetAccounts`: every account the token can see (a token covers several accounts;
278    /// pick the `id` to put into the connector config as `account_id`).
279    ///
280    /// No status filter is sent. Whether the broker then returns only open accounts or all of them is
281    /// not verified against the live API, so check `status` of each [`Account`](crate::v1::Account)
282    /// before you trade on it. The call is not rate limited here.
283    ///
284    /// # Errors
285    ///
286    /// The `tonic::Status` of the failed call: `Unauthenticated` for a bad token, `Unavailable` or
287    /// `DeadlineExceeded` for network trouble. The call is a read, so repeating it is safe.
288    ///
289    /// # Examples
290    ///
291    /// ```
292    /// use brokapi_tbank_protocol::{TbankClient, TbankConfig};
293    /// # use brokapi_mocks::tbank::{MockServer, TOKEN};
294    ///
295    /// # #[tokio::main(flavor = "current_thread")]
296    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
297    /// # let server = MockServer::start(Default::default()).await;
298    /// # let cfg = TbankConfig::for_mock(TOKEN, &server.endpoint);
299    /// let client = TbankClient::new(&cfg)?;
300    /// let ids: Vec<String> = client.list_accounts().await?.into_iter().map(|a| a.id).collect();
301    /// // One connector per account: put each id into `TbankConfig::with_account`.
302    /// assert!(ids.len() >= 2);
303    ///
304    /// // A wrong token is refused by the server and shows as a status, not a panic.
305    /// let bad = TbankClient::new(&TbankConfig::for_mock("wrong-token", &server.endpoint))?;
306    /// let status = bad.list_accounts().await.unwrap_err();
307    /// assert_eq!(status.code(), tonic::Code::Unauthenticated);
308    /// # Ok(())
309    /// # }
310    /// ```
311    pub async fn list_accounts(&self) -> Result<Vec<crate::v1::Account>, Status> {
312        let resp = self
313            .users()
314            .get_accounts(crate::v1::GetAccountsRequest { status: None })
315            .await?;
316        Ok(resp.into_inner().accounts)
317    }
318}