Skip to main content

dna_rs/ops/
domain.rs

1use serde_json::Value;
2
3use crate::client::DnaClient;
4use crate::error::DnaResult;
5use crate::models::contact::{ContactInput, ContactPayload};
6use crate::models::domain::{
7    DomainInfo, DomainInfoResponse, DomainList, DomainListItem, DomainSummary, LockPayload,
8    PrivacyPayload, RegisterPayload, RenewPayload, RenewResponse, RenewResult,
9};
10use crate::ops::util::{build_contact_payload, parse_domain_info};
11use std::collections::HashMap;
12
13/// Default nameservers used when none are supplied by the caller.
14const DEFAULT_NS: &[&str] = &["ns1.domainnameapi.com", "ns2.domainnameapi.com"];
15
16impl DnaClient {
17    // ── List ─────────────────────────────────────────────────────────────────
18
19    /// List all domains in the account.
20    ///
21    /// Pass `extra` key/value pairs to override pagination defaults
22    /// (`MaxResultCount` = 200, `SkipCount` = 0).
23    pub async fn get_list(&self, extra: Option<&[(&str, &str)]>) -> DnaResult<DomainList> {
24        let mut params: Vec<(&str, String)> =
25            vec![("MaxResultCount", "200".into()), ("SkipCount", "0".into())];
26        if let Some(overrides) = extra {
27            for (k, v) in overrides {
28                params.retain(|(pk, _)| pk != k);
29                params.push((k, v.to_string()));
30            }
31        }
32
33        let query: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
34        let raw: Value = self.http.get("domains", Some(&query)).await?;
35
36        let total_count = raw.get("totalCount").and_then(Value::as_u64).unwrap_or(0);
37
38        let items: Vec<DomainListItem> = raw
39            .get("items")
40            .and_then(Value::as_array)
41            .map(|arr| serde_json::from_value(Value::Array(arr.clone())))
42            .transpose()?
43            .unwrap_or_default();
44
45        let domains = items
46            .into_iter()
47            .map(|item| DomainSummary {
48                id: item.id.unwrap_or(0),
49                status: item
50                    .status_text
51                    .or_else(|| {
52                        item.status
53                            .as_ref()
54                            .and_then(Value::as_str)
55                            .map(str::to_owned)
56                    })
57                    .unwrap_or_default(),
58                domain_name: item.domain_name.unwrap_or_default(),
59                auth_code: item.auth_code.unwrap_or_default(),
60                lock_status: item.lock_status.unwrap_or(false),
61                privacy_protection_status: item.privacy_protection_status.unwrap_or(false),
62                is_child_name_server: item.hosts.as_ref().map(|h| !h.is_empty()).unwrap_or(false),
63                name_servers: item.name_servers.unwrap_or_default(),
64                start_date: item.start_date.unwrap_or_default(),
65                expiration_date: item.expiration_date.unwrap_or_default(),
66                remaining_days: item.remaining_day.unwrap_or(0),
67            })
68            .collect();
69
70        Ok(DomainList {
71            domains,
72            total_count,
73        })
74    }
75
76    // ── Detail ────────────────────────────────────────────────────────────────
77
78    /// Fetch full details for a single domain.
79    pub async fn get_details(&self, domain_name: &str) -> DnaResult<DomainInfo> {
80        let query = [("DomainName", domain_name)];
81        let raw: DomainInfoResponse = self.http.get("domains/info", Some(&query)).await?;
82        parse_domain_info(raw)
83    }
84
85    /// Synchronise domain data from the registry (delegates to [`get_details`]).
86    pub async fn sync_from_registry(&self, domain_name: &str) -> DnaResult<DomainInfo> {
87        self.get_details(domain_name).await
88    }
89
90    // ── Renew ─────────────────────────────────────────────────────────────────
91
92    /// Renew a domain for `period` additional years.
93    pub async fn renew(&self, domain_name: &str, period: u32) -> DnaResult<RenewResult> {
94        let payload = RenewPayload {
95            domain_name: domain_name.into(),
96            period,
97        };
98        let resp: RenewResponse = self.http.post("domains/renew", &payload).await?;
99
100        let expiration_date = resp.expiration_date.ok_or_else(|| {
101            crate::error::DnaError::UnexpectedResponse(
102                "Renew response missing `expirationDate`".into(),
103            )
104        })?;
105
106        Ok(RenewResult { expiration_date })
107    }
108
109    // ── Register ──────────────────────────────────────────────────────────────
110
111    /// Register a new domain with full contact information.
112    pub async fn register_with_contact_info(
113        &self,
114        domain_name: &str,
115        period: u32,
116        contacts: HashMap<&str, ContactInput>,
117        name_servers: Option<Vec<String>>,
118        epp_lock: bool,
119        privacy_lock: bool,
120        additional_attributes: Option<Value>,
121    ) -> DnaResult<DomainInfo> {
122        let payload_contacts: Vec<ContactPayload> = contacts
123            .iter()
124            .map(|(t, c)| build_contact_payload(c, t))
125            .collect();
126
127        let ns = name_servers.unwrap_or_else(|| DEFAULT_NS.iter().map(|s| s.to_string()).collect());
128
129        let payload = RegisterPayload {
130            domain_name: domain_name.into(),
131            period,
132            name_servers: ns,
133            is_locked: epp_lock,
134            privacy_enabled: privacy_lock,
135            contacts: payload_contacts,
136            additional_attributes: additional_attributes
137                .unwrap_or_else(|| Value::Object(Default::default())),
138        };
139
140        let raw: DomainInfoResponse = self
141            .http
142            .post("domains/register-with-contacts", &payload)
143            .await?;
144        parse_domain_info(raw)
145    }
146
147    // ── Lock ──────────────────────────────────────────────────────────────────
148
149    /// Enable the registrar transfer lock.
150    pub async fn enable_theft_protection_lock(&self, domain_name: &str) -> DnaResult<()> {
151        let payload = LockPayload {
152            domain_name: domain_name.into(),
153            lock_status: true,
154        };
155        let _: Value = self.http.post("domains/lock", &payload).await?;
156        Ok(())
157    }
158
159    /// Disable the registrar transfer lock.
160    pub async fn disable_theft_protection_lock(&self, domain_name: &str) -> DnaResult<()> {
161        let payload = LockPayload {
162            domain_name: domain_name.into(),
163            lock_status: false,
164        };
165        let _: Value = self.http.post("domains/lock", &payload).await?;
166        Ok(())
167    }
168
169    // ── Privacy ───────────────────────────────────────────────────────────────
170
171    /// Enable or disable WHOIS privacy protection.
172    ///
173    /// `_reason` is accepted for API-compatibility but not forwarded (the REST
174    /// gateway does not expose a reason field).
175    pub async fn modify_privacy_protection_status(
176        &self,
177        domain_name: &str,
178        status: bool,
179        _reason: Option<&str>,
180    ) -> DnaResult<bool> {
181        let payload = PrivacyPayload {
182            domain_name: domain_name.into(),
183            privacy_status: status,
184        };
185        let _: Value = self.http.post("domains/privacy", &payload).await?;
186        Ok(status)
187    }
188
189    // ── Utility ───────────────────────────────────────────────────────────────
190
191    /// Returns `true` if the domain has a `.tr` TLD.
192    pub fn is_tr_tld(&self, domain: &str) -> bool {
193        domain.to_lowercase().ends_with(".tr")
194    }
195
196    /// Validate and normalise a [`ContactInput`], filling in Turkish defaults
197    /// for any blank mandatory fields (mirrors `validateContact` in the PHP library).
198    pub fn validate_contact(&self, mut c: ContactInput) -> ContactInput {
199        fn fill(s: &mut String, default: &str) {
200            if s.trim().is_empty() {
201                *s = default.to_owned();
202            }
203        }
204
205        // Capture first_name before borrowing mutably via fill
206        let first = c.first_name.clone();
207
208        fill(&mut c.first_name, "Isimyok");
209        fill(
210            &mut c.last_name,
211            if first.is_empty() { "Isimyok" } else { &first },
212        );
213        fill(&mut c.address_line1, "Addres yok");
214        fill(&mut c.city, "ISTANBUL");
215        fill(&mut c.country, "TR");
216        fill(&mut c.zip_code, "34000");
217        fill(&mut c.phone, "5555555555");
218        fill(&mut c.phone_country_code, "90");
219
220        // Strip non-digits, then apply country-code rules.
221        let digits: String = c.phone.chars().filter(|ch| ch.is_ascii_digit()).collect();
222        match digits.len() {
223            10 => {
224                c.phone_country_code = String::new();
225                c.phone = digits;
226            }
227            11 if digits.starts_with('9') => {
228                c.phone_country_code = digits[..2].to_owned();
229                c.phone = digits[2..].to_owned();
230            }
231            12 if digits.starts_with("90") => {
232                c.phone_country_code = digits[..2].to_owned();
233                c.phone = digits[2..].to_owned();
234            }
235            _ => {
236                c.phone_country_code = "90".to_owned();
237                c.phone = if digits.is_empty() {
238                    "5555555555".into()
239                } else {
240                    digits
241                };
242            }
243        }
244
245        fill(&mut c.phone_country_code, "90");
246        fill(&mut c.phone, "5555555555");
247        c
248    }
249}