1use std::{borrow::Cow, sync::Arc, time::Duration};
17
18use bytes::Bytes;
19use reqwest::header::{self, HeaderMap, HeaderValue};
20use thiserror::Error;
21use tracing::Instrument;
22
23use crate::{
24 error::CrpcError,
25 token_source::{TokenSource, TokenSourceError},
26};
27
28#[derive(Debug, Error)]
30pub enum CrpcClientError {
31 #[error("connection error {context}: {source:#?}")]
33 ConnectionError {
34 context: Cow<'static, str>,
36 source: Box<dyn std::error::Error + Send + Sync + 'static>,
38 },
39 #[error("server returned an error: {0:#?}")]
41 CrpcError(CrpcError),
42 #[error("failed to decode response body: {context}: {source:#?}")]
44 DecodeError {
45 context: Cow<'static, str>,
47 source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
49 body: Option<Bytes>,
51 },
52 #[error("failed to retrieve token: {0}")]
54 TokenSourceError(#[from] TokenSourceError),
55 #[error("failed to format token as header value: {0}")]
57 InvalidTokenHeader(#[source] header::InvalidHeaderValue),
58}
59
60impl CrpcClientError {
61 #[must_use]
66 pub fn is_transient(&self) -> bool {
67 match self {
68 Self::ConnectionError { .. } => true,
71 Self::CrpcError(error) => error.is_transient(),
72 Self::DecodeError { .. } => false,
73 Self::InvalidTokenHeader(_) => false,
74 Self::TokenSourceError(error) => error.is_transient(),
75 }
76 }
77}
78
79#[derive(Debug, Error)]
81pub enum CrpcClientCreationError {
82 #[error("failed to build HTTP client")]
84 HttpClient(#[source] reqwest::Error),
85 #[error("invalid user agent {user_agent:?}")]
87 InvalidUserAgent {
88 user_agent: String,
90 #[source]
92 source: header::InvalidHeaderValue,
93 },
94}
95
96impl CrpcClientCreationError {
97 #[must_use]
102 pub fn is_transient(&self) -> bool {
103 match self {
104 Self::HttpClient(_) | Self::InvalidUserAgent { .. } => false,
107 }
108 }
109}
110
111const APPLICATION_PROTO: &str = "application/proto";
112
113pub struct CrpcClient {
115 http_client: reqwest::Client,
116 base_url: url::Url,
117 token_source: Option<Arc<dyn TokenSource>>,
118 user_agent: HeaderValue,
119}
120
121impl CrpcClient {
122 pub fn new(base_url: &url::Url) -> Result<Self, CrpcClientCreationError> {
124 let http_client = reqwest::ClientBuilder::new()
125 .timeout(Duration::from_secs(30))
126 .build()
127 .map_err(CrpcClientCreationError::HttpClient)?;
128
129 Self::new_with_client(base_url, http_client)
130 }
131
132 pub fn new_with_client(
134 base_url: &url::Url,
135 http_client: reqwest::Client,
136 ) -> Result<Self, CrpcClientCreationError> {
137 let default_user_agent = format!("reqwest-crpc {}", env!("CARGO_PKG_VERSION"));
138 let user_agent = HeaderValue::from_str(&default_user_agent).map_err(|source| {
139 CrpcClientCreationError::InvalidUserAgent {
140 user_agent: default_user_agent,
141 source,
142 }
143 })?;
144
145 Ok(CrpcClient {
146 http_client,
147 base_url: base_url.clone(),
148 token_source: None,
149 user_agent,
150 })
151 }
152
153 pub fn use_token_source(&mut self, token_source: Arc<dyn TokenSource>) -> &mut Self {
155 self.token_source = Some(token_source);
156 self
157 }
158
159 pub fn use_user_agent(
161 &mut self,
162 user_agent: &str,
163 ) -> Result<&mut Self, CrpcClientCreationError> {
164 self.user_agent = HeaderValue::from_str(user_agent).map_err(|source| {
165 CrpcClientCreationError::InvalidUserAgent {
166 user_agent: user_agent.to_owned(),
167 source,
168 }
169 })?;
170 Ok(self)
171 }
172
173 pub async fn unary_request<Req, Res>(
175 &self,
176 path: &str,
177 req: &Req,
178 ) -> Result<Res, CrpcClientError>
179 where
180 Req: buffa::Message,
181 Res: buffa::Message,
182 {
183 let body = self.post(path, req.encode_to_vec()).await?;
184
185 Res::decode_from_slice(&body).map_err(|e| {
186 CrpcClientError::DecodeError {
187 context: "error decoding response body".into(),
188 source: Some(e.into()),
189 body: Some(body),
190 }
191 })
192 }
193
194 async fn post(&self, path: &str, body: Vec<u8>) -> Result<Bytes, CrpcClientError> {
198 self.do_post(path, body)
199 .instrument(tracing::info_span!("request", %path, id = rand::random::<u16>()))
200 .await
201 }
202
203 async fn do_post(&self, path: &str, body: Vec<u8>) -> Result<Bytes, CrpcClientError> {
204 let url = self.base_url.join(path).map_err(|e| {
205 CrpcClientError::ConnectionError {
206 context: "error joining base URL and path".into(),
207 source: e.into(),
208 }
209 })?;
210
211 let mut headers = HeaderMap::with_capacity(3);
212 headers.insert(
213 header::CONTENT_TYPE,
214 header::HeaderValue::from_static(APPLICATION_PROTO),
215 );
216 headers.insert(header::USER_AGENT, self.user_agent.clone());
217
218 tracing::trace!(?url, ?headers, "Sending crpc unary request");
219
220 if let Some(token_source) = &self.token_source {
221 let token = token_source.get_token().await?;
222 let token_header = header::HeaderValue::from_str(&token_source.format_header(token))
223 .map_err(CrpcClientError::InvalidTokenHeader)?;
224
225 headers.insert(header::AUTHORIZATION, token_header);
226 }
227
228 let response = self
229 .http_client
230 .post(url)
231 .body(reqwest::Body::from(body))
232 .headers(headers)
233 .send()
234 .await
235 .map_err(|e| {
236 CrpcClientError::ConnectionError {
237 context: "error sending request".into(),
238 source: e.into(),
239 }
240 })?;
241
242 tracing::trace!(status=%response.status(), body_len=%response.content_length().unwrap_or(0), "Received crpc unary response");
243
244 let status = response.status();
245 if !status.is_success() {
246 let response_raw = response
247 .text()
248 .await
249 .unwrap_or_else(|_| "<failed to read body>".to_string());
250
251 match serde_json::from_str::<CrpcError>(&response_raw) {
253 Ok(crpc_err) => {
254 return Err(CrpcClientError::CrpcError(crpc_err));
255 }
256 Err(_) => {
257 return Err(CrpcClientError::CrpcError(CrpcError::new(
258 status.into(),
259 response_raw,
260 )));
261 }
262 }
263 }
264
265 response.bytes().await.map_err(|e| {
266 CrpcClientError::ConnectionError {
267 context: "error reading response body".into(),
268 source: e.into(),
269 }
270 })
271 }
272}