1use 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
55const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
57
58#[derive(Clone)]
65#[non_exhaustive]
66pub enum Auth {
67 Jwt(SecretString),
69 ApiKey {
71 key: String,
73 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#[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 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 pub fn base_url(mut self, url: impl Into<String>) -> Self {
142 self.base_url = Some(url.into());
143 self
144 }
145
146 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 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 pub fn timeout(mut self, timeout: Duration) -> Self {
169 self.timeout = timeout;
170 self
171 }
172
173 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 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 pub fn allow_insecure(mut self) -> Self {
216 self.allow_insecure = true;
217 self
218 }
219
220 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#[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 pub fn builder() -> AnsClientBuilder {
282 AnsClientBuilder::new()
283 }
284
285 pub fn new() -> Result<Self> {
289 Self::builder().build()
290 }
291
292 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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}