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        }
364        self.send(req).await
365    }
366
367    // =========================================================================
368    // Registration Operations
369    // =========================================================================
370
371    /// Register a new agent.
372    ///
373    /// Returns the pending registration details including required next steps
374    /// for completing registration (DNS configuration, domain validation, etc.).
375    ///
376    /// # Errors
377    ///
378    /// - [`ClientError::Conflict`] if the agent is already registered
379    /// - [`ClientError::InvalidRequest`] if the request is invalid
380    #[instrument(skip(self, request), fields(agent_host = %request.agent_host))]
381    pub async fn register_agent(
382        &self,
383        request: &AgentRegistrationRequest,
384    ) -> Result<RegistrationPending> {
385        self.request("POST", "/v1/agents/register", Some(request))
386            .await
387    }
388
389    /// Get agent details by ID.
390    ///
391    /// # Errors
392    ///
393    /// - [`ClientError::NotFound`] if the agent doesn't exist
394    #[instrument(skip(self))]
395    pub async fn get_agent(&self, agent_id: &str) -> Result<AgentDetails> {
396        let path = format!("/v1/agents/{}", urlencoding::encode(agent_id));
397        self.request("GET", &path, None::<&()>).await
398    }
399
400    /// Search for agents.
401    ///
402    /// # Arguments
403    ///
404    /// * `criteria` - Search criteria (display name, host, version, protocol)
405    /// * `limit` - Maximum results to return (1-100, default 20)
406    /// * `offset` - Number of results to skip for pagination
407    #[instrument(skip(self))]
408    pub async fn search_agents(
409        &self,
410        criteria: &SearchCriteria,
411        limit: Option<u32>,
412        offset: Option<u32>,
413    ) -> Result<AgentSearchResponse> {
414        let mut query: Vec<(&str, String)> = Vec::new();
415
416        if let Some(name) = &criteria.agent_display_name {
417            query.push(("agentDisplayName", name.clone()));
418        }
419        if let Some(host) = &criteria.agent_host {
420            query.push(("agentHost", host.clone()));
421        }
422        if let Some(version) = &criteria.version {
423            query.push(("version", version.clone()));
424        }
425        if let Some(protocol) = &criteria.protocol {
426            query.push(("protocol", protocol.to_string()));
427        }
428        if let Some(limit) = limit {
429            query.push(("limit", limit.to_string()));
430        }
431        if let Some(offset) = offset {
432            query.push(("offset", offset.to_string()));
433        }
434
435        let req = self.build_request("GET", "/v1/agents")?.query(&query);
436        self.send(req).await
437    }
438
439    /// Resolve an ANS name to agent details.
440    ///
441    /// # Arguments
442    ///
443    /// * `agent_host` - The agent's host domain
444    /// * `version` - Version pattern ("*" for any, or semver range like "^1.0.0")
445    #[instrument(skip(self))]
446    pub async fn resolve_agent(
447        &self,
448        agent_host: &str,
449        version: &str,
450    ) -> Result<AgentResolutionResponse> {
451        let request = AgentResolutionRequest {
452            agent_host: agent_host.to_string(),
453            version: version.to_string(),
454        };
455        self.request("POST", "/v1/agents/resolution", Some(&request))
456            .await
457    }
458
459    // =========================================================================
460    // Validation Operations
461    // =========================================================================
462
463    /// Trigger ACME domain validation.
464    ///
465    /// Call this after configuring the ACME challenge (DNS or HTTP).
466    ///
467    /// # Errors
468    ///
469    /// - [`ClientError::NotFound`] if the agent doesn't exist
470    /// - [`ClientError::InvalidRequest`] if validation fails
471    #[instrument(skip(self))]
472    pub async fn verify_acme(&self, agent_id: &str) -> Result<AgentStatus> {
473        let path = format!("/v1/agents/{}/verify-acme", urlencoding::encode(agent_id));
474        self.request("POST", &path, None::<&()>).await
475    }
476
477    /// Verify DNS records are configured correctly.
478    ///
479    /// Call this after configuring all required DNS records.
480    ///
481    /// # Errors
482    ///
483    /// - [`ClientError::NotFound`] if the agent doesn't exist
484    /// - [`ClientError::InvalidRequest`] if DNS verification fails
485    #[instrument(skip(self))]
486    pub async fn verify_dns(&self, agent_id: &str) -> Result<AgentStatus> {
487        let path = format!("/v1/agents/{}/verify-dns", urlencoding::encode(agent_id));
488        self.request("POST", &path, None::<&()>).await
489    }
490
491    // =========================================================================
492    // Certificate Operations
493    // =========================================================================
494
495    /// Get server certificates for an agent.
496    #[instrument(skip(self))]
497    pub async fn get_server_certificates(
498        &self,
499        agent_id: &str,
500    ) -> Result<Vec<CertificateResponse>> {
501        let path = format!(
502            "/v1/agents/{}/certificates/server",
503            urlencoding::encode(agent_id)
504        );
505        self.request("GET", &path, None::<&()>).await
506    }
507
508    /// Get identity certificates for an agent.
509    #[instrument(skip(self))]
510    pub async fn get_identity_certificates(
511        &self,
512        agent_id: &str,
513    ) -> Result<Vec<CertificateResponse>> {
514        let path = format!(
515            "/v1/agents/{}/certificates/identity",
516            urlencoding::encode(agent_id)
517        );
518        self.request("GET", &path, None::<&()>).await
519    }
520
521    /// Submit a server certificate CSR.
522    #[instrument(skip(self, csr_pem))]
523    pub async fn submit_server_csr(
524        &self,
525        agent_id: &str,
526        csr_pem: &str,
527    ) -> Result<CsrSubmissionResponse> {
528        let path = format!(
529            "/v1/agents/{}/certificates/server",
530            urlencoding::encode(agent_id)
531        );
532        let request = CsrSubmissionRequest {
533            csr_pem: csr_pem.to_string(),
534        };
535        self.request("POST", &path, Some(&request)).await
536    }
537
538    /// Submit an identity certificate CSR.
539    #[instrument(skip(self, csr_pem))]
540    pub async fn submit_identity_csr(
541        &self,
542        agent_id: &str,
543        csr_pem: &str,
544    ) -> Result<CsrSubmissionResponse> {
545        let path = format!(
546            "/v1/agents/{}/certificates/identity",
547            urlencoding::encode(agent_id)
548        );
549        let request = CsrSubmissionRequest {
550            csr_pem: csr_pem.to_string(),
551        };
552        self.request("POST", &path, Some(&request)).await
553    }
554
555    /// Get CSR status.
556    #[instrument(skip(self))]
557    pub async fn get_csr_status(&self, agent_id: &str, csr_id: &str) -> Result<CsrStatusResponse> {
558        let path = format!(
559            "/v1/agents/{}/csrs/{}/status",
560            urlencoding::encode(agent_id),
561            urlencoding::encode(csr_id)
562        );
563        self.request("GET", &path, None::<&()>).await
564    }
565
566    // =========================================================================
567    // Revocation Operations
568    // =========================================================================
569
570    /// Revoke an agent.
571    ///
572    /// This permanently revokes the agent's certificates and marks the
573    /// registration as revoked.
574    ///
575    /// # Errors
576    ///
577    /// - [`ClientError::NotFound`] if the agent doesn't exist
578    #[instrument(skip(self))]
579    pub async fn revoke_agent(
580        &self,
581        agent_id: &str,
582        reason: RevocationReason,
583        comments: Option<&str>,
584    ) -> Result<AgentRevocationResponse> {
585        let path = format!("/v1/agents/{}/revoke", urlencoding::encode(agent_id));
586        let request = AgentRevocationRequest {
587            reason,
588            comments: comments.map(String::from),
589        };
590        self.request("POST", &path, Some(&request)).await
591    }
592
593    // =========================================================================
594    // Event Operations
595    // =========================================================================
596
597    /// Get paginated agent events.
598    ///
599    /// This endpoint is used by Agent Host Providers (AHPs) to track agent
600    /// registration events across the system.
601    ///
602    /// # Arguments
603    ///
604    /// * `limit` - Maximum events to return (1-100)
605    /// * `provider_id` - Filter by provider ID (optional)
606    /// * `last_log_id` - Continuation token from previous response (for pagination)
607    ///
608    /// # Example
609    ///
610    /// ```rust,no_run
611    /// use ans_client::AnsClient;
612    ///
613    /// # async fn example() -> ans_client::Result<()> {
614    /// let client = AnsClient::builder()
615    ///     .base_url("https://api.godaddy.com")
616    ///     .api_key("key", "secret")
617    ///     .build()?;
618    ///
619    /// // Get first page
620    /// let page1 = client.get_events(Some(50), None, None).await?;
621    /// for event in &page1.items {
622    ///     println!("{}: {} - {}", event.event_type, event.ans_name, event.agent_host);
623    /// }
624    ///
625    /// // Get next page if available
626    /// if let Some(last_id) = page1.last_log_id {
627    ///     let page2 = client.get_events(Some(50), None, Some(&last_id)).await?;
628    /// }
629    /// # Ok(())
630    /// # }
631    /// ```
632    #[instrument(skip(self))]
633    pub async fn get_events(
634        &self,
635        limit: Option<u32>,
636        provider_id: Option<&str>,
637        last_log_id: Option<&str>,
638    ) -> Result<EventPageResponse> {
639        let mut query: Vec<(&str, String)> = Vec::new();
640
641        if let Some(limit) = limit {
642            query.push(("limit", limit.to_string()));
643        }
644        if let Some(provider_id) = provider_id {
645            query.push(("providerId", provider_id.to_string()));
646        }
647        if let Some(last_log_id) = last_log_id {
648            query.push(("lastLogId", last_log_id.to_string()));
649        }
650
651        let req = self
652            .build_request("GET", "/v1/agents/events")?
653            .query(&query);
654        self.send(req).await
655    }
656}
657
658#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_auth_header() {
665        let jwt = Auth::Jwt(SecretString::from("token123"));
666        assert_eq!(jwt.header_value().expose_secret(), "sso-jwt token123");
667
668        let api_key = Auth::ApiKey {
669            key: "mykey".into(),
670            secret: SecretString::from("mysecret"),
671        };
672        assert_eq!(
673            api_key.header_value().expose_secret(),
674            "sso-key mykey:mysecret"
675        );
676    }
677
678    #[test]
679    fn test_auth_debug_redacts_secrets() {
680        let jwt = Auth::Jwt(SecretString::from("super-secret-token"));
681        let debug_output = format!("{:?}", jwt);
682        assert!(
683            !debug_output.contains("super-secret-token"),
684            "JWT token must not appear in Debug output"
685        );
686        assert!(debug_output.contains("[REDACTED]"));
687
688        let api_key = Auth::ApiKey {
689            key: "mykey".into(),
690            secret: SecretString::from("top-secret"),
691        };
692        let debug_output = format!("{:?}", api_key);
693        assert!(
694            !debug_output.contains("top-secret"),
695            "API secret must not appear in Debug output"
696        );
697        assert!(
698            debug_output.contains("mykey"),
699            "API key (non-secret) should appear in Debug output"
700        );
701        assert!(debug_output.contains("[REDACTED]"));
702    }
703
704    #[test]
705    fn test_builder_defaults() {
706        let client = AnsClient::builder().build().unwrap();
707        assert_eq!(client.base_url.as_str(), "https://api.godaddy.com/");
708        assert!(client.auth.is_none());
709    }
710
711    #[test]
712    fn test_builder_custom_url() {
713        let client = AnsClient::builder()
714            .base_url("https://api.godaddy.com")
715            .jwt("mytoken")
716            .build()
717            .unwrap();
718
719        assert_eq!(client.base_url.as_str(), "https://api.godaddy.com/");
720        assert!(matches!(client.auth, Some(Auth::Jwt(_))));
721    }
722
723    #[test]
724    fn test_builder_api_key() {
725        let client = AnsClient::builder()
726            .api_key("mykey", "mysecret")
727            .build()
728            .unwrap();
729
730        match &client.auth {
731            Some(Auth::ApiKey { key, secret }) => {
732                assert_eq!(key, "mykey");
733                assert_eq!(secret.expose_secret(), "mysecret");
734            }
735            _ => panic!("Expected Auth::ApiKey"),
736        }
737    }
738
739    #[test]
740    fn test_event_type_display() {
741        use crate::models::EventType;
742
743        assert_eq!(EventType::AgentRegistered.to_string(), "AGENT_REGISTERED");
744        assert_eq!(EventType::AgentRenewed.to_string(), "AGENT_RENEWED");
745        assert_eq!(EventType::AgentRevoked.to_string(), "AGENT_REVOKED");
746        assert_eq!(
747            EventType::AgentVersionUpdated.to_string(),
748            "AGENT_VERSION_UPDATED"
749        );
750    }
751
752    #[test]
753    fn test_builder_timeout() {
754        let client = AnsClient::builder()
755            .timeout(Duration::from_secs(5))
756            .build()
757            .unwrap();
758
759        // Client builds successfully with custom timeout
760        assert_eq!(client.base_url.as_str(), "https://api.godaddy.com/");
761    }
762
763    #[test]
764    fn test_builder_custom_header() {
765        let client = AnsClient::builder()
766            .header("x-request-id", "test-123")
767            .build()
768            .unwrap();
769
770        assert_eq!(
771            client.extra_headers.get("x-request-id").unwrap(),
772            "test-123"
773        );
774    }
775
776    #[test]
777    fn test_builder_multiple_headers() {
778        let client = AnsClient::builder()
779            .headers([("x-correlation-id", "corr-456"), ("x-source", "test")])
780            .build()
781            .unwrap();
782
783        assert_eq!(
784            client.extra_headers.get("x-correlation-id").unwrap(),
785            "corr-456"
786        );
787        assert_eq!(client.extra_headers.get("x-source").unwrap(), "test");
788    }
789
790    #[test]
791    fn test_builder_invalid_header_name() {
792        let result = AnsClient::builder()
793            .header("invalid header\0name", "value")
794            .build();
795
796        assert!(matches!(result, Err(ClientError::Configuration(_))));
797    }
798
799    #[test]
800    fn test_builder_invalid_url() {
801        let result = AnsClient::builder().base_url("not a valid url").build();
802
803        assert!(result.is_err());
804    }
805
806    #[test]
807    fn test_new_default_client() {
808        let client = AnsClient::new().unwrap();
809        assert_eq!(client.base_url.as_str(), "https://api.godaddy.com/");
810        assert!(client.auth.is_none());
811    }
812
813    #[test]
814    fn test_builder_rejects_http_url() {
815        let result = AnsClient::builder()
816            .base_url("http://api.example.com")
817            .build();
818
819        match result {
820            Err(ClientError::Configuration(msg)) => {
821                assert!(msg.contains("HTTPS"), "error should mention HTTPS: {msg}");
822            }
823            other => panic!("expected Configuration error, got: {other:?}"),
824        }
825    }
826
827    #[test]
828    fn test_builder_allow_insecure_permits_http() {
829        let client = AnsClient::builder()
830            .base_url("http://localhost:8080")
831            .allow_insecure()
832            .build()
833            .unwrap();
834
835        assert_eq!(client.base_url.scheme(), "http");
836    }
837
838    #[test]
839    fn test_builder_https_url_always_accepted() {
840        let client = AnsClient::builder()
841            .base_url("https://api.example.com")
842            .build()
843            .unwrap();
844
845        assert_eq!(client.base_url.scheme(), "https");
846    }
847}