use std::fmt;
use std::time::Duration;
use reqwest::Client;
use reqwest::header::{self, HeaderMap, HeaderName, HeaderValue};
use secrecy::{ExposeSecret, SecretString};
use tracing::{debug, instrument};
use url::Url;
use crate::error::{ClientError, HttpError, Result};
use crate::models::{
AgentDetails, AgentRegistrationRequest, AgentResolutionRequest, AgentResolutionResponse,
AgentRevocationRequest, AgentRevocationResponse, AgentSearchResponse, AgentStatus,
CertificateResponse, CsrStatusResponse, CsrSubmissionRequest, CsrSubmissionResponse,
EventPageResponse, RegistrationPending, RevocationReason, SearchCriteria,
};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Clone)]
#[non_exhaustive]
pub enum Auth {
Jwt(SecretString),
ApiKey {
key: String,
secret: SecretString,
},
}
impl fmt::Debug for Auth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Jwt(_) => f.debug_tuple("Jwt").field(&"[REDACTED]").finish(),
Self::ApiKey { key, .. } => f
.debug_struct("ApiKey")
.field("key", key)
.field("secret", &"[REDACTED]")
.finish(),
}
}
}
impl Auth {
fn header_value(&self) -> SecretString {
match self {
Self::Jwt(token) => SecretString::from(format!("sso-jwt {}", token.expose_secret())),
Self::ApiKey { key, secret } => {
SecretString::from(format!("sso-key {key}:{}", secret.expose_secret()))
}
}
}
}
#[derive(Debug)]
pub struct AnsClientBuilder {
base_url: Option<String>,
auth: Option<Auth>,
timeout: Duration,
extra_headers: Vec<(String, String)>,
allow_insecure: bool,
}
impl Default for AnsClientBuilder {
fn default() -> Self {
Self::new()
}
}
impl AnsClientBuilder {
pub fn new() -> Self {
Self {
base_url: None,
auth: None,
timeout: DEFAULT_TIMEOUT,
extra_headers: Vec::new(),
allow_insecure: false,
}
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn jwt(mut self, token: impl Into<String>) -> Self {
self.auth = Some(Auth::Jwt(SecretString::from(token.into())));
self
}
pub fn api_key(mut self, key: impl Into<String>, secret: impl Into<String>) -> Self {
self.auth = Some(Auth::ApiKey {
key: key.into(),
secret: SecretString::from(secret.into()),
});
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_headers.push((name.into(), value.into()));
self
}
pub fn headers(
mut self,
headers: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
) -> Self {
self.extra_headers
.extend(headers.into_iter().map(|(n, v)| (n.into(), v.into())));
self
}
pub fn allow_insecure(mut self) -> Self {
self.allow_insecure = true;
self
}
pub fn build(self) -> Result<AnsClient> {
let base_url = self
.base_url
.unwrap_or_else(|| "https://api.godaddy.com".to_string());
let base_url = Url::parse(&base_url).map_err(|e| ClientError::InvalidUrl(e.to_string()))?;
if !self.allow_insecure && base_url.scheme() != "https" {
return Err(ClientError::Configuration(format!(
"base URL must use HTTPS (got \"{}\"). \
Use .allow_insecure() to permit non-HTTPS URLs for local development.",
base_url.scheme()
)));
}
let http_client = Client::builder()
.timeout(self.timeout)
.build()
.map_err(|e| ClientError::Configuration(format!("failed to build HTTP client: {e}")))?;
let mut extra_headers = HeaderMap::new();
for (name, value) in self.extra_headers {
let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
ClientError::Configuration(format!("invalid header name '{name}': {e}"))
})?;
let header_value = HeaderValue::from_str(&value).map_err(|e| {
ClientError::Configuration(format!("invalid header value for '{name}': {e}"))
})?;
extra_headers.insert(header_name, header_value);
}
Ok(AnsClient {
base_url,
auth: self.auth,
http_client,
extra_headers,
})
}
}
#[derive(Debug, Clone)]
pub struct AnsClient {
base_url: Url,
auth: Option<Auth>,
http_client: Client,
extra_headers: HeaderMap,
}
impl AnsClient {
pub fn builder() -> AnsClientBuilder {
AnsClientBuilder::new()
}
pub fn new() -> Result<Self> {
Self::builder().build()
}
fn url(&self, path: &str) -> Result<Url> {
self.base_url
.join(path)
.map_err(|e| ClientError::InvalidUrl(e.to_string()))
}
fn build_request(&self, method: &str, path: &str) -> Result<reqwest::RequestBuilder> {
let url = self.url(path)?;
let mut req = match method {
"GET" => self.http_client.get(url),
"POST" => self.http_client.post(url),
"PUT" => self.http_client.put(url),
"DELETE" => self.http_client.delete(url),
_ => {
return Err(ClientError::Configuration(format!(
"unsupported method: {method}"
)));
}
};
req = req.header(header::ACCEPT, "application/json").header(
header::USER_AGENT,
format!("ans-client/{}", env!("CARGO_PKG_VERSION")),
);
if let Some(auth) = &self.auth {
req = req.header(header::AUTHORIZATION, auth.header_value().expose_secret());
}
for (name, value) in &self.extra_headers {
req = req.header(name, value);
}
Ok(req)
}
async fn send<T: serde::de::DeserializeOwned>(
&self,
req: reqwest::RequestBuilder,
) -> Result<T> {
let response = req.send().await.map_err(HttpError::from)?;
let status = response.status();
if status.is_success() {
let body_text = response.text().await.map_err(HttpError::from)?;
serde_json::from_str(&body_text).map_err(|e| {
debug!(error = %e, body = %&body_text[..body_text.len().min(200)], "JSON deserialization failed");
ClientError::Json(e)
})
} else {
let body = response.text().await.map_err(HttpError::from)?;
Err(ClientError::from_response(status.as_u16(), &body))
}
}
#[instrument(skip(self, body), fields(method = %method, path = %path))]
async fn request<T, B>(&self, method: &str, path: &str, body: Option<&B>) -> Result<T>
where
T: serde::de::DeserializeOwned,
B: serde::Serialize,
{
let mut req = self.build_request(method, path)?;
if let Some(body) = body {
req = req
.header(header::CONTENT_TYPE, "application/json")
.json(body);
} else if method == "POST" || method == "PUT" || method == "PATCH" {
req = req.header(header::CONTENT_LENGTH, "0");
}
self.send(req).await
}
#[instrument(skip(self, request), fields(agent_host = %request.agent_host))]
pub async fn register_agent(
&self,
request: &AgentRegistrationRequest,
) -> Result<RegistrationPending> {
self.request("POST", "/v1/agents/register", Some(request))
.await
}
#[instrument(skip(self))]
pub async fn get_agent(&self, agent_id: &str) -> Result<AgentDetails> {
let path = format!("/v1/agents/{}", urlencoding::encode(agent_id));
self.request("GET", &path, None::<&()>).await
}
#[instrument(skip(self))]
pub async fn search_agents(
&self,
criteria: &SearchCriteria,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<AgentSearchResponse> {
let mut query: Vec<(&str, String)> = Vec::new();
if let Some(name) = &criteria.agent_display_name {
query.push(("agentDisplayName", name.clone()));
}
if let Some(host) = &criteria.agent_host {
query.push(("agentHost", host.clone()));
}
if let Some(version) = &criteria.version {
query.push(("version", version.clone()));
}
if let Some(protocol) = &criteria.protocol {
query.push(("protocol", protocol.to_string()));
}
if let Some(limit) = limit {
query.push(("limit", limit.to_string()));
}
if let Some(offset) = offset {
query.push(("offset", offset.to_string()));
}
let req = self.build_request("GET", "/v1/agents")?.query(&query);
self.send(req).await
}
#[instrument(skip(self))]
pub async fn resolve_agent(
&self,
agent_host: &str,
version: &str,
) -> Result<AgentResolutionResponse> {
let request = AgentResolutionRequest {
agent_host: agent_host.to_string(),
version: version.to_string(),
};
self.request("POST", "/v1/agents/resolution", Some(&request))
.await
}
#[instrument(skip(self))]
pub async fn verify_acme(&self, agent_id: &str) -> Result<AgentStatus> {
let path = format!("/v1/agents/{}/verify-acme", urlencoding::encode(agent_id));
self.request("POST", &path, None::<&()>).await
}
#[instrument(skip(self))]
pub async fn verify_dns(&self, agent_id: &str) -> Result<AgentStatus> {
let path = format!("/v1/agents/{}/verify-dns", urlencoding::encode(agent_id));
self.request("POST", &path, None::<&()>).await
}
#[instrument(skip(self))]
pub async fn get_server_certificates(
&self,
agent_id: &str,
) -> Result<Vec<CertificateResponse>> {
let path = format!(
"/v1/agents/{}/certificates/server",
urlencoding::encode(agent_id)
);
self.request("GET", &path, None::<&()>).await
}
#[instrument(skip(self))]
pub async fn get_identity_certificates(
&self,
agent_id: &str,
) -> Result<Vec<CertificateResponse>> {
let path = format!(
"/v1/agents/{}/certificates/identity",
urlencoding::encode(agent_id)
);
self.request("GET", &path, None::<&()>).await
}
#[instrument(skip(self, csr_pem))]
pub async fn submit_server_csr(
&self,
agent_id: &str,
csr_pem: &str,
) -> Result<CsrSubmissionResponse> {
let path = format!(
"/v1/agents/{}/certificates/server",
urlencoding::encode(agent_id)
);
let request = CsrSubmissionRequest {
csr_pem: csr_pem.to_string(),
};
self.request("POST", &path, Some(&request)).await
}
#[instrument(skip(self, csr_pem))]
pub async fn submit_identity_csr(
&self,
agent_id: &str,
csr_pem: &str,
) -> Result<CsrSubmissionResponse> {
let path = format!(
"/v1/agents/{}/certificates/identity",
urlencoding::encode(agent_id)
);
let request = CsrSubmissionRequest {
csr_pem: csr_pem.to_string(),
};
self.request("POST", &path, Some(&request)).await
}
#[instrument(skip(self))]
pub async fn get_csr_status(&self, agent_id: &str, csr_id: &str) -> Result<CsrStatusResponse> {
let path = format!(
"/v1/agents/{}/csrs/{}/status",
urlencoding::encode(agent_id),
urlencoding::encode(csr_id)
);
self.request("GET", &path, None::<&()>).await
}
#[instrument(skip(self))]
pub async fn revoke_agent(
&self,
agent_id: &str,
reason: RevocationReason,
comments: Option<&str>,
) -> Result<AgentRevocationResponse> {
let path = format!("/v1/agents/{}/revoke", urlencoding::encode(agent_id));
let request = AgentRevocationRequest {
reason,
comments: comments.map(String::from),
};
self.request("POST", &path, Some(&request)).await
}
#[instrument(skip(self))]
pub async fn get_events(
&self,
limit: Option<u32>,
provider_id: Option<&str>,
last_log_id: Option<&str>,
) -> Result<EventPageResponse> {
let mut query: Vec<(&str, String)> = Vec::new();
if let Some(limit) = limit {
query.push(("limit", limit.to_string()));
}
if let Some(provider_id) = provider_id {
query.push(("providerId", provider_id.to_string()));
}
if let Some(last_log_id) = last_log_id {
query.push(("lastLogId", last_log_id.to_string()));
}
let req = self
.build_request("GET", "/v1/agents/events")?
.query(&query);
self.send(req).await
}
}
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_header() {
let jwt = Auth::Jwt(SecretString::from("token123"));
assert_eq!(jwt.header_value().expose_secret(), "sso-jwt token123");
let api_key = Auth::ApiKey {
key: "mykey".into(),
secret: SecretString::from("mysecret"),
};
assert_eq!(
api_key.header_value().expose_secret(),
"sso-key mykey:mysecret"
);
}
#[test]
fn test_auth_debug_redacts_secrets() {
let jwt = Auth::Jwt(SecretString::from("super-secret-token"));
let debug_output = format!("{:?}", jwt);
assert!(
!debug_output.contains("super-secret-token"),
"JWT token must not appear in Debug output"
);
assert!(debug_output.contains("[REDACTED]"));
let api_key = Auth::ApiKey {
key: "mykey".into(),
secret: SecretString::from("top-secret"),
};
let debug_output = format!("{:?}", api_key);
assert!(
!debug_output.contains("top-secret"),
"API secret must not appear in Debug output"
);
assert!(
debug_output.contains("mykey"),
"API key (non-secret) should appear in Debug output"
);
assert!(debug_output.contains("[REDACTED]"));
}
#[test]
fn test_builder_defaults() {
let client = AnsClient::builder().build().unwrap();
assert_eq!(client.base_url.as_str(), "https://api.godaddy.com/");
assert!(client.auth.is_none());
}
#[test]
fn test_builder_custom_url() {
let client = AnsClient::builder()
.base_url("https://api.godaddy.com")
.jwt("mytoken")
.build()
.unwrap();
assert_eq!(client.base_url.as_str(), "https://api.godaddy.com/");
assert!(matches!(client.auth, Some(Auth::Jwt(_))));
}
#[test]
fn test_builder_api_key() {
let client = AnsClient::builder()
.api_key("mykey", "mysecret")
.build()
.unwrap();
match &client.auth {
Some(Auth::ApiKey { key, secret }) => {
assert_eq!(key, "mykey");
assert_eq!(secret.expose_secret(), "mysecret");
}
_ => panic!("Expected Auth::ApiKey"),
}
}
#[test]
fn test_event_type_display() {
use crate::models::EventType;
assert_eq!(EventType::AgentRegistered.to_string(), "AGENT_REGISTERED");
assert_eq!(EventType::AgentRenewed.to_string(), "AGENT_RENEWED");
assert_eq!(EventType::AgentRevoked.to_string(), "AGENT_REVOKED");
assert_eq!(
EventType::AgentVersionUpdated.to_string(),
"AGENT_VERSION_UPDATED"
);
}
#[test]
fn test_builder_timeout() {
let client = AnsClient::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
assert_eq!(client.base_url.as_str(), "https://api.godaddy.com/");
}
#[test]
fn test_builder_custom_header() {
let client = AnsClient::builder()
.header("x-request-id", "test-123")
.build()
.unwrap();
assert_eq!(
client.extra_headers.get("x-request-id").unwrap(),
"test-123"
);
}
#[test]
fn test_builder_multiple_headers() {
let client = AnsClient::builder()
.headers([("x-correlation-id", "corr-456"), ("x-source", "test")])
.build()
.unwrap();
assert_eq!(
client.extra_headers.get("x-correlation-id").unwrap(),
"corr-456"
);
assert_eq!(client.extra_headers.get("x-source").unwrap(), "test");
}
#[test]
fn test_builder_invalid_header_name() {
let result = AnsClient::builder()
.header("invalid header\0name", "value")
.build();
assert!(matches!(result, Err(ClientError::Configuration(_))));
}
#[test]
fn test_builder_invalid_url() {
let result = AnsClient::builder().base_url("not a valid url").build();
assert!(result.is_err());
}
#[test]
fn test_new_default_client() {
let client = AnsClient::new().unwrap();
assert_eq!(client.base_url.as_str(), "https://api.godaddy.com/");
assert!(client.auth.is_none());
}
#[test]
fn test_builder_rejects_http_url() {
let result = AnsClient::builder()
.base_url("http://api.example.com")
.build();
match result {
Err(ClientError::Configuration(msg)) => {
assert!(msg.contains("HTTPS"), "error should mention HTTPS: {msg}");
}
other => panic!("expected Configuration error, got: {other:?}"),
}
}
#[test]
fn test_builder_allow_insecure_permits_http() {
let client = AnsClient::builder()
.base_url("http://localhost:8080")
.allow_insecure()
.build()
.unwrap();
assert_eq!(client.base_url.scheme(), "http");
}
#[test]
fn test_builder_https_url_always_accepted() {
let client = AnsClient::builder()
.base_url("https://api.example.com")
.build()
.unwrap();
assert_eq!(client.base_url.scheme(), "https");
}
}