Skip to main content

ans_client/
client.rs

1//! ANS Registry API client.
2//!
3//! The client provides methods for agent registration, certificate management,
4//! and agent discovery operations.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! use ans_client::{AnsClient, models::*};
10//!
11//! #[tokio::main]
12//! async fn main() -> ans_client::Result<()> {
13//!     let client = AnsClient::builder()
14//!         .base_url("https://api.godaddy.com")
15//!         .jwt("your-jwt-token")
16//!         .build()?;
17//!
18//!     let endpoint = AgentEndpoint::new("https://agent.example.com/mcp", Protocol::Mcp)
19//!         .with_transports(vec![Transport::StreamableHttp]);
20//!
21//!     let request = AgentRegistrationRequest::new(
22//!         "my-agent",
23//!         "agent.example.com",
24//!         "1.0.0",
25//!         "-----BEGIN CERTIFICATE REQUEST-----...",
26//!         vec![endpoint],
27//!     )
28//!     .with_description("My AI agent")
29//!     .with_server_csr_pem("-----BEGIN CERTIFICATE REQUEST-----...");
30//!
31//!     let pending = client.register_agent(&request).await?;
32//!     println!("Registered: {}", pending.ans_name);
33//!
34//!     Ok(())
35//! }
36//! ```
37
38use std::fmt;
39use std::time::Duration;
40
41use reqwest::Client;
42use reqwest::header::{self, HeaderMap, HeaderName, HeaderValue};
43use secrecy::{ExposeSecret, SecretString};
44use tracing::{debug, instrument};
45use url::Url;
46
47use crate::error::{ClientError, HttpError, Result};
48use crate::models::{
49    AgentDetails, AgentRegistrationRequest, AgentResolutionRequest, AgentResolutionResponse,
50    AgentRevocationRequest, AgentRevocationResponse, AgentSearchResponse, AgentStatus,
51    CertificateResponse, CsrStatusResponse, CsrSubmissionRequest, CsrSubmissionResponse,
52    EventPageResponse, RegistrationPending, RevocationReason, SearchCriteria,
53};
54
55/// Default request timeout.
56const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
57
58/// Authentication method for the ANS API.
59///
60/// Secrets are stored using [`SecretString`] which provides:
61/// - Zeroization on drop (secret bytes overwritten in memory)
62/// - Debug output prints `[REDACTED]` instead of the secret value
63/// - Explicit `.expose_secret()` required to access the value
64#[derive(Clone)]
65#[non_exhaustive]
66pub enum Auth {
67    /// JWT authentication for internal endpoints.
68    Jwt(SecretString),
69    /// API key authentication for public gateway.
70    ApiKey {
71        /// The API key identifier.
72        key: String,
73        /// The API key secret.
74        secret: SecretString,
75    },
76}
77
78impl fmt::Debug for Auth {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::Jwt(_) => f.debug_tuple("Jwt").field(&"[REDACTED]").finish(),
82            Self::ApiKey { key, .. } => f
83                .debug_struct("ApiKey")
84                .field("key", key)
85                .field("secret", &"[REDACTED]")
86                .finish(),
87        }
88    }
89}
90
91impl Auth {
92    fn header_value(&self) -> SecretString {
93        match self {
94            Self::Jwt(token) => SecretString::from(format!("sso-jwt {}", token.expose_secret())),
95            Self::ApiKey { key, secret } => {
96                SecretString::from(format!("sso-key {key}:{}", secret.expose_secret()))
97            }
98        }
99    }
100}
101
102/// Builder for constructing an [`AnsClient`].
103#[derive(Debug)]
104pub struct AnsClientBuilder {
105    base_url: Option<String>,
106    auth: Option<Auth>,
107    timeout: Duration,
108    extra_headers: Vec<(String, String)>,
109    allow_insecure: bool,
110}
111
112impl Default for AnsClientBuilder {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118impl AnsClientBuilder {
119    /// Create a new builder with default settings.
120    pub fn new() -> Self {
121        Self {
122            base_url: None,
123            auth: None,
124            timeout: DEFAULT_TIMEOUT,
125            extra_headers: Vec::new(),
126            allow_insecure: false,
127        }
128    }
129
130    /// Set the base URL for API requests.
131    ///
132    /// # Examples
133    ///
134    /// ```rust
135    /// use ans_client::AnsClient;
136    ///
137    /// let client = AnsClient::builder()
138    ///     .base_url("https://api.godaddy.com")
139    ///     .build();
140    /// ```
141    pub fn base_url(mut self, url: impl Into<String>) -> Self {
142        self.base_url = Some(url.into());
143        self
144    }
145
146    /// Set JWT authentication (for internal endpoints).
147    ///
148    /// Use this when connecting to internal RA endpoints like
149    /// `api.{env}-godaddy.com`.
150    pub fn jwt(mut self, token: impl Into<String>) -> Self {
151        self.auth = Some(Auth::Jwt(SecretString::from(token.into())));
152        self
153    }
154
155    /// Set API key authentication (for public gateway).
156    ///
157    /// Use this when connecting to public API gateway endpoints like
158    /// `api.godaddy.com`.
159    pub fn api_key(mut self, key: impl Into<String>, secret: impl Into<String>) -> Self {
160        self.auth = Some(Auth::ApiKey {
161            key: key.into(),
162            secret: SecretString::from(secret.into()),
163        });
164        self
165    }
166
167    /// Set the request timeout.
168    pub fn timeout(mut self, timeout: Duration) -> Self {
169        self.timeout = timeout;
170        self
171    }
172
173    /// Add a custom header to include with every request.
174    ///
175    /// Header names and values are validated when [`build()`](Self::build) is called.
176    /// Use this for API gateway headers, correlation IDs, or other
177    /// headers required by your environment.
178    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
179        self.extra_headers.push((name.into(), value.into()));
180        self
181    }
182
183    /// Add multiple custom headers to include with every request.
184    ///
185    /// Header names and values are validated when [`build()`](Self::build) is called.
186    pub fn headers(
187        mut self,
188        headers: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
189    ) -> Self {
190        self.extra_headers
191            .extend(headers.into_iter().map(|(n, v)| (n.into(), v.into())));
192        self
193    }
194
195    /// Allow insecure (non-HTTPS) base URLs.
196    ///
197    /// By default, the builder rejects `http://` base URLs because this SDK
198    /// sends authentication credentials (JWT tokens, API key secrets) in the
199    /// `Authorization` header on every request. Sending credentials over
200    /// plaintext HTTP is a security risk.
201    ///
202    /// Only use this for local development or testing against mock servers.
203    ///
204    /// # Example
205    ///
206    /// ```rust
207    /// use ans_client::AnsClient;
208    ///
209    /// let client = AnsClient::builder()
210    ///     .base_url("http://localhost:8080")
211    ///     .allow_insecure()
212    ///     .build()
213    ///     .unwrap();
214    /// ```
215    pub fn allow_insecure(mut self) -> Self {
216        self.allow_insecure = true;
217        self
218    }
219
220    /// Build the client.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the base URL is invalid, uses a non-HTTPS scheme
225    /// (unless [`allow_insecure`](Self::allow_insecure) is set), or if any
226    /// custom header names or values are invalid.
227    pub fn build(self) -> Result<AnsClient> {
228        let base_url = self
229            .base_url
230            .unwrap_or_else(|| "https://api.godaddy.com".to_string());
231
232        let base_url = Url::parse(&base_url).map_err(|e| ClientError::InvalidUrl(e.to_string()))?;
233
234        if !self.allow_insecure && base_url.scheme() != "https" {
235            return Err(ClientError::Configuration(format!(
236                "base URL must use HTTPS (got \"{}\"). \
237                 Use .allow_insecure() to permit non-HTTPS URLs for local development.",
238                base_url.scheme()
239            )));
240        }
241
242        let http_client = Client::builder()
243            .timeout(self.timeout)
244            .build()
245            .map_err(|e| ClientError::Configuration(format!("failed to build HTTP client: {e}")))?;
246
247        let mut extra_headers = HeaderMap::new();
248        for (name, value) in self.extra_headers {
249            let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
250                ClientError::Configuration(format!("invalid header name '{name}': {e}"))
251            })?;
252            let header_value = HeaderValue::from_str(&value).map_err(|e| {
253                ClientError::Configuration(format!("invalid header value for '{name}': {e}"))
254            })?;
255            extra_headers.insert(header_name, header_value);
256        }
257
258        Ok(AnsClient {
259            base_url,
260            auth: self.auth,
261            http_client,
262            extra_headers,
263        })
264    }
265}
266
267/// ANS Registry API client.
268///
269/// Provides methods for agent registration, certificate management,
270/// and agent discovery operations.
271#[derive(Debug, Clone)]
272pub struct AnsClient {
273    base_url: Url,
274    auth: Option<Auth>,
275    http_client: Client,
276    extra_headers: HeaderMap,
277}
278
279impl AnsClient {
280    /// Create a new builder for constructing a client.
281    pub fn builder() -> AnsClientBuilder {
282        AnsClientBuilder::new()
283    }
284
285    /// Create a client with default settings.
286    ///
287    /// Uses `https://api.godaddy.com` as the base URL with no authentication.
288    pub fn new() -> Result<Self> {
289        Self::builder().build()
290    }
291
292    /// Build the URL for a path.
293    fn url(&self, path: &str) -> Result<Url> {
294        self.base_url
295            .join(path)
296            .map_err(|e| ClientError::InvalidUrl(e.to_string()))
297    }
298
299    /// Build a request with common headers and authentication.
300    fn build_request(&self, method: &str, path: &str) -> Result<reqwest::RequestBuilder> {
301        let url = self.url(path)?;
302
303        let mut req = match method {
304            "GET" => self.http_client.get(url),
305            "POST" => self.http_client.post(url),
306            "PUT" => self.http_client.put(url),
307            "DELETE" => self.http_client.delete(url),
308            _ => {
309                return Err(ClientError::Configuration(format!(
310                    "unsupported method: {method}"
311                )));
312            }
313        };
314
315        req = req.header(header::ACCEPT, "application/json").header(
316            header::USER_AGENT,
317            format!("ans-client/{}", env!("CARGO_PKG_VERSION")),
318        );
319
320        if let Some(auth) = &self.auth {
321            req = req.header(header::AUTHORIZATION, auth.header_value().expose_secret());
322        }
323
324        for (name, value) in &self.extra_headers {
325            req = req.header(name, value);
326        }
327
328        Ok(req)
329    }
330
331    /// Send a request and deserialize the JSON response.
332    async fn send<T: serde::de::DeserializeOwned>(
333        &self,
334        req: reqwest::RequestBuilder,
335    ) -> Result<T> {
336        let response = req.send().await.map_err(HttpError::from)?;
337        let status = response.status();
338
339        if status.is_success() {
340            let body_text = response.text().await.map_err(HttpError::from)?;
341            serde_json::from_str(&body_text).map_err(|e| {
342                debug!(error = %e, body = %&body_text[..body_text.len().min(200)], "JSON deserialization failed");
343                ClientError::Json(e)
344            })
345        } else {
346            let body = response.text().await.map_err(HttpError::from)?;
347            Err(ClientError::from_response(status.as_u16(), &body))
348        }
349    }
350
351    /// Execute a request and deserialize the response.
352    #[instrument(skip(self, body), fields(method = %method, path = %path))]
353    async fn request<T, B>(&self, method: &str, path: &str, body: Option<&B>) -> Result<T>
354    where
355        T: serde::de::DeserializeOwned,
356        B: serde::Serialize,
357    {
358        let mut req = self.build_request(method, path)?;
359        if let Some(body) = body {
360            req = req
361                .header(header::CONTENT_TYPE, "application/json")
362                .json(body);
363        } else if method == "POST" || method == "PUT" || method == "PATCH" {
364            req = req.header(header::CONTENT_LENGTH, "0");
365        }
366        self.send(req).await
367    }
368
369    // =========================================================================
370    // Registration Operations
371    // =========================================================================
372
373    /// Register a new agent.
374    ///
375    /// Returns the pending registration details including required next steps
376    /// for completing registration (DNS configuration, domain validation, etc.).
377    ///
378    /// # Errors
379    ///
380    /// - [`ClientError::Conflict`] if the agent is already registered
381    /// - [`ClientError::InvalidRequest`] if the request is invalid
382    #[instrument(skip(self, request), fields(agent_host = %request.agent_host))]
383    pub async fn register_agent(
384        &self,
385        request: &AgentRegistrationRequest,
386    ) -> Result<RegistrationPending> {
387        self.request("POST", "/v1/agents/register", Some(request))
388            .await
389    }
390
391    /// Get agent details by ID.
392    ///
393    /// # Errors
394    ///
395    /// - [`ClientError::NotFound`] if the agent doesn't exist
396    #[instrument(skip(self))]
397    pub async fn get_agent(&self, agent_id: &str) -> Result<AgentDetails> {
398        let path = format!("/v1/agents/{}", urlencoding::encode(agent_id));
399        self.request("GET", &path, None::<&()>).await
400    }
401
402    /// Search for agents.
403    ///
404    /// # Arguments
405    ///
406    /// * `criteria` - Search criteria (display name, host, version, protocol)
407    /// * `limit` - Maximum results to return (1-100, default 20)
408    /// * `offset` - Number of results to skip for pagination
409    #[instrument(skip(self))]
410    pub async fn search_agents(
411        &self,
412        criteria: &SearchCriteria,
413        limit: Option<u32>,
414        offset: Option<u32>,
415    ) -> Result<AgentSearchResponse> {
416        let mut query: Vec<(&str, String)> = Vec::new();
417
418        if let Some(name) = &criteria.agent_display_name {
419            query.push(("agentDisplayName", name.clone()));
420        }
421        if let Some(host) = &criteria.agent_host {
422            query.push(("agentHost", host.clone()));
423        }
424        if let Some(version) = &criteria.version {
425            query.push(("version", version.clone()));
426        }
427        if let Some(protocol) = &criteria.protocol {
428            query.push(("protocol", protocol.to_string()));
429        }
430        if let Some(limit) = limit {
431            query.push(("limit", limit.to_string()));
432        }
433        if let Some(offset) = offset {
434            query.push(("offset", offset.to_string()));
435        }
436
437        let req = self.build_request("GET", "/v1/agents")?.query(&query);
438        self.send(req).await
439    }
440
441    /// Resolve an ANS name to agent details.
442    ///
443    /// # Arguments
444    ///
445    /// * `agent_host` - The agent's host domain
446    /// * `version` - Version pattern ("*" for any, or semver range like "^1.0.0")
447    #[instrument(skip(self))]
448    pub async fn resolve_agent(
449        &self,
450        agent_host: &str,
451        version: &str,
452    ) -> Result<AgentResolutionResponse> {
453        let request = AgentResolutionRequest {
454            agent_host: agent_host.to_string(),
455            version: version.to_string(),
456        };
457        self.request("POST", "/v1/agents/resolution", Some(&request))
458            .await
459    }
460
461    // =========================================================================
462    // Validation Operations
463    // =========================================================================
464
465    /// Trigger ACME domain validation.
466    ///
467    /// Call this after configuring the ACME challenge (DNS or HTTP).
468    ///
469    /// # Errors
470    ///
471    /// - [`ClientError::NotFound`] if the agent doesn't exist
472    /// - [`ClientError::InvalidRequest`] if validation fails
473    #[instrument(skip(self))]
474    pub async fn verify_acme(&self, agent_id: &str) -> Result<AgentStatus> {
475        let path = format!("/v1/agents/{}/verify-acme", urlencoding::encode(agent_id));
476        self.request("POST", &path, None::<&()>).await
477    }
478
479    /// Verify DNS records are configured correctly.
480    ///
481    /// Call this after configuring all required DNS records.
482    ///
483    /// # Errors
484    ///
485    /// - [`ClientError::NotFound`] if the agent doesn't exist
486    /// - [`ClientError::InvalidRequest`] if DNS verification fails
487    #[instrument(skip(self))]
488    pub async fn verify_dns(&self, agent_id: &str) -> Result<AgentStatus> {
489        let path = format!("/v1/agents/{}/verify-dns", urlencoding::encode(agent_id));
490        self.request("POST", &path, None::<&()>).await
491    }
492
493    // =========================================================================
494    // Certificate Operations
495    // =========================================================================
496
497    /// Get server certificates for an agent.
498    #[instrument(skip(self))]
499    pub async fn get_server_certificates(
500        &self,
501        agent_id: &str,
502    ) -> Result<Vec<CertificateResponse>> {
503        let path = format!(
504            "/v1/agents/{}/certificates/server",
505            urlencoding::encode(agent_id)
506        );
507        self.request("GET", &path, None::<&()>).await
508    }
509
510    /// Get identity certificates for an agent.
511    #[instrument(skip(self))]
512    pub async fn get_identity_certificates(
513        &self,
514        agent_id: &str,
515    ) -> Result<Vec<CertificateResponse>> {
516        let path = format!(
517            "/v1/agents/{}/certificates/identity",
518            urlencoding::encode(agent_id)
519        );
520        self.request("GET", &path, None::<&()>).await
521    }
522
523    /// Submit a server certificate CSR.
524    #[instrument(skip(self, csr_pem))]
525    pub async fn submit_server_csr(
526        &self,
527        agent_id: &str,
528        csr_pem: &str,
529    ) -> Result<CsrSubmissionResponse> {
530        let path = format!(
531            "/v1/agents/{}/certificates/server",
532            urlencoding::encode(agent_id)
533        );
534        let request = CsrSubmissionRequest {
535            csr_pem: csr_pem.to_string(),
536        };
537        self.request("POST", &path, Some(&request)).await
538    }
539
540    /// Submit an identity certificate CSR.
541    #[instrument(skip(self, csr_pem))]
542    pub async fn submit_identity_csr(
543        &self,
544        agent_id: &str,
545        csr_pem: &str,
546    ) -> Result<CsrSubmissionResponse> {
547        let path = format!(
548            "/v1/agents/{}/certificates/identity",
549            urlencoding::encode(agent_id)
550        );
551        let request = CsrSubmissionRequest {
552            csr_pem: csr_pem.to_string(),
553        };
554        self.request("POST", &path, Some(&request)).await
555    }
556
557    /// Get CSR status.
558    #[instrument(skip(self))]
559    pub async fn get_csr_status(&self, agent_id: &str, csr_id: &str) -> Result<CsrStatusResponse> {
560        let path = format!(
561            "/v1/agents/{}/csrs/{}/status",
562            urlencoding::encode(agent_id),
563            urlencoding::encode(csr_id)
564        );
565        self.request("GET", &path, None::<&()>).await
566    }
567
568    // =========================================================================
569    // Revocation Operations
570    // =========================================================================
571
572    /// Revoke an agent.
573    ///
574    /// This permanently revokes the agent's certificates and marks the
575    /// registration as revoked.
576    ///
577    /// # Errors
578    ///
579    /// - [`ClientError::NotFound`] if the agent doesn't exist
580    #[instrument(skip(self))]
581    pub async fn revoke_agent(
582        &self,
583        agent_id: &str,
584        reason: RevocationReason,
585        comments: Option<&str>,
586    ) -> Result<AgentRevocationResponse> {
587        let path = format!("/v1/agents/{}/revoke", urlencoding::encode(agent_id));
588        let request = AgentRevocationRequest {
589            reason,
590            comments: comments.map(String::from),
591        };
592        self.request("POST", &path, Some(&request)).await
593    }
594
595    // =========================================================================
596    // Event Operations
597    // =========================================================================
598
599    /// Get paginated agent events.
600    ///
601    /// This endpoint is used by Agent Host Providers (AHPs) to track agent
602    /// registration events across the system.
603    ///
604    /// # Arguments
605    ///
606    /// * `limit` - Maximum events to return (1-100)
607    /// * `provider_id` - Filter by provider ID (optional)
608    /// * `last_log_id` - Continuation token from previous response (for pagination)
609    ///
610    /// # Example
611    ///
612    /// ```rust,no_run
613    /// use ans_client::AnsClient;
614    ///
615    /// # async fn example() -> ans_client::Result<()> {
616    /// let client = AnsClient::builder()
617    ///     .base_url("https://api.godaddy.com")
618    ///     .api_key("key", "secret")
619    ///     .build()?;
620    ///
621    /// // Get first page
622    /// let page1 = client.get_events(Some(50), None, None).await?;
623    /// for event in &page1.items {
624    ///     println!("{}: {} - {}", event.event_type, event.ans_name, event.agent_host);
625    /// }
626    ///
627    /// // Get next page if available
628    /// if let Some(last_id) = page1.last_log_id {
629    ///     let page2 = client.get_events(Some(50), None, Some(&last_id)).await?;
630    /// }
631    /// # Ok(())
632    /// # }
633    /// ```
634    #[instrument(skip(self))]
635    pub async fn get_events(
636        &self,
637        limit: Option<u32>,
638        provider_id: Option<&str>,
639        last_log_id: Option<&str>,
640    ) -> Result<EventPageResponse> {
641        let mut query: Vec<(&str, String)> = Vec::new();
642
643        if let Some(limit) = limit {
644            query.push(("limit", limit.to_string()));
645        }
646        if let Some(provider_id) = provider_id {
647            query.push(("providerId", provider_id.to_string()));
648        }
649        if let Some(last_log_id) = last_log_id {
650            query.push(("lastLogId", last_log_id.to_string()));
651        }
652
653        let req = self
654            .build_request("GET", "/v1/agents/events")?
655            .query(&query);
656        self.send(req).await
657    }
658}
659
660#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    #[test]
666    fn test_auth_header() {
667        let jwt = Auth::Jwt(SecretString::from("token123"));
668        assert_eq!(jwt.header_value().expose_secret(), "sso-jwt token123");
669
670        let api_key = Auth::ApiKey {
671            key: "mykey".into(),
672            secret: SecretString::from("mysecret"),
673        };
674        assert_eq!(
675            api_key.header_value().expose_secret(),
676            "sso-key mykey:mysecret"
677        );
678    }
679
680    #[test]
681    fn test_auth_debug_redacts_secrets() {
682        let jwt = Auth::Jwt(SecretString::from("super-secret-token"));
683        let debug_output = format!("{:?}", jwt);
684        assert!(
685            !debug_output.contains("super-secret-token"),
686            "JWT token must not appear in Debug output"
687        );
688        assert!(debug_output.contains("[REDACTED]"));
689
690        let api_key = Auth::ApiKey {
691            key: "mykey".into(),
692            secret: SecretString::from("top-secret"),
693        };
694        let debug_output = format!("{:?}", api_key);
695        assert!(
696            !debug_output.contains("top-secret"),
697            "API secret must not appear in Debug output"
698        );
699        assert!(
700            debug_output.contains("mykey"),
701            "API key (non-secret) should appear in Debug output"
702        );
703        assert!(debug_output.contains("[REDACTED]"));
704    }
705
706    #[test]
707    fn test_builder_defaults() {
708        let client = AnsClient::builder().build().unwrap();
709        assert_eq!(client.base_url.as_str(), "https://api.godaddy.com/");
710        assert!(client.auth.is_none());
711    }
712
713    #[test]
714    fn test_builder_custom_url() {
715        let client = AnsClient::builder()
716            .base_url("https://api.godaddy.com")
717            .jwt("mytoken")
718            .build()
719            .unwrap();
720
721        assert_eq!(client.base_url.as_str(), "https://api.godaddy.com/");
722        assert!(matches!(client.auth, Some(Auth::Jwt(_))));
723    }
724
725    #[test]
726    fn test_builder_api_key() {
727        let client = AnsClient::builder()
728            .api_key("mykey", "mysecret")
729            .build()
730            .unwrap();
731
732        match &client.auth {
733            Some(Auth::ApiKey { key, secret }) => {
734                assert_eq!(key, "mykey");
735                assert_eq!(secret.expose_secret(), "mysecret");
736            }
737            _ => panic!("Expected Auth::ApiKey"),
738        }
739    }
740
741    #[test]
742    fn test_event_type_display() {
743        use crate::models::EventType;
744
745        assert_eq!(EventType::AgentRegistered.to_string(), "AGENT_REGISTERED");
746        assert_eq!(EventType::AgentRenewed.to_string(), "AGENT_RENEWED");
747        assert_eq!(EventType::AgentRevoked.to_string(), "AGENT_REVOKED");
748        assert_eq!(
749            EventType::AgentVersionUpdated.to_string(),
750            "AGENT_VERSION_UPDATED"
751        );
752    }
753
754    #[test]
755    fn test_builder_timeout() {
756        let client = AnsClient::builder()
757            .timeout(Duration::from_secs(5))
758            .build()
759            .unwrap();
760
761        // Client builds successfully with custom timeout
762        assert_eq!(client.base_url.as_str(), "https://api.godaddy.com/");
763    }
764
765    #[test]
766    fn test_builder_custom_header() {
767        let client = AnsClient::builder()
768            .header("x-request-id", "test-123")
769            .build()
770            .unwrap();
771
772        assert_eq!(
773            client.extra_headers.get("x-request-id").unwrap(),
774            "test-123"
775        );
776    }
777
778    #[test]
779    fn test_builder_multiple_headers() {
780        let client = AnsClient::builder()
781            .headers([("x-correlation-id", "corr-456"), ("x-source", "test")])
782            .build()
783            .unwrap();
784
785        assert_eq!(
786            client.extra_headers.get("x-correlation-id").unwrap(),
787            "corr-456"
788        );
789        assert_eq!(client.extra_headers.get("x-source").unwrap(), "test");
790    }
791
792    #[test]
793    fn test_builder_invalid_header_name() {
794        let result = AnsClient::builder()
795            .header("invalid header\0name", "value")
796            .build();
797
798        assert!(matches!(result, Err(ClientError::Configuration(_))));
799    }
800
801    #[test]
802    fn test_builder_invalid_url() {
803        let result = AnsClient::builder().base_url("not a valid url").build();
804
805        assert!(result.is_err());
806    }
807
808    #[test]
809    fn test_new_default_client() {
810        let client = AnsClient::new().unwrap();
811        assert_eq!(client.base_url.as_str(), "https://api.godaddy.com/");
812        assert!(client.auth.is_none());
813    }
814
815    #[test]
816    fn test_builder_rejects_http_url() {
817        let result = AnsClient::builder()
818            .base_url("http://api.example.com")
819            .build();
820
821        match result {
822            Err(ClientError::Configuration(msg)) => {
823                assert!(msg.contains("HTTPS"), "error should mention HTTPS: {msg}");
824            }
825            other => panic!("expected Configuration error, got: {other:?}"),
826        }
827    }
828
829    #[test]
830    fn test_builder_allow_insecure_permits_http() {
831        let client = AnsClient::builder()
832            .base_url("http://localhost:8080")
833            .allow_insecure()
834            .build()
835            .unwrap();
836
837        assert_eq!(client.base_url.scheme(), "http");
838    }
839
840    #[test]
841    fn test_builder_https_url_always_accepted() {
842        let client = AnsClient::builder()
843            .base_url("https://api.example.com")
844            .build()
845            .unwrap();
846
847        assert_eq!(client.base_url.scheme(), "https");
848    }
849}