1use std::{fmt, io::Write};
2
3use bytes::Bytes;
4use flate2::{Compression, write::GzEncoder};
5use miette::{IntoDiagnostic, Result, WrapErr};
6use reqwest::Url;
7
8use crate::{
9 reqwest_transport::ReqwestTransport,
10 transport::{CanopyResponse, CanopyTransport},
11};
12
13#[derive(Debug, Clone)]
20pub struct CanopyHttpError {
21 pub status: reqwest::StatusCode,
23 pub path: String,
25 pub body: String,
27}
28
29impl fmt::Display for CanopyHttpError {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 write!(
32 f,
33 "canopy {} returned {}: {}",
34 self.path, self.status, self.body
35 )
36 }
37}
38
39impl std::error::Error for CanopyHttpError {}
40impl miette::Diagnostic for CanopyHttpError {}
41
42pub struct CanopyClient<T = ReqwestTransport> {
57 transport: T,
58}
59
60impl<T> fmt::Debug for CanopyClient<T> {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 f.debug_struct("CanopyClient").finish_non_exhaustive()
63 }
64}
65
66impl CanopyClient<ReqwestTransport> {
67 pub async fn new(
79 device_key_pem: Option<&str>,
80 make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
81 ) -> Result<Option<Self>> {
82 Self::with_urls(
83 crate::DEFAULT_CANOPY_URL
84 .parse()
85 .expect("default canopy URL is valid"),
86 crate::TAILSCALE_URL
87 .parse()
88 .expect("default tailscale URL is valid"),
89 device_key_pem,
90 make_builder,
91 )
92 .await
93 }
94
95 pub async fn with_urls(
102 base_url: Url,
103 tailscale_url: Url,
104 device_key_pem: Option<&str>,
105 make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
106 ) -> Result<Option<Self>> {
107 Ok(
108 ReqwestTransport::new(base_url, tailscale_url, device_key_pem, make_builder)
109 .await?
110 .map(Self::with_transport),
111 )
112 }
113
114 pub async fn is_tailscale(&self) -> bool {
116 self.transport.is_tailscale().await
117 }
118
119 pub async fn refresh(&self) -> Result<()> {
123 self.transport.refresh().await
124 }
125
126 pub async fn renew(&self) -> Result<()> {
132 self.transport.renew().await
133 }
134
135 #[cfg(feature = "raw-requests")]
141 pub async fn get(&self, tailscale_path: &str, mtls_path: &str) -> Result<reqwest::Response> {
142 self.transport.get(tailscale_path, mtls_path).await
143 }
144
145 #[cfg(feature = "raw-requests")]
151 pub async fn request(
152 &self,
153 method: reqwest::Method,
154 path: &str,
155 ) -> Result<reqwest::RequestBuilder> {
156 self.transport.request(method, path).await
157 }
158}
159
160impl<T: CanopyTransport> CanopyClient<T> {
161 pub fn with_transport(transport: T) -> Self {
169 Self { transport }
170 }
171
172 pub fn transport(&self) -> &T {
174 &self.transport
175 }
176
177 async fn send_call<B: serde::Serialize + ?Sized>(
186 &self,
187 method: reqwest::Method,
188 path: &str,
189 body: Option<&B>,
190 ) -> Result<CanopyResponse> {
191 let mut request = http::Request::builder().method(method).uri(path);
192 let body = match body {
193 Some(body) => {
194 let raw = serde_json::to_vec(body)
195 .into_diagnostic()
196 .wrap_err_with(|| format!("serialising canopy {path} body"))?;
197 let compressed = gzip_bytes(&raw)
198 .into_diagnostic()
199 .wrap_err_with(|| format!("gzipping canopy {path} body"))?;
200 request = request
201 .header(reqwest::header::CONTENT_TYPE, "application/json")
202 .header(reqwest::header::CONTENT_ENCODING, "gzip");
203 Bytes::from(compressed)
204 }
205 None => Bytes::new(),
206 };
207
208 let request = request
209 .body(body)
210 .into_diagnostic()
211 .wrap_err_with(|| format!("building canopy {path} request"))?;
212
213 let response = self
214 .transport
215 .call(request)
216 .await
217 .wrap_err_with(|| format!("calling canopy {path}"))?;
218
219 let status = response.status();
220 if !status.is_success() {
221 return Err(miette::Report::new(CanopyHttpError {
222 status,
223 path: path.to_owned(),
224 body: String::from_utf8_lossy(response.body()).into_owned(),
225 }));
226 }
227 Ok(response)
228 }
229
230 pub(crate) async fn call_json<B, R>(
232 &self,
233 method: reqwest::Method,
234 path: &str,
235 body: Option<&B>,
236 ) -> Result<R>
237 where
238 B: serde::Serialize + ?Sized,
239 R: serde::de::DeserializeOwned,
240 {
241 let response = self.send_call(method, path, body).await?;
242 serde_json::from_slice(response.body())
243 .into_diagnostic()
244 .wrap_err_with(|| format!("parsing canopy {path} response"))
245 }
246
247 pub(crate) async fn call_empty<B: serde::Serialize + ?Sized>(
249 &self,
250 method: reqwest::Method,
251 path: &str,
252 body: Option<&B>,
253 ) -> Result<()> {
254 self.send_call(method, path, body).await.map(drop)
255 }
256
257 #[cfg(feature = "raw-requests")]
264 pub async fn request_json<Res: serde::de::DeserializeOwned>(
265 &self,
266 method: reqwest::Method,
267 path: &str,
268 body: Option<&(impl serde::Serialize + ?Sized)>,
269 ) -> Result<Res> {
270 self.call_json(method, path, body).await
271 }
272}
273
274fn gzip_bytes(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
275 let mut encoder = GzEncoder::new(Vec::with_capacity(bytes.len() / 2), Compression::default());
276 encoder.write_all(bytes)?;
277 encoder.finish()
278}
279
280#[cfg(test)]
281mod tests {
282 use std::sync::Mutex;
283
284 use crate::{
285 DEFAULT_CANOPY_URL,
286 test_support::{closed_url, serve_once},
287 transport::CanopyRequest,
288 };
289
290 use super::*;
291
292 fn mtls_client_against(base: &str) -> CanopyClient {
293 CanopyClient::with_transport(ReqwestTransport::mtls_for_tests(base))
294 }
295
296 #[derive(Debug, serde::Deserialize, PartialEq)]
297 struct Echo {
298 ok: bool,
299 who: String,
300 }
301
302 #[tokio::test]
305 async fn with_urls_builds_on_the_default_transport() {
306 let (tailnet, _server) = serve_once("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n[]");
307 let client = CanopyClient::with_urls(
308 DEFAULT_CANOPY_URL.parse().unwrap(),
309 tailnet.parse().unwrap(),
310 None,
311 reqwest::Client::builder,
312 )
313 .await
314 .expect("keyless build should not error")
315 .expect("a reachable tailnet is an auth path in its own right");
316 assert!(client.is_tailscale().await);
317 client.renew().await.expect("renew should be a no-op");
318 }
319
320 #[tokio::test]
321 async fn with_urls_yields_no_client_without_an_auth_path() {
322 let client = CanopyClient::with_urls(
323 DEFAULT_CANOPY_URL.parse().unwrap(),
324 closed_url().parse().unwrap(),
325 None,
326 reqwest::Client::builder,
327 )
328 .await
329 .expect("keyless build should not error");
330 assert!(client.is_none());
331 }
332
333 #[tokio::test]
334 async fn call_json_gzips_body_sets_user_agent_and_parses_response() {
335 let (base, handle) = serve_once(
336 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 26\r\n\r\n{\"ok\":true,\"who\":\"device\"}",
337 );
338 let client = mtls_client_against(&base);
339
340 let payload = serde_json::json!({ "hello": "world" });
341 let got: Echo = client
342 .call_json(reqwest::Method::POST, "/thing", Some(&payload))
343 .await
344 .expect("call_json should succeed");
345
346 assert_eq!(
347 got,
348 Echo {
349 ok: true,
350 who: "device".into()
351 }
352 );
353
354 let captured = handle.join().unwrap();
355 assert!(
356 captured.request_line.starts_with("POST /thing "),
357 "unexpected request line: {}",
358 captured.request_line
359 );
360 let headers = captured.headers.to_ascii_lowercase();
361 assert!(
362 headers.contains("user-agent: bestool-canopy/"),
363 "missing canopy user-agent in:\n{}",
364 captured.headers
365 );
366 assert!(
367 headers.contains("content-encoding: gzip"),
368 "body should be gzipped:\n{}",
369 captured.headers
370 );
371 let sent: serde_json::Value = serde_json::from_slice(&gunzip(&captured.body)).unwrap();
373 assert_eq!(sent, payload);
374 }
375
376 #[tokio::test]
377 async fn call_json_errors_on_non_success_with_body() {
378 let (base, handle) =
379 serve_once("HTTP/1.1 418 I'm a teapot\r\nContent-Length: 14\r\n\r\nno coffee here");
380 let client = mtls_client_against(&base);
381
382 let err = client
383 .call_json::<(), serde_json::Value>(reqwest::Method::GET, "/brew", None::<&()>)
384 .await
385 .expect_err("non-2xx should error");
386 let msg = err.to_string();
387 assert!(msg.contains("/brew"), "expected path in error: {msg}");
388 assert!(msg.contains("418"), "expected status in error: {msg}");
389 assert!(
390 msg.contains("no coffee here"),
391 "expected body text in error: {msg}"
392 );
393
394 handle.join().unwrap();
395 }
396
397 #[derive(Default)]
400 struct StubTransport {
401 seen: Mutex<Vec<CanopyRequest>>,
402 response: Option<CanopyResponse>,
403 }
404
405 impl StubTransport {
406 fn responding(status: u16, body: &str) -> Self {
407 Self {
408 seen: Mutex::default(),
409 response: Some(
410 http::Response::builder()
411 .status(status)
412 .body(Bytes::copy_from_slice(body.as_bytes()))
413 .unwrap(),
414 ),
415 }
416 }
417
418 fn took(&self) -> Vec<CanopyRequest> {
419 std::mem::take(&mut *self.seen.lock().unwrap())
420 }
421 }
422
423 #[async_trait::async_trait]
424 impl CanopyTransport for StubTransport {
425 async fn call(&self, request: CanopyRequest) -> Result<CanopyResponse> {
426 self.seen.lock().unwrap().push(request);
427 match &self.response {
428 Some(response) => {
429 let mut clone = http::Response::new(response.body().clone());
430 *clone.status_mut() = response.status();
431 *clone.headers_mut() = response.headers().clone();
432 Ok(clone)
433 }
434 None => Err(miette::miette!("this transport cannot reach canopy")),
435 }
436 }
437 }
438
439 #[tokio::test]
440 async fn a_custom_transport_carries_the_typed_calls() {
441 let client = CanopyClient::with_transport(StubTransport::responding(
442 200,
443 r#"{"ok":true,"who":"stub"}"#,
444 ));
445
446 let payload = serde_json::json!({ "hello": "world" });
447 let got: Echo = client
448 .call_json(reqwest::Method::POST, "/thing", Some(&payload))
449 .await
450 .expect("the typed machinery should run on any transport");
451 assert_eq!(
452 got,
453 Echo {
454 ok: true,
455 who: "stub".into()
456 }
457 );
458
459 let seen = client.transport().took();
460 let [request] = &seen[..] else {
461 panic!("expected exactly one request, got {}", seen.len());
462 };
463 assert_eq!(request.method(), reqwest::Method::POST);
464 assert_eq!(request.uri(), "/thing");
467 assert_eq!(
468 request.headers().get(reqwest::header::CONTENT_ENCODING),
469 Some(&reqwest::header::HeaderValue::from_static("gzip"))
470 );
471 assert_eq!(
472 serde_json::from_slice::<serde_json::Value>(&gunzip(request.body())).unwrap(),
473 payload
474 );
475 }
476
477 #[tokio::test]
478 async fn a_custom_transport_sees_generated_endpoint_methods() {
479 let client = CanopyClient::with_transport(StubTransport::responding(200, "[]"));
482 let servers = client
483 .servers()
484 .await
485 .expect("generated methods work on any transport");
486 assert!(servers.is_empty());
487
488 let seen = client.transport().took();
489 let [request] = &seen[..] else {
490 panic!("expected exactly one request, got {}", seen.len());
491 };
492 assert_eq!(request.method(), reqwest::Method::GET);
493 assert_eq!(request.uri(), "/servers");
494 assert!(request.body().is_empty(), "a GET should carry no body");
495 assert!(
496 request
497 .headers()
498 .get(reqwest::header::CONTENT_TYPE)
499 .is_none(),
500 "a bodyless request shouldn't claim a content type"
501 );
502 }
503
504 #[tokio::test]
505 async fn a_custom_transport_maps_non_success_to_canopy_http_error() {
506 let client =
507 CanopyClient::with_transport(StubTransport::responding(412, "device is dormant"));
508 let err = client
509 .backup_target()
510 .await
511 .expect_err("412 is not a success");
512 let http_err = err
513 .downcast_ref::<CanopyHttpError>()
514 .expect("non-2xx from any transport surfaces as CanopyHttpError");
515 assert_eq!(http_err.status, reqwest::StatusCode::PRECONDITION_FAILED);
516 assert_eq!(http_err.path, "/backup-target");
517 assert_eq!(http_err.body, "device is dormant");
518 }
519
520 #[tokio::test]
521 async fn a_custom_transport_error_is_reported_with_the_path() {
522 let client = CanopyClient::with_transport(StubTransport::default());
523 let err = client.tags().await.expect_err("the stub always fails");
524 let chain = format!("{err:?}");
525 assert!(chain.contains("/tags"), "expected path in report: {chain}");
526 assert!(
527 chain.contains("cannot reach canopy"),
528 "expected the transport's own error in report: {chain}"
529 );
530 }
531
532 #[tokio::test]
533 async fn a_boxed_transport_is_a_transport() {
534 let client: CanopyClient<Box<dyn CanopyTransport>> =
536 CanopyClient::with_transport(Box::new(StubTransport::responding(200, "[]")));
537 assert!(client.servers().await.unwrap().is_empty());
538 }
539
540 fn gunzip(bytes: &[u8]) -> Vec<u8> {
541 use flate2::read::GzDecoder;
542 use std::io::Read as _;
543
544 let mut out = Vec::new();
545 GzDecoder::new(bytes)
546 .read_to_end(&mut out)
547 .expect("body should be valid gzip");
548 out
549 }
550
551 #[test]
552 fn gzip_bytes_roundtrips() {
553 let original = br#"{"health":[{"check":"x","result":"passed"}]}"#;
554 let compressed = gzip_bytes(original).expect("gzip should succeed");
555 assert!(
556 compressed.starts_with(&[0x1f, 0x8b]),
557 "expected gzip magic bytes"
558 );
559 assert_eq!(gunzip(&compressed), original);
560 }
561}