use std::time::Duration;
use serde::Serialize;
use ureq::http::Response as HttpResponse;
use super::client::{VerifyClient, VerifyError};
use super::types::{GetVerifySessionResponse, PostVerifySessionResponse, VerifySessionRequest};
use crate::auth::{AuthError, Token};
pub const DEFAULT_BASE_URL: &str = crate::auth::ServerUrl::PROD;
pub const REQUEST_TIMEOUT_SECS: u64 = 60;
pub struct HttpVerifyClient {
base_url: String,
bearer_header: String,
agent: ureq::Agent,
}
impl HttpVerifyClient {
pub fn new(base_url: impl Into<String>, token: &Token) -> Self {
let base_url = base_url.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,
bearer_header,
agent,
}
}
pub fn production(token: &Token) -> Self {
Self::new(DEFAULT_BASE_URL, token)
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
fn post_json<Req, Resp>(&self, path: &str, body: &Req) -> Result<Resp, VerifyError>
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)
}
fn get_json<Resp>(&self, path: &str) -> Result<Resp, VerifyError>
where
Resp: for<'de> serde::Deserialize<'de>,
{
let url = self.url(path);
let result = self
.agent
.get(&url)
.header("Authorization", &self.bearer_header)
.call();
consume_response(result)
}
}
impl std::fmt::Debug for HttpVerifyClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpVerifyClient")
.field("base_url", &self.base_url)
.field("bearer_header", &"Bearer <redacted>")
.finish()
}
}
impl VerifyClient for HttpVerifyClient {
fn post_session(
&self,
req: &VerifySessionRequest,
) -> Result<PostVerifySessionResponse, VerifyError> {
self.post_json("/verify/sessions", req)
}
fn get_session(
&self,
session_id: &str,
wait_seconds: Option<u32>,
) -> Result<GetVerifySessionResponse, VerifyError> {
let encoded = url_encode(session_id);
let path = match wait_seconds {
Some(n) if n > 0 => format!("/verify/sessions/{encoded}?wait={n}"),
_ => format!("/verify/sessions/{encoded}"),
};
self.get_json(&path)
}
}
fn consume_response<T>(
result: Result<HttpResponse<ureq::Body>, ureq::Error>,
) -> Result<T, VerifyError>
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| VerifyError::Decode(format!("read body: {e}")))?;
map_response(status, &body)
}
Err(e) => Err(transport_error_to_verify_error(e)),
}
}
pub(crate) fn map_response<T>(status: u16, body: &str) -> Result<T, VerifyError>
where
T: for<'de> serde::Deserialize<'de>,
{
match status {
200..=299 => serde_json::from_str(body)
.map_err(|e| VerifyError::Decode(format!("parse 2xx body: {e}"))),
401 => Err(VerifyError::Auth(AuthError::Invalid)),
400..=499 => Err(VerifyError::BadRequest {
status,
message: extract_message_or_body(body),
}),
500..=599 => Err(VerifyError::Server {
status,
message: extract_message_or_body(body),
}),
other => Err(VerifyError::Server {
status: other,
message: format!("unexpected status code {other}"),
}),
}
}
pub(crate) fn transport_error_to_verify_error(e: ureq::Error) -> VerifyError {
let s = e.to_string();
if s.contains("timed out") || s.contains("timeout") {
VerifyError::Timeout
} else {
VerifyError::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()
}
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
let safe = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
if safe {
out.push(b as char);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canon_verify::types::VerifySessionTag;
fn sample_post_response_json() -> String {
let resp = PostVerifySessionResponse {
session_id: "01HN1234567890".into(),
view_url: "https://dev.aretta.ai/dashboard/jobs/01HN1234567890".into(),
plan_size: 2,
};
serde_json::to_string(&resp).unwrap()
}
#[test]
fn map_response_2xx_decodes_post() {
let body = sample_post_response_json();
let resp: PostVerifySessionResponse = map_response(202, &body).unwrap();
assert_eq!(resp.session_id, "01HN1234567890");
assert_eq!(resp.plan_size, 2);
}
#[test]
fn map_response_2xx_garbage_is_decode_error() {
let err: Result<PostVerifySessionResponse, _> = map_response(200, "not json");
assert!(matches!(err.unwrap_err(), VerifyError::Decode(_)));
}
#[test]
fn map_response_401_maps_to_auth_invalid() {
let err: Result<PostVerifySessionResponse, _> = map_response(401, "{}");
assert!(matches!(
err.unwrap_err(),
VerifyError::Auth(AuthError::Invalid)
));
}
#[test]
fn map_response_402_no_canon_coverage_is_bad_request_with_message() {
let err: Result<PostVerifySessionResponse, _> = map_response(
402,
r#"{"error": "no_canon_coverage", "message": "no canon coverage applies for your scopes"}"#,
);
match err.unwrap_err() {
VerifyError::BadRequest { status, message } => {
assert_eq!(status, 402);
assert!(message.contains("no_canon_coverage"));
}
other => panic!("expected BadRequest 402, got {other:?}"),
}
}
#[test]
fn map_response_400_no_eligible_tags() {
let err: Result<PostVerifySessionResponse, _> =
map_response(400, r#"{"error": "no_eligible_tags"}"#);
match err.unwrap_err() {
VerifyError::BadRequest { status, message } => {
assert_eq!(status, 400);
assert!(message.contains("no_eligible_tags"));
}
other => panic!("expected BadRequest 400, got {other:?}"),
}
}
#[test]
fn map_response_404_for_get_session() {
let err: Result<GetVerifySessionResponse, _> =
map_response(404, r#"{"error": "not_found"}"#);
match err.unwrap_err() {
VerifyError::BadRequest {
status: 404,
message,
} => {
assert!(message.contains("not_found"));
}
other => panic!("expected BadRequest 404, got {other:?}"),
}
}
#[test]
fn map_response_5xx_is_server_error() {
let err: Result<PostVerifySessionResponse, _> =
map_response(503, r#"{"error": "upstream timeout"}"#);
match err.unwrap_err() {
VerifyError::Server {
status: 503,
message,
} => {
assert!(message.contains("upstream"));
}
other => panic!("expected Server 503, got {other:?}"),
}
}
#[test]
fn http_client_construction_does_not_panic() {
let tok = Token::new("test-token");
let c = HttpVerifyClient::new("https://example.test", &tok);
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 = HttpVerifyClient::new("https://example.test", &tok);
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_is_send_and_object_safe() {
let tok = Token::new("t");
let _: Box<dyn VerifyClient> =
Box::new(HttpVerifyClient::new("https://example.test", &tok));
}
#[test]
fn url_encode_handles_session_id_charset() {
assert_eq!(
url_encode("01HN1234567890ABCDEFGHJKMN"),
"01HN1234567890ABCDEFGHJKMN"
);
assert_eq!(
url_encode("a1b2c3d4-e5f6-7890-1234-567890abcdef"),
"a1b2c3d4-e5f6-7890-1234-567890abcdef"
);
assert_eq!(url_encode("foo bar"), "foo%20bar");
}
#[allow(dead_code)]
fn _request_shape_is_reachable() {
let _ = VerifySessionRequest {
repo_full_name: String::new(),
commit_sha: String::new(),
tags: vec![VerifySessionTag {
annotation_id: String::new(),
canon_id: String::new(),
version: String::new(),
source_path: String::new(),
}],
};
}
}