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: prost::Message,
181 Res: prost::Message + Default,
182 {
183 self.do_unary_request(path, req)
184 .instrument(tracing::info_span!("request", %path, id = rand::random::<u16>()))
185 .await
186 }
187
188 async fn do_unary_request<Req, Res>(
190 &self,
191 path: &str,
192 req: &Req,
193 ) -> Result<Res, CrpcClientError>
194 where
195 Req: prost::Message,
196 Res: prost::Message + Default,
197 {
198 let url = self.base_url.join(path).map_err(|e| {
199 CrpcClientError::ConnectionError {
200 context: "error joining base URL and path".into(),
201 source: e.into(),
202 }
203 })?;
204
205 let mut headers = HeaderMap::with_capacity(3);
206 headers.insert(
207 header::CONTENT_TYPE,
208 header::HeaderValue::from_static(APPLICATION_PROTO),
209 );
210 headers.insert(header::USER_AGENT, self.user_agent.clone());
211
212 tracing::trace!(?url, ?headers, "Sending crpc unary request");
213
214 if let Some(token_source) = &self.token_source {
215 let token = token_source.get_token().await?;
216 let token_header = header::HeaderValue::from_str(&token_source.format_header(token))
217 .map_err(CrpcClientError::InvalidTokenHeader)?;
218
219 headers.insert(header::AUTHORIZATION, token_header);
220 }
221
222 let body = req.encode_to_vec();
223 let response = self
224 .http_client
225 .post(url)
226 .body(reqwest::Body::from(body))
227 .headers(headers)
228 .send()
229 .await
230 .map_err(|e| {
231 CrpcClientError::ConnectionError {
232 context: "error sending request".into(),
233 source: e.into(),
234 }
235 })?;
236
237 tracing::trace!(status=%response.status(), body_len=%response.content_length().unwrap_or(0), "Received crpc unary response");
238
239 let status = response.status();
240 if !status.is_success() {
241 let response_raw = response
242 .text()
243 .await
244 .unwrap_or_else(|_| "<failed to read body>".to_string());
245
246 match serde_json::from_str::<CrpcError>(&response_raw) {
248 Ok(crpc_err) => {
249 return Err(CrpcClientError::CrpcError(crpc_err));
250 }
251 Err(_) => {
252 return Err(CrpcClientError::CrpcError(CrpcError::new(
253 status.into(),
254 response_raw,
255 )));
256 }
257 }
258 }
259
260 let body = response.bytes().await.map_err(|e| {
261 CrpcClientError::ConnectionError {
262 context: "error reading response body".into(),
263 source: e.into(),
264 }
265 })?;
266
267 Res::decode(&body[..]).map_err(|e| {
268 CrpcClientError::DecodeError {
269 context: "error decoding response body".into(),
270 source: Some(e.into()),
271 body: Some(body.clone()),
272 }
273 })
274 }
275}