use std::time::Duration;
use serde::Serialize;
use ureq::http::Response as HttpResponse;
use super::client::{AuthError, CanonClient, CanonError};
use super::types::{
CanonCatalogue, CanonCatalogueEntry, CanonMatchRequest, CanonMatchResponse, Serving,
};
use super::Token;
pub const REQUEST_TIMEOUT_SECS: u64 = 8;
pub struct HttpCanonClient {
base_url: String,
repo: String,
bearer_header: String,
agent: ureq::Agent,
}
impl HttpCanonClient {
pub fn new(base_url: impl Into<String>, token: &Token, repo: impl Into<String>) -> Self {
let base_url = base_url.into();
let repo = repo.into();
let bearer_header = format!("Bearer {}", token.as_str());
let config = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(REQUEST_TIMEOUT_SECS)))
.user_agent(format!("aristo/{}", env!("CARGO_PKG_VERSION")))
.http_status_as_error(false)
.build();
let agent: ureq::Agent = config.into();
Self {
base_url,
repo,
bearer_header,
agent,
}
}
fn url(&self, path: &str) -> String {
format!("{}/{}/api{}", self.base_url, self.repo, path)
}
fn post_json<Req, Resp>(&self, path: &str, body: &Req) -> Result<Resp, CanonError>
where
Req: Serialize,
Resp: for<'de> serde::Deserialize<'de>,
{
let url = self.url(path);
let result = self
.agent
.post(&url)
.header("Authorization", &self.bearer_header)
.header("Content-Type", "application/json")
.send_json(body);
consume_response(result).map_err(|e| at_server(e, &self.base_url))
}
}
impl std::fmt::Debug for HttpCanonClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpCanonClient")
.field("base_url", &self.base_url)
.field("repo", &self.repo)
.field("bearer_header", &"Bearer <redacted>")
.finish()
}
}
impl CanonClient for HttpCanonClient {
fn match_annotations(&self, req: &CanonMatchRequest) -> Result<CanonMatchResponse, CanonError> {
self.post_json("/canon/match", req)
}
fn catalogue(&self) -> Result<CanonCatalogue, CanonError> {
let url = self.url("/catalogue");
let result = self
.agent
.get(&url)
.header("Authorization", &self.bearer_header)
.call();
let serving = result.as_ref().ok().and_then(serving_from_headers);
let entries: Vec<CanonCatalogueEntry> =
consume_response(result).map_err(|e| at_server(e, &self.base_url))?;
Ok(CanonCatalogue { entries, serving })
}
}
fn serving_from_headers(resp: &HttpResponse<ureq::Body>) -> Option<Serving> {
let get = |name: &str| resp.headers().get(name).and_then(|v| v.to_str().ok());
Serving::from_headers(
get(Serving::HEADER),
get(Serving::EDITION_HEADER),
get(Serving::REASON_HEADER),
)
}
fn at_server(e: CanonError, server: &str) -> CanonError {
match e {
CanonError::Auth(a) => CanonError::Auth(a.at_server(server)),
other => other,
}
}
fn consume_response<T>(
result: Result<HttpResponse<ureq::Body>, ureq::Error>,
) -> Result<T, CanonError>
where
T: for<'de> serde::Deserialize<'de>,
{
match result {
Ok(mut resp) => {
let status = resp.status().as_u16();
let body = resp
.body_mut()
.read_to_string()
.map_err(|e| CanonError::Decode(format!("read body: {e}")))?;
map_response(status, &body)
}
Err(e) => Err(transport_error_to_canon_error(e)),
}
}
pub(crate) fn map_response<T>(status: u16, body: &str) -> Result<T, CanonError>
where
T: for<'de> serde::Deserialize<'de>,
{
match status {
200..=299 => serde_json::from_str(body)
.map_err(|e| CanonError::Decode(format!("parse 2xx body: {e}"))),
401 => Err(CanonError::Auth(AuthError::rejected())),
400..=499 => Err(CanonError::BadRequest {
status,
message: extract_message_or_body(body),
}),
500..=599 => Err(CanonError::Server {
status,
message: extract_message_or_body(body),
}),
other => Err(CanonError::Server {
status: other,
message: format!("unexpected status code {other}"),
}),
}
}
pub(crate) fn transport_error_to_canon_error(e: ureq::Error) -> CanonError {
let s = e.to_string();
if s.contains("timed out") || s.contains("timeout") {
CanonError::Timeout
} else {
CanonError::Network(s)
}
}
fn extract_message_or_body(body: &str) -> String {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(s) = v.get("error").and_then(|x| x.as_str()) {
return s.to_string();
}
if let Some(s) = v.get("message").and_then(|x| x.as_str()) {
return s.to_string();
}
}
let trimmed = body.trim();
if trimmed.len() > 500 {
format!("{}…", &trimmed[..500])
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canon::types::{CanonMatch, PrefixTier, VerificationMetadata};
fn sample_match_response_json() -> String {
let resp = CanonMatchResponse {
results: vec![vec![CanonMatch {
canon_id: "foo".into(),
version: "v0.1.0".into(),
canonical_text: "foo".into(),
confidence: 0.9,
scope: ":vanilla".into(),
prefix_tier: PrefixTier::Kanon,
backed_by: None,
linked: Some("arta_xyz".into()),
verification: VerificationMetadata {
coverage_level: "none".into(),
test_binaries: vec![],
instrumentation: None,
},
}]],
effective_scopes: vec![":vanilla".into()],
canon_version: "v0.2.0".into(),
matched_at: "2026-06-15T09:14:22Z".into(),
suggestions: None,
};
serde_json::to_string(&resp).unwrap()
}
#[test]
fn map_response_200_decodes_match_response() {
let body = sample_match_response_json();
let resp: CanonMatchResponse = map_response(200, &body).unwrap();
assert_eq!(resp.canon_version, "v0.2.0");
assert_eq!(resp.results[0][0].canon_id, "foo");
}
#[test]
fn map_response_2xx_other_codes_also_decode() {
let body = sample_match_response_json();
let resp: CanonMatchResponse = map_response(201, &body).unwrap();
assert_eq!(resp.canon_version, "v0.2.0");
let resp: CanonMatchResponse = map_response(299, &body).unwrap();
assert_eq!(resp.canon_version, "v0.2.0");
}
#[test]
fn map_response_2xx_garbage_body_is_decode_error() {
let err: Result<CanonMatchResponse, _> = map_response(200, "not json");
let err = err.unwrap_err();
assert!(matches!(err, CanonError::Decode(_)));
}
#[test]
fn map_response_401_maps_to_auth_invalid() {
let err: Result<CanonMatchResponse, _> = map_response(401, "{}");
let err = err.unwrap_err();
assert!(matches!(err, CanonError::Auth(AuthError::Invalid { .. })));
}
#[test]
fn map_response_400_with_json_error_field_extracts_message() {
let body = r#"{"error": "confidence_threshold below floor 0.5"}"#;
let err: Result<CanonMatchResponse, _> = map_response(400, body);
let err = err.unwrap_err();
match err {
CanonError::BadRequest { status, message } => {
assert_eq!(status, 400);
assert!(message.contains("0.5"));
}
other => panic!("expected BadRequest, got {other:?}"),
}
}
#[test]
fn map_response_400_with_json_message_field_extracts_message() {
let body = r#"{"message": "missing annotations"}"#;
let err: Result<CanonMatchResponse, _> = map_response(400, body);
match err.unwrap_err() {
CanonError::BadRequest {
status: 400,
message,
} => {
assert!(message.contains("missing"));
}
other => panic!("expected BadRequest 400, got {other:?}"),
}
}
#[test]
fn map_response_400_with_plain_text_passes_through_body() {
let err: Result<CanonMatchResponse, _> = map_response(400, "raw text reason");
match err.unwrap_err() {
CanonError::BadRequest {
status: 400,
message,
} => {
assert_eq!(message, "raw text reason");
}
other => panic!("expected BadRequest 400, got {other:?}"),
}
}
#[test]
fn map_response_500_maps_to_server_error() {
let err: Result<CanonMatchResponse, _> =
map_response(500, r#"{"error": "internal server bug"}"#);
match err.unwrap_err() {
CanonError::Server {
status: 500,
message,
} => {
assert!(message.contains("internal"));
}
other => panic!("expected Server 500, got {other:?}"),
}
}
#[test]
fn map_response_503_maps_to_server_error() {
let err: Result<CanonMatchResponse, _> = map_response(503, "");
assert!(matches!(
err.unwrap_err(),
CanonError::Server { status: 503, .. }
));
}
#[test]
fn map_response_truncates_huge_body() {
let huge = "x".repeat(2000);
let err: Result<CanonMatchResponse, _> = map_response(400, &huge);
match err.unwrap_err() {
CanonError::BadRequest { message, .. } => {
assert!(
message.len() < 1000,
"expected truncation, got {} chars",
message.len()
);
assert!(message.ends_with('…'));
}
other => panic!("expected BadRequest, got {other:?}"),
}
}
#[test]
fn http_client_construction_does_not_panic() {
let tok = Token::new("test-token");
let c = HttpCanonClient::new("https://example.test", &tok, "widgets");
assert_eq!(c.base_url, "https://example.test");
assert_eq!(c.bearer_header, "Bearer test-token");
}
#[test]
fn http_client_debug_redacts_token() {
let tok = Token::new("super-secret-do-not-log");
let c = HttpCanonClient::new("https://example.test", &tok, "widgets");
let s = format!("{c:?}");
assert!(
!s.contains("super-secret-do-not-log"),
"Debug must not leak token: {s}"
);
assert!(s.contains("redacted"));
}
#[test]
fn http_client_url_construction() {
let tok = Token::new("t");
let c = HttpCanonClient::new("https://api.example.test", &tok, "widgets");
assert_eq!(
c.url("/canon/match"),
"https://api.example.test/widgets/api/canon/match"
);
assert_eq!(
c.url("/canon/entry/foo"),
"https://api.example.test/widgets/api/canon/entry/foo"
);
assert_eq!(
c.url("/catalogue"),
"https://api.example.test/widgets/api/catalogue"
);
}
#[test]
fn http_client_is_send_and_object_safe() {
let tok = Token::new("t");
let _boxed: Box<dyn CanonClient> = Box::new(HttpCanonClient::new(
"https://example.test",
&tok,
"widgets",
));
}
}