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}