use std::{fmt, io::Write};
use bytes::Bytes;
use flate2::{Compression, write::GzEncoder};
use miette::{IntoDiagnostic, Result, WrapErr};
use reqwest::Url;
use crate::{
reqwest_transport::ReqwestTransport,
transport::{CanopyResponse, CanopyTransport},
};
#[derive(Debug, Clone)]
pub struct CanopyHttpError {
pub status: reqwest::StatusCode,
pub path: String,
pub body: String,
}
impl fmt::Display for CanopyHttpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"canopy {} returned {}: {}",
self.path, self.status, self.body
)
}
}
impl std::error::Error for CanopyHttpError {}
impl miette::Diagnostic for CanopyHttpError {}
pub struct CanopyClient<T = ReqwestTransport> {
transport: T,
}
impl<T> fmt::Debug for CanopyClient<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CanopyClient").finish_non_exhaustive()
}
}
impl CanopyClient<ReqwestTransport> {
pub async fn new(
device_key_pem: Option<&str>,
make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
) -> Result<Option<Self>> {
Self::with_urls(
crate::DEFAULT_CANOPY_URL
.parse()
.expect("default canopy URL is valid"),
crate::TAILSCALE_URL
.parse()
.expect("default tailscale URL is valid"),
device_key_pem,
make_builder,
)
.await
}
pub async fn with_urls(
base_url: Url,
tailscale_url: Url,
device_key_pem: Option<&str>,
make_builder: impl Fn() -> reqwest::ClientBuilder + Send + Sync + 'static,
) -> Result<Option<Self>> {
Ok(
ReqwestTransport::new(base_url, tailscale_url, device_key_pem, make_builder)
.await?
.map(Self::with_transport),
)
}
pub async fn is_tailscale(&self) -> bool {
self.transport.is_tailscale().await
}
pub async fn refresh(&self) -> Result<()> {
self.transport.refresh().await
}
pub async fn renew(&self) -> Result<()> {
self.transport.renew().await
}
#[cfg(feature = "raw-requests")]
pub async fn get(&self, tailscale_path: &str, mtls_path: &str) -> Result<reqwest::Response> {
self.transport.get(tailscale_path, mtls_path).await
}
#[cfg(feature = "raw-requests")]
pub async fn request(
&self,
method: reqwest::Method,
path: &str,
) -> Result<reqwest::RequestBuilder> {
self.transport.request(method, path).await
}
}
impl<T: CanopyTransport> CanopyClient<T> {
pub fn with_transport(transport: T) -> Self {
Self { transport }
}
pub fn transport(&self) -> &T {
&self.transport
}
async fn send_call<B: serde::Serialize + ?Sized>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&B>,
) -> Result<CanopyResponse> {
let mut request = http::Request::builder().method(method).uri(path);
let body = match body {
Some(body) => {
let raw = serde_json::to_vec(body)
.into_diagnostic()
.wrap_err_with(|| format!("serialising canopy {path} body"))?;
let compressed = gzip_bytes(&raw)
.into_diagnostic()
.wrap_err_with(|| format!("gzipping canopy {path} body"))?;
request = request
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::CONTENT_ENCODING, "gzip");
Bytes::from(compressed)
}
None => Bytes::new(),
};
let request = request
.body(body)
.into_diagnostic()
.wrap_err_with(|| format!("building canopy {path} request"))?;
let response = self
.transport
.call(request)
.await
.wrap_err_with(|| format!("calling canopy {path}"))?;
let status = response.status();
if !status.is_success() {
return Err(miette::Report::new(CanopyHttpError {
status,
path: path.to_owned(),
body: String::from_utf8_lossy(response.body()).into_owned(),
}));
}
Ok(response)
}
pub(crate) async fn call_json<B, R>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&B>,
) -> Result<R>
where
B: serde::Serialize + ?Sized,
R: serde::de::DeserializeOwned,
{
let response = self.send_call(method, path, body).await?;
serde_json::from_slice(response.body())
.into_diagnostic()
.wrap_err_with(|| format!("parsing canopy {path} response"))
}
pub(crate) async fn call_empty<B: serde::Serialize + ?Sized>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&B>,
) -> Result<()> {
self.send_call(method, path, body).await.map(drop)
}
#[cfg(feature = "raw-requests")]
pub async fn request_json<Res: serde::de::DeserializeOwned>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&(impl serde::Serialize + ?Sized)>,
) -> Result<Res> {
self.call_json(method, path, body).await
}
}
fn gzip_bytes(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
let mut encoder = GzEncoder::new(Vec::with_capacity(bytes.len() / 2), Compression::default());
encoder.write_all(bytes)?;
encoder.finish()
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use crate::{
DEFAULT_CANOPY_URL,
test_support::{closed_url, serve_once},
transport::CanopyRequest,
};
use super::*;
fn mtls_client_against(base: &str) -> CanopyClient {
CanopyClient::with_transport(ReqwestTransport::mtls_for_tests(base))
}
#[derive(Debug, serde::Deserialize, PartialEq)]
struct Echo {
ok: bool,
who: String,
}
#[tokio::test]
async fn with_urls_builds_on_the_default_transport() {
let (tailnet, _server) = serve_once("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n[]");
let client = CanopyClient::with_urls(
DEFAULT_CANOPY_URL.parse().unwrap(),
tailnet.parse().unwrap(),
None,
reqwest::Client::builder,
)
.await
.expect("keyless build should not error")
.expect("a reachable tailnet is an auth path in its own right");
assert!(client.is_tailscale().await);
client.renew().await.expect("renew should be a no-op");
}
#[tokio::test]
async fn with_urls_yields_no_client_without_an_auth_path() {
let client = CanopyClient::with_urls(
DEFAULT_CANOPY_URL.parse().unwrap(),
closed_url().parse().unwrap(),
None,
reqwest::Client::builder,
)
.await
.expect("keyless build should not error");
assert!(client.is_none());
}
#[tokio::test]
async fn call_json_gzips_body_sets_user_agent_and_parses_response() {
let (base, handle) = serve_once(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 26\r\n\r\n{\"ok\":true,\"who\":\"device\"}",
);
let client = mtls_client_against(&base);
let payload = serde_json::json!({ "hello": "world" });
let got: Echo = client
.call_json(reqwest::Method::POST, "/thing", Some(&payload))
.await
.expect("call_json should succeed");
assert_eq!(
got,
Echo {
ok: true,
who: "device".into()
}
);
let captured = handle.join().unwrap();
assert!(
captured.request_line.starts_with("POST /thing "),
"unexpected request line: {}",
captured.request_line
);
let headers = captured.headers.to_ascii_lowercase();
assert!(
headers.contains("user-agent: bestool-canopy/"),
"missing canopy user-agent in:\n{}",
captured.headers
);
assert!(
headers.contains("content-encoding: gzip"),
"body should be gzipped:\n{}",
captured.headers
);
let sent: serde_json::Value = serde_json::from_slice(&gunzip(&captured.body)).unwrap();
assert_eq!(sent, payload);
}
#[tokio::test]
async fn call_json_errors_on_non_success_with_body() {
let (base, handle) =
serve_once("HTTP/1.1 418 I'm a teapot\r\nContent-Length: 14\r\n\r\nno coffee here");
let client = mtls_client_against(&base);
let err = client
.call_json::<(), serde_json::Value>(reqwest::Method::GET, "/brew", None::<&()>)
.await
.expect_err("non-2xx should error");
let msg = err.to_string();
assert!(msg.contains("/brew"), "expected path in error: {msg}");
assert!(msg.contains("418"), "expected status in error: {msg}");
assert!(
msg.contains("no coffee here"),
"expected body text in error: {msg}"
);
handle.join().unwrap();
}
#[derive(Default)]
struct StubTransport {
seen: Mutex<Vec<CanopyRequest>>,
response: Option<CanopyResponse>,
}
impl StubTransport {
fn responding(status: u16, body: &str) -> Self {
Self {
seen: Mutex::default(),
response: Some(
http::Response::builder()
.status(status)
.body(Bytes::copy_from_slice(body.as_bytes()))
.unwrap(),
),
}
}
fn took(&self) -> Vec<CanopyRequest> {
std::mem::take(&mut *self.seen.lock().unwrap())
}
}
#[async_trait::async_trait]
impl CanopyTransport for StubTransport {
async fn call(&self, request: CanopyRequest) -> Result<CanopyResponse> {
self.seen.lock().unwrap().push(request);
match &self.response {
Some(response) => {
let mut clone = http::Response::new(response.body().clone());
*clone.status_mut() = response.status();
*clone.headers_mut() = response.headers().clone();
Ok(clone)
}
None => Err(miette::miette!("this transport cannot reach canopy")),
}
}
}
#[tokio::test]
async fn a_custom_transport_carries_the_typed_calls() {
let client = CanopyClient::with_transport(StubTransport::responding(
200,
r#"{"ok":true,"who":"stub"}"#,
));
let payload = serde_json::json!({ "hello": "world" });
let got: Echo = client
.call_json(reqwest::Method::POST, "/thing", Some(&payload))
.await
.expect("the typed machinery should run on any transport");
assert_eq!(
got,
Echo {
ok: true,
who: "stub".into()
}
);
let seen = client.transport().took();
let [request] = &seen[..] else {
panic!("expected exactly one request, got {}", seen.len());
};
assert_eq!(request.method(), reqwest::Method::POST);
assert_eq!(request.uri(), "/thing");
assert_eq!(
request.headers().get(reqwest::header::CONTENT_ENCODING),
Some(&reqwest::header::HeaderValue::from_static("gzip"))
);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&gunzip(request.body())).unwrap(),
payload
);
}
#[tokio::test]
async fn a_custom_transport_sees_generated_endpoint_methods() {
let client = CanopyClient::with_transport(StubTransport::responding(200, "[]"));
let servers = client
.servers()
.await
.expect("generated methods work on any transport");
assert!(servers.is_empty());
let seen = client.transport().took();
let [request] = &seen[..] else {
panic!("expected exactly one request, got {}", seen.len());
};
assert_eq!(request.method(), reqwest::Method::GET);
assert_eq!(request.uri(), "/servers");
assert!(request.body().is_empty(), "a GET should carry no body");
assert!(
request
.headers()
.get(reqwest::header::CONTENT_TYPE)
.is_none(),
"a bodyless request shouldn't claim a content type"
);
}
#[tokio::test]
async fn a_custom_transport_maps_non_success_to_canopy_http_error() {
let client =
CanopyClient::with_transport(StubTransport::responding(412, "device is dormant"));
let err = client
.backup_target()
.await
.expect_err("412 is not a success");
let http_err = err
.downcast_ref::<CanopyHttpError>()
.expect("non-2xx from any transport surfaces as CanopyHttpError");
assert_eq!(http_err.status, reqwest::StatusCode::PRECONDITION_FAILED);
assert_eq!(http_err.path, "/backup-target");
assert_eq!(http_err.body, "device is dormant");
}
#[tokio::test]
async fn a_custom_transport_error_is_reported_with_the_path() {
let client = CanopyClient::with_transport(StubTransport::default());
let err = client.tags().await.expect_err("the stub always fails");
let chain = format!("{err:?}");
assert!(chain.contains("/tags"), "expected path in report: {chain}");
assert!(
chain.contains("cannot reach canopy"),
"expected the transport's own error in report: {chain}"
);
}
#[tokio::test]
async fn a_boxed_transport_is_a_transport() {
let client: CanopyClient<Box<dyn CanopyTransport>> =
CanopyClient::with_transport(Box::new(StubTransport::responding(200, "[]")));
assert!(client.servers().await.unwrap().is_empty());
}
fn gunzip(bytes: &[u8]) -> Vec<u8> {
use flate2::read::GzDecoder;
use std::io::Read as _;
let mut out = Vec::new();
GzDecoder::new(bytes)
.read_to_end(&mut out)
.expect("body should be valid gzip");
out
}
#[test]
fn gzip_bytes_roundtrips() {
let original = br#"{"health":[{"check":"x","result":"passed"}]}"#;
let compressed = gzip_bytes(original).expect("gzip should succeed");
assert!(
compressed.starts_with(&[0x1f, 0x8b]),
"expected gzip magic bytes"
);
assert_eq!(gunzip(&compressed), original);
}
}