@@ -13,6 +13,28 @@
Api { status: u16, message: String },
}
/// Build an `ApiError::Api` from a non-success response, summarizing HTML
/// bodies so we don't paste an entire error page into the user's terminal.
async fn api_error_from(resp: reqwest::Response) -> ApiError {
let status = resp.status().as_u16();
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_lowercase();
let is_html =
content_type.starts_with("text/html") || content_type.starts_with("application/xhtml");
let body = resp.text().await.unwrap_or_default();
let message = if is_html {
"server returned an HTML error page (expected JSON)".to_string()
} else {
body
};
ApiError::Api { status, message }
}
#[allow(dead_code)]
pub struct Client {
http: reqwest::Client,
@@ -45,6 +67,29 @@
})
}
/// Construct a client for tests, pointing at an arbitrary base URL.
/// The base URL should be the bare server origin (e.g. `http://127.0.0.1:NNNN`);
/// the `/api/v1` prefix is added internally, mirroring `from_config`.
#[cfg(test)]
pub fn for_test(base_url: impl Into<String>, token: impl Into<String>) -> Self {
let token = token.into();
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
);
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let http = reqwest::Client::builder()
.default_headers(headers)
.build()
.expect("build test client");
Self {
http,
base_url: base_url.into(),
token,
}
}
fn url(&self, path: &str) -> String {
let base = self.base_url.trim_end_matches('/');
format!("{base}/api/v1{path}")
@@ -93,14 +138,9 @@
pub async fn delete_empty(&self, path: &str) -> Result<(), ApiError> {
let resp = self.http.delete(self.url(path)).send().await?;
if resp.status().is_success() {
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let text = resp.text().await.unwrap_or_default();
Err(ApiError::Api {
status: status.as_u16(),
message: text,
Err(api_error_from(resp).await)
})
}
}
@@ -112,29 +152,19 @@
.json(&serde_json::json!({}))
.send()
.await?;
let status = resp.status();
if status.is_success() {
if resp.status().is_success() {
Ok(())
} else {
let text = resp.text().await.unwrap_or_default();
Err(ApiError::Api {
status: status.as_u16(),
message: text,
Err(api_error_from(resp).await)
})
}
}
/// Raw GET that returns the response body as a string (for non-JSON endpoints).
pub async fn get_raw(&self, path: &str) -> Result<String, ApiError> {
let resp = self.http.get(self.url(path)).send().await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.is_success() {
Ok(text)
if resp.status().is_success() {
Ok(resp.text().await.unwrap_or_default())
} else {
Err(ApiError::Api {
Err(api_error_from(resp).await)
status: status.as_u16(),
message: text,
})
}
}
@@ -143,14 +173,9 @@
&self,
resp: reqwest::Response,
) -> Result<T, ApiError> {
let status = resp.status();
if status.is_success() {
if resp.status().is_success() {
Ok(resp.json().await?)
} else {
let text = resp.text().await.unwrap_or_default();
Err(ApiError::Api {
status: status.as_u16(),
message: text,
})
Err(api_error_from(resp).await)
}
}
@@ -163,14 +188,9 @@
.header("Accept", "text/event-stream")
.send()
.await?;
let status = resp.status();
if resp.status().is_success() {
if status.is_success() {
Ok(resp)
} else {
Err(api_error_from(resp).await)
let text = resp.text().await.unwrap_or_default();
Err(ApiError::Api {
status: status.as_u16(),
message: text,
})
}
}
@@ -209,14 +229,9 @@
let url = self.url(path);
let resp = self.http.post(&url).multipart(form).send().await?;
let status = resp.status();
if status.is_success() {
if resp.status().is_success() {
Ok(resp.json().await?)
} else {
let text = resp.text().await.unwrap_or_default();
Err(ApiError::Api {
status: status.as_u16(),
message: text,
})
Err(api_error_from(resp).await)
}
}
@@ -228,13 +243,8 @@
output: &std::path::Path,
) -> Result<(), ApiError> {
let resp = self.http.get(self.url(path)).send().await?;
if !resp.status().is_success() {
let status = resp.status();
return Err(api_error_from(resp).await);
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(ApiError::Api {
status: status.as_u16(),
message: text,
});
}
let bytes = resp.bytes().await?;
@@ -253,5 +263,195 @@
pub fn token(&self) -> &str {
&self.token
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[derive(Debug, Deserialize, PartialEq)]
struct Job {
id: String,
name: String,
}
fn html_error_page(status: u16) -> String {
format!(
"<!DOCTYPE html><html><head><title>{status} - Anvil</title></head>\
<body><h1>{status}</h1><p>Lorem ipsum dolor sit amet, way too long \
to paste into a terminal error message.</p></body></html>"
)
}
#[tokio::test]
async fn get_happy_path_parses_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/foo/bar/ci/jobs/abc"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"id": "abc", "name": "clippy"})),
)
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let job: Job = client.get("/foo/bar/ci/jobs/abc").await.unwrap();
assert_eq!(
job,
Job {
id: "abc".into(),
name: "clippy".into()
}
);
}
#[tokio::test]
async fn get_unhappy_json_body_is_surfaced_verbatim() {
let server = MockServer::start().await;
let body = r#"{"error":"not_found","message":"job missing"}"#;
Mock::given(method("GET"))
.and(path("/api/v1/foo/bar/ci/jobs/missing"))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_string(body),
)
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let err = client
.get::<serde_json::Value>("/foo/bar/ci/jobs/missing")
.await
.unwrap_err();
match err {
ApiError::Api { status, message } => {
assert_eq!(status, 404);
assert_eq!(message, body);
}
other => panic!("expected ApiError::Api, got {other:?}"),
}
}
#[tokio::test]
async fn get_unhappy_html_body_is_summarized() {
let server = MockServer::start().await;
let html = html_error_page(406);
Mock::given(method("GET"))
.and(path("/api/v1/foo/bar/ci/jobs/x/logs"))
.respond_with(
ResponseTemplate::new(406)
.set_body_raw(html.into_bytes(), "text/html; charset=utf-8"),
)
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let err = client
.get::<serde_json::Value>("/foo/bar/ci/jobs/x/logs")
.await
.unwrap_err();
match err {
ApiError::Api { status, message } => {
assert_eq!(status, 406);
assert!(
!message.contains("<!DOCTYPE"),
"HTML body leaked into error message: {message}"
);
assert!(
!message.contains("<html"),
"HTML body leaked into error message: {message}"
);
assert!(
message.to_lowercase().contains("html"),
"expected message to mention HTML; got: {message}"
);
}
other => panic!("expected ApiError::Api, got {other:?}"),
}
}
#[tokio::test]
async fn sse_stream_happy_path_returns_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/foo/bar/ci/jobs/x/logs"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string("event: done\ndata: {}\n\n"),
)
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let resp = client
.get_sse_stream("/foo/bar/ci/jobs/x/logs")
.await
.unwrap();
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn sse_stream_unhappy_html_body_is_summarized() {
let server = MockServer::start().await;
let html = html_error_page(406);
Mock::given(method("GET"))
.and(path("/api/v1/foo/bar/ci/jobs/x/logs"))
.respond_with(
ResponseTemplate::new(406)
.set_body_raw(html.into_bytes(), "text/html; charset=utf-8"),
)
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let err = client
.get_sse_stream("/foo/bar/ci/jobs/x/logs")
.await
.unwrap_err();
match err {
ApiError::Api { status, message } => {
assert_eq!(status, 406);
assert!(
!message.contains("<!DOCTYPE") && !message.contains("<html"),
"HTML body leaked into SSE error message: {message}"
);
}
other => panic!("expected ApiError::Api, got {other:?}"),
}
}
#[tokio::test]
async fn sse_stream_unhappy_json_body_is_surfaced_verbatim() {
let server = MockServer::start().await;
let body = r#"{"error":"not_found"}"#;
Mock::given(method("GET"))
.and(path("/api/v1/foo/bar/ci/jobs/x/logs"))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_string(body),
)
.mount(&server)
.await;
let client = Client::for_test(server.uri(), "tok");
let err = client
.get_sse_stream("/foo/bar/ci/jobs/x/logs")
.await
.unwrap_err();
match err {
ApiError::Api { status, message } => {
assert_eq!(status, 404);
assert_eq!(message, body);
}
other => panic!("expected ApiError::Api, got {other:?}"),
}
}
}