use crate::config::Config;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use serde::de::DeserializeOwned;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("{0}")]
Config(#[from] crate::config::ConfigError),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("API error ({status}): {message}")]
Api { status: u16, message: String },
}
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,
base_url: String,
token: String,
}
#[allow(dead_code)]
impl Client {
pub fn from_config() -> Result<Self, ApiError> {
let config = Config::load()?;
let base_url = config.server_url()?.to_string();
let token = config.token()?.to_string();
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()?;
Ok(Self {
http,
base_url,
token,
})
}
#[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}")
}
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
let resp = self.http.get(self.url(path)).send().await?;
self.handle_response(resp).await
}
pub async fn get_with_query<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, ApiError> {
let resp = self.http.get(self.url(path)).query(query).send().await?;
self.handle_response(resp).await
}
pub async fn post<T: DeserializeOwned>(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<T, ApiError> {
let resp = self.http.post(self.url(path)).json(body).send().await?;
self.handle_response(resp).await
}
pub async fn put<T: DeserializeOwned>(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<T, ApiError> {
let resp = self.http.put(self.url(path)).json(body).send().await?;
self.handle_response(resp).await
}
pub async fn patch<T: DeserializeOwned>(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<T, ApiError> {
let resp = self.http.patch(self.url(path)).json(body).send().await?;
self.handle_response(resp).await
}
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() {
Ok(())
} else {
Err(api_error_from(resp).await)
}
}
pub async fn post_empty(&self, path: &str) -> Result<(), ApiError> {
let resp = self
.http
.post(self.url(path))
.json(&serde_json::json!({}))
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
Err(api_error_from(resp).await)
}
}
pub async fn get_raw(&self, path: &str) -> Result<String, ApiError> {
let resp = self.http.get(self.url(path)).send().await?;
if resp.status().is_success() {
Ok(resp.text().await.unwrap_or_default())
} else {
Err(api_error_from(resp).await)
}
}
async fn handle_response<T: DeserializeOwned>(
&self,
resp: reqwest::Response,
) -> Result<T, ApiError> {
if resp.status().is_success() {
Ok(resp.json().await?)
} else {
Err(api_error_from(resp).await)
}
}
pub async fn get_sse_stream(&self, path: &str) -> Result<reqwest::Response, ApiError> {
let resp = self
.http
.get(self.url(path))
.header("Accept", "text/event-stream")
.send()
.await?;
if resp.status().is_success() {
Ok(resp)
} else {
Err(api_error_from(resp).await)
}
}
pub async fn upload_file(
&self,
path: &str,
file_path: &std::path::Path,
field_name: &str,
) -> Result<serde_json::Value, ApiError> {
let file_name = file_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let file_bytes = tokio::fs::read(file_path)
.await
.map_err(|e| ApiError::Api {
status: 0,
message: format!("Failed to read file: {e}"),
})?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name(file_name)
.mime_str("application/octet-stream")
.map_err(|e| ApiError::Api {
status: 0,
message: format!("Invalid MIME type: {e}"),
})?;
let form = reqwest::multipart::Form::new().part(field_name.to_string(), part);
let url = self.url(path);
let resp = self.http.post(&url).multipart(form).send().await?;
if resp.status().is_success() {
Ok(resp.json().await?)
} else {
Err(api_error_from(resp).await)
}
}
pub async fn download_file(
&self,
path: &str,
output: &std::path::Path,
) -> Result<(), ApiError> {
let resp = self.http.get(self.url(path)).send().await?;
if !resp.status().is_success() {
return Err(api_error_from(resp).await);
}
let bytes = resp.bytes().await?;
tokio::fs::write(output, &bytes)
.await
.map_err(|e| ApiError::Api {
status: 0,
message: format!("Failed to write file: {e}"),
})?;
Ok(())
}
pub fn base_url(&self) -> &str {
&self.base_url
}
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:?}"),
}
}
}