main
rs 259 lines 8.68 KB
Raw
1 //! The HTTP transport every endpoint module is built on.
2
3 use {
4 crate::{
5 environment::Environment,
6 error::{ApiError, ErrorResponse, Result},
7 },
8 reqwest::{Method, RequestBuilder, StatusCode},
9 serde::{de::DeserializeOwned, Serialize},
10 tracing::debug,
11 };
12
13 const USER_AGENT: &str = concat!("si/", env!("CARGO_PKG_VERSION"));
14
15 /// A configured client for one sigit.si environment.
16 ///
17 /// Cheap to clone (the inner `reqwest::Client` is an `Arc` over a shared
18 /// connection pool), so pass it around by value.
19 #[derive(Debug, Clone)]
20 pub struct Client {
21 http: reqwest::Client,
22 environment: Environment,
23 api_url: String,
24 base_url: String,
25 token: Option<String>,
26 }
27
28 impl Client {
29 /// An unauthenticated client. Every endpoint on this API needs a token, so
30 /// this is mostly a starting point for [`Client::with_token`].
31 pub fn new(environment: Environment) -> Result<Self> {
32 let http = reqwest::Client::builder()
33 .user_agent(USER_AGENT)
34 .build()
35 .map_err(|source| ApiError::Transport {
36 host: environment.base_url(),
37 source,
38 })?;
39
40 Ok(Self {
41 http,
42 environment,
43 api_url: environment.api_url(),
44 base_url: environment.base_url(),
45 token: None,
46 })
47 }
48
49 /// Attach the bearer token sent on every subsequent request.
50 pub fn with_token(mut self, token: impl Into<String>) -> Self {
51 self.token = Some(token.into());
52 self
53 }
54
55 pub fn environment(&self) -> Environment {
56 self.environment
57 }
58
59 /// The web origin — the base for browser and `.git` clone URLs.
60 pub fn base_url(&self) -> &str {
61 &self.base_url
62 }
63
64 pub fn api_url(&self) -> &str {
65 &self.api_url
66 }
67
68 pub fn token(&self) -> Option<&str> {
69 self.token.as_deref()
70 }
71
72 // ── Verb helpers ────────────────────────────────────────────────────────
73
74 pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
75 self.send(self.request(Method::GET, path)).await
76 }
77
78 pub async fn post<T: DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> {
79 self.send(self.request(Method::POST, path).json(body)).await
80 }
81
82 pub async fn patch<T: DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> {
83 self.send(self.request(Method::PATCH, path).json(body))
84 .await
85 }
86
87 /// For endpoints that answer `204 No Content` (sign-out, deletes).
88 pub async fn delete(&self, path: &str) -> Result<()> {
89 self.send_empty(self.request(Method::DELETE, path)).await
90 }
91
92 /// Escape hatch for `si api` — an arbitrary method and path against the API
93 /// root, returning the raw JSON body.
94 pub async fn raw(
95 &self,
96 method: Method,
97 path: &str,
98 body: Option<serde_json::Value>,
99 ) -> Result<serde_json::Value> {
100 let mut builder = self.request(method, path);
101 if let Some(body) = body {
102 builder = builder.json(&body);
103 }
104
105 let response = self.execute(builder).await?;
106 let status = response.status();
107 let text = response.text().await.unwrap_or_default();
108
109 // A raw call reports the body as-is, whether or not the status is 2xx —
110 // seeing the server's actual error JSON is the point of the command.
111 // Only auth failures are translated, so the caller can clear the token.
112 if status == StatusCode::UNAUTHORIZED {
113 return Err(ApiError::Unauthorized);
114 }
115 if text.trim().is_empty() {
116 return Ok(serde_json::Value::Null);
117 }
118 serde_json::from_str(&text).map_err(|e| ApiError::Parse(format!("{e}: {text}")))
119 }
120
121 // ── Plumbing ────────────────────────────────────────────────────────────
122
123 /// Build a request against `<api_url>/<path>`, carrying the bearer token.
124 pub fn request(&self, method: Method, path: &str) -> RequestBuilder {
125 let url = format!("{}/{}", self.api_url, path.trim_start_matches('/'));
126 let builder = self.http.request(method, url);
127 match &self.token {
128 Some(token) => builder.bearer_auth(token),
129 None => builder,
130 }
131 }
132
133 /// Send a request and deserialize a JSON body.
134 pub async fn send<T: DeserializeOwned>(&self, builder: RequestBuilder) -> Result<T> {
135 let response = self.execute(builder).await?;
136 let status = response.status();
137 let text = response.text().await.unwrap_or_default();
138
139 if !status.is_success() {
140 return Err(Self::error_from(status, &text));
141 }
142
143 serde_json::from_str(&text).map_err(|e| ApiError::Parse(format!("{e}: {text}")))
144 }
145
146 /// Send a request that returns no useful body (`204`, or a `200` we ignore).
147 pub async fn send_empty(&self, builder: RequestBuilder) -> Result<()> {
148 let response = self.execute(builder).await?;
149 let status = response.status();
150
151 if status.is_success() {
152 return Ok(());
153 }
154
155 let text = response.text().await.unwrap_or_default();
156 Err(Self::error_from(status, &text))
157 }
158
159 async fn execute(&self, builder: RequestBuilder) -> Result<reqwest::Response> {
160 if self.token.is_none() {
161 debug!("sending an unauthenticated request to {}", self.api_url);
162 }
163
164 builder.send().await.map_err(|source| ApiError::Transport {
165 host: self.base_url.clone(),
166 source,
167 })
168 }
169
170 /// Turn a non-2xx response into the most specific error we can. The body is
171 /// usually an `ErrorResponse`; when it isn't (a proxy's HTML 502, say), fall
172 /// back to the status line so the message is still actionable.
173 fn error_from(status: StatusCode, body: &str) -> ApiError {
174 if status == StatusCode::UNAUTHORIZED {
175 return ApiError::Unauthorized;
176 }
177
178 match serde_json::from_str::<ErrorResponse>(body) {
179 Ok(error) => ApiError::Status {
180 status: status.as_u16(),
181 error_code: error.error_code,
182 message: error.message,
183 },
184 Err(_) => ApiError::Status {
185 status: status.as_u16(),
186 error_code: crate::error::codes::UNKNOWN,
187 message: format!(
188 "The server returned {}{}",
189 status,
190 truncate_for_message(body)
191 ),
192 },
193 }
194 }
195 }
196
197 /// Append a short excerpt of an unparseable body, so an HTML error page shows
198 /// something useful without dumping a full page into the terminal.
199 fn truncate_for_message(body: &str) -> String {
200 let body = body.trim();
201 if body.is_empty() {
202 return String::new();
203 }
204
205 const MAX: usize = 200;
206 let excerpt: String = body.chars().take(MAX).collect();
207 if body.chars().count() > MAX {
208 format!(": {excerpt}…")
209 } else {
210 format!(": {excerpt}")
211 }
212 }
213
214 #[cfg(test)]
215 mod tests {
216 use super::*;
217
218 #[test]
219 fn unauthorized_maps_to_the_dedicated_variant() {
220 let error = Client::error_from(StatusCode::UNAUTHORIZED, r#"{"error_code":100,"m":"x"}"#);
221 assert!(matches!(error, ApiError::Unauthorized));
222 }
223
224 #[test]
225 fn error_bodies_keep_the_servers_message_and_code() {
226 let body = r#"{"error_code":101,"message":"Repository not found."}"#;
227 match Client::error_from(StatusCode::NOT_FOUND, body) {
228 ApiError::Status {
229 status,
230 error_code,
231 message,
232 } => {
233 assert_eq!(status, 404);
234 assert_eq!(error_code, 101);
235 assert_eq!(message, "Repository not found.");
236 }
237 other => panic!("expected a Status error, got {other:?}"),
238 }
239 }
240
241 #[test]
242 fn non_json_bodies_fall_back_to_the_status_line() {
243 match Client::error_from(StatusCode::BAD_GATEWAY, "<html>nginx</html>") {
244 ApiError::Status { message, .. } => {
245 assert!(message.contains("502"), "{message}");
246 assert!(message.contains("nginx"), "{message}");
247 }
248 other => panic!("expected a Status error, got {other:?}"),
249 }
250 }
251
252 #[test]
253 fn long_html_bodies_are_truncated() {
254 let body = "x".repeat(1000);
255 let message = truncate_for_message(&body);
256 assert!(message.ends_with('…'));
257 assert!(message.chars().count() < 250);
258 }
259 }