Skip to main content

porkbun_api/
lib.rs

1#![warn(missing_docs)]
2//! # porkbun-api
3//!
4//! this crate provides an async implementation of [porkbun](https://porkbun.com)'s domain management [api](https://porkbun.com/api/json/v3/documentation).
5//! It provides a transport-agnostic [Client], and a [DefaultTransport] based on hyper suitable for use in tokio-based applications.
6//!
7//! ## example
8//!
9//! ```no_run
10//! use porkbun_api::{Client, ApiKey, CreateOrEditDnsRecord};
11//! use porkbun_api::transport::DefaultTransportError;
12//!
13//! #[tokio::main]
14//! async fn main() -> Result<(), porkbun_api::Error<DefaultTransportError>> {
15//!     let api_key = ApiKey::new("secret", "api_key");
16//!     let client = Client::new(api_key);
17//!
18//!     let domain = &client.domains().await?[0].domain;
19//!     let subdomain = Some("my.ip");
20//!     let my_ip = client.ping().await?;
21//!     let record = CreateOrEditDnsRecord::A_or_AAAA(subdomain, my_ip);
22//!     let id = client.create(domain, record).await?;
23//!     println!("added record {id}");
24//!     client.delete(domain, &id).await?;
25//!     println!("removed record {id}");
26//!     Ok(())
27//! }
28//! ```
29//!
30//! ## Features
31//!
32//! - `default-client` enabled by default. Includes a default transport layer implementation for the [Client]. This can be disabled if you are implementing your own.
33//! - `tracing` optional. When enabled, adds `#[instrument]` spans on all public API methods
34//!   and emits events in the Default transport layer.
35//!   HTTP-level request/response tracing is intentionally omitted — if you need it,
36//!   wrap [`DefaultTransport`] in your own [`MakeRequest`](transport::MakeRequest)
37//!   implementation that adds spans. See `examples/http_tracing.rs` for a working example.
38//!
39//! ## known issues with the porkbun api
40//!
41//! Hostnames are a subset of DNS names. `🦆.example.com` is a valid DNS name for example, but it is not a valid hostname.
42//! The porkbun api _will_ let you set an entry for `🦆.example.com`, but if you then try to query it, it will be returned as `??.example.com`. This is an issue with the porkbun servers.
43//!
44//! Also, the porkbun api server can also be quite slow, sometimes taking several seconds before it accepts an api call. Keep this in mind when integrating this library within a larger application.
45
46mod serde_util;
47
48pub mod transport;
49#[cfg(feature = "default-client")]
50use transport::DefaultTransport;
51use transport::MakeRequest;
52mod error;
53pub use error::Error;
54use error::{ApiErrorMessage, ApiResponse, ErrorImpl};
55mod uri;
56
57use chrono::NaiveDateTime;
58use http_body_util::{BodyExt, Full};
59use hyper::{
60    body::{Body, Bytes},
61    Request, StatusCode, Uri,
62};
63use serde::{Deserialize, Serialize};
64use std::{
65    borrow::Cow,
66    collections::HashMap,
67    fmt::Display,
68    net::{IpAddr, Ipv4Addr, Ipv6Addr},
69    time::Duration,
70};
71
72/// Holds the credentials needed to access the API
73#[derive(Deserialize, Serialize, Clone)]
74pub struct ApiKey {
75    secretapikey: String,
76    apikey: String,
77}
78
79impl ApiKey {
80    /// Creates a new [ApiKey] from the given API secret and API key.
81    pub fn new(secret: impl Into<String>, api_key: impl Into<String>) -> Self {
82        Self {
83            secretapikey: secret.into(),
84            apikey: api_key.into(),
85        }
86    }
87}
88
89/// Valid DNS record types
90#[allow(missing_docs)]
91#[derive(Clone, Copy, Deserialize, Serialize, Debug, PartialEq, Eq)]
92pub enum DnsRecordType {
93    A,
94    MX,
95    CNAME,
96    ALIAS,
97    TXT,
98    NS,
99    AAAA,
100    SRV,
101    TLSA,
102    CAA,
103    HTTPS,
104    SVCB,
105}
106
107impl Display for DnsRecordType {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.write_str(match self {
110            Self::A => "A",
111            Self::MX => "MX",
112            Self::CNAME => "CNAME",
113            Self::ALIAS => "ALIAS",
114            Self::TXT => "TXT",
115            Self::NS => "NS",
116            Self::AAAA => "AAAA",
117            Self::SRV => "SRV",
118            Self::TLSA => "TLSA",
119            Self::CAA => "CAA",
120            Self::HTTPS => "HTTPS",
121            Self::SVCB => "SVCB",
122        })
123    }
124}
125
126/// create, or edit with a DNS record with a domain/id pair
127#[derive(Debug, Serialize, PartialEq, Eq)]
128pub struct CreateOrEditDnsRecord<'a> {
129    /// The subdomain for the record being created, not including the domain itself. Leave blank to create a record on the root domain. Use * to create a wildcard record.
130    #[serde(rename = "name")]
131    pub subdomain: Option<&'a str>,
132    /// The type of record that should be created
133    #[serde(rename = "type")]
134    pub record_type: DnsRecordType,
135    /// The answer content for the record.
136    pub content: Cow<'a, str>,
137    /// The time to live in seconds for the record. The minimum and the default is 600 seconds.
138    pub ttl: Option<u64>,
139    /// The priority of the record for those that support it.
140    //these get returned as strings, might be we can set these to non-standard values?
141    pub prio: u32,
142    // you'd expect a comment field here, but its missing from the api 🥲
143    // doesn't seem to be notes, note, or comments
144    //todo: ask if there is an api? the web interface seems to use a different api. including one with bulk mgmt
145}
146
147impl<'a> CreateOrEditDnsRecord<'a> {
148    /// Makes a new [CreateOrEditDnsRecord] for the given subdomain, with the given record type and content.
149    pub fn new(
150        subdomain: Option<&'a str>,
151        record_type: DnsRecordType,
152        content: impl Into<Cow<'a, str>>,
153    ) -> Self {
154        Self {
155            subdomain,
156            record_type,
157            content: content.into(),
158            ttl: None,
159            prio: 0,
160        }
161    }
162    /// Makes a new [CreateOrEditDnsRecord]  for creating an A-record for the given subdomain, with the given IP address as a response.
163    #[allow(non_snake_case)]
164    pub fn A(subdomain: Option<&'a str>, ip: Ipv4Addr) -> Self {
165        Self::new(subdomain, DnsRecordType::A, Cow::Owned(ip.to_string()))
166    }
167    /// Makes a new [CreateOrEditDnsRecord] for creating an AAAA-record for the given subdomain, with the given IP address as a response.
168    #[allow(non_snake_case)]
169    pub fn AAAA(subdomain: Option<&'a str>, ip: Ipv6Addr) -> Self {
170        Self::new(subdomain, DnsRecordType::AAAA, Cow::Owned(ip.to_string()))
171    }
172    /// Makes a new [CreateOrEditDnsRecord] for creating an A- or AAAA-record (depending on the value of the ip address) for the given subdomain, with the given IP address as a response.
173    #[allow(non_snake_case)]
174    pub fn A_or_AAAA(subdomain: Option<&'a str>, ip: IpAddr) -> Self {
175        match ip {
176            IpAddr::V4(my_ip) => Self::A(subdomain, my_ip),
177            IpAddr::V6(my_ip) => Self::AAAA(subdomain, my_ip),
178        }
179    }
180
181    /// Set the time-to-live for this record.
182    /// The minimum and the default is 600 seconds. Any value less than 600 seconds will be rounded up.
183    #[must_use]
184    pub fn with_ttl(self, ttl: Option<Duration>) -> Self {
185        Self {
186            ttl: ttl.as_ref().map(Duration::as_secs),
187            ..self
188        }
189    }
190
191    /// Set the priority for this record, for records that support it.
192    #[must_use]
193    pub fn with_priority(self, prio: u32) -> Self {
194        Self { prio, ..self }
195    }
196}
197
198//might be an integer actually but sometimes sends a string
199// so we opt to store it as a string just in case it can start with
200// a '0'
201// todo: ask about this
202#[derive(Deserialize, Debug)]
203struct EntryId {
204    #[serde(with = "serde_util::string_or_int")]
205    id: String,
206}
207
208/// A DNS entry for a domain, returned by the API
209#[derive(Deserialize, Debug)]
210pub struct DnsEntry {
211    /// the unique ID of this entry
212    #[serde(with = "serde_util::string_or_int")]
213    pub id: String,
214    /// the full name of the entry, e.g. `_atproto.example.com`
215    pub name: String,
216    /// the type of record, e.g. A or TXT.
217    #[serde(rename = "type")]
218    pub record_type: DnsRecordType,
219    /// the content of this record.
220    pub content: String,
221    /// the time-to-live of this record
222    //string in docs
223    #[serde(with = "serde_util::u64_from_string_or_int")]
224    pub ttl: u64,
225    /// The priority of this record
226    //string in docs
227    #[serde(default, with = "serde_util::u64_from_string_or_int")]
228    pub prio: u64,
229    /// Any notes set for this record.
230    /// Note that you can not set these from the API itself, you have to do so with the management console on the websiter.
231    pub notes: Option<String>,
232}
233
234#[derive(Deserialize, Debug)]
235struct DnsRecordsByDomainOrIDResponse {
236    records: Vec<DnsEntry>,
237}
238
239/// The default pricing for the registration, renewal and transfer of a given TLD.
240#[derive(Deserialize, Debug)]
241#[serde(rename_all = "camelCase")]
242pub struct Pricing {
243    /// the registration price.
244    pub registration: String,
245    /// the renewal price.
246    pub renewal: String,
247    /// the transfer price.
248    pub transfer: String,
249    /// A field indicating that his is a "special" domain, and if so what kind.
250    /// Currently this only valid version seems to be ["handshake"](https://porkbun.com/handshake)
251    ///
252    /// This field is undocumented by porkbun, but I included it anyways to let people filter out these TLDs.
253    //todo: ask
254    //undocumented field, helps filter out stupid handshake domains
255    pub special_type: TldType,
256}
257
258impl Pricing {
259    /// returns true if this is a normal ICANN/IANA TLDs like .com, .engineering or .gay
260    pub fn is_icann(&self) -> bool {
261        self.special_type.is_icann()
262    }
263}
264
265/// Describes what registry a TLD belongs to.
266#[derive(Debug)]
267pub enum TldType {
268    /// The normal ICANN/IANA TLDs like .com, .engineering or .gay
269    /// you probably want one of these
270    Normal,
271    /// An [experimental blockchain protocol](https://porkbun.com/handshake).
272    Handshake,
273    /// An unknown registery.
274    ///
275    /// at the time of writting, porkbun only supports ICANN and Handshake, but this variant exists for future-proofing.
276    Other(String),
277}
278
279impl TldType {
280    /// returns true if this is a normal ICANN/IANA TLDs like .com, .engineering or .gay
281    pub fn is_icann(&self) -> bool {
282        matches!(self, Self::Normal)
283    }
284}
285
286impl<'de> Deserialize<'de> for TldType {
287    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
288    where
289        D: serde::Deserializer<'de>,
290    {
291        let string_value = Option::<String>::deserialize(deserializer)?;
292        Ok(match string_value {
293            None => Self::Normal,
294            Some(s) => {
295                if s.eq_ignore_ascii_case("handshake") {
296                    Self::Handshake
297                } else {
298                    Self::Other(s)
299                }
300            }
301        })
302    }
303}
304
305#[derive(Deserialize, Debug)]
306struct DomainPricingResponse {
307    //tld-to-pricing
308    pricing: HashMap<String, Pricing>,
309}
310
311#[derive(Serialize, Deserialize)]
312struct UpdateNameServers {
313    ns: Vec<String>,
314}
315
316#[derive(Serialize)]
317#[serde(rename_all = "camelCase")]
318struct DomainListAll {
319    /// An index to start at when retrieving the domains, defaults to 0. To get all domains increment by 1000 until you receive an empty array.
320    start: usize,
321    /// should be "yes"
322    #[serde(default, with = "serde_util::yesno")]
323    include_labels: bool,
324}
325
326#[derive(Deserialize, Debug)]
327#[serde(rename_all = "camelCase")]
328struct DomainListAllResponse {
329    domains: Vec<DomainInfo>,
330}
331
332/// A domain registration returned by the API server
333#[derive(Deserialize, Debug)]
334#[serde(rename_all = "camelCase")]
335pub struct DomainInfo {
336    /// The registered domain, including the TLD
337    pub domain: String,
338    // usually ACTIVE or..
339    /// The status of the domain. "ACTIVE" if active,
340    pub status: String,
341    /// the TLD of the domain
342    pub tld: String,
343    /// The date-time this domain was created
344    // ask: what is the TZ of this?
345    #[serde(with = "serde_util::datetime")]
346    pub create_date: NaiveDateTime,
347    /// The date-time this domain will expire
348    #[serde(with = "serde_util::datetime")]
349    pub expire_date: NaiveDateTime,
350    /// whether the security lock has been turned on or not
351    // docs say these are "1", probably booleans?
352    #[serde(with = "serde_util::stringoneintzero")]
353    pub security_lock: bool,
354    #[serde(with = "serde_util::stringoneintzero")]
355    /// whether whois privacy has been turned on or not
356    pub whois_privacy: bool,
357    /// whether auto-renewal is enabled or not
358    // docs say this is a bool, is a string
359    #[serde(with = "serde_util::stringoneintzero")]
360    pub auto_renew: bool,
361    /// whether this is an external domain or not
362    #[serde(with = "serde_util::stringoneintzero")]
363    pub not_local: bool,
364    /// Any labels that have been assigned to this domain from the web interface
365    #[serde(default)]
366    pub labels: Vec<Label>,
367}
368
369/// A label added to a domain.
370#[derive(Deserialize, Debug)]
371#[serde(rename_all = "camelCase")]
372pub struct Label {
373    /// The unique ID of the label
374    #[serde(deserialize_with = "serde_util::string_or_int::deserialize")]
375    pub id: String,
376    /// the name of the label
377    pub title: String,
378    /// the color of the label (used in the web interface)
379    pub color: String,
380}
381
382/// A url forwarding configuration
383#[derive(Serialize, Deserialize, Debug)]
384#[serde(rename_all = "camelCase")]
385pub struct Forward {
386    /// The subdomain to forward, or None to forward the root domain
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub subdomain: Option<String>,
389    /// The location to forward to
390    pub location: String,
391    #[serde(rename = "type")]
392    /// The type of redirect to use (permanent or temporary)
393    pub forward_type: ForwardType,
394    /// Whether to include the path in the forwarded request
395    #[serde(with = "serde_util::yesno")]
396    pub include_path: bool,
397    /// Whether to forward all subdomains
398    #[serde(with = "serde_util::yesno")]
399    pub wildcard: bool,
400}
401
402impl Forward {
403    /// creates a new [Forward] configuration with the given subdomain and location.
404    /// Sets the forward_type to temporary, includes the path, and does not enable wildcard forwarding.
405    pub fn new(subdomain: Option<impl Into<String>>, to: impl Into<String>) -> Self {
406        Self {
407            subdomain: subdomain.map(|s| s.into()),
408            location: to.into(),
409            forward_type: ForwardType::Temporary,
410            include_path: true,
411            wildcard: false,
412        }
413    }
414
415    /// Enable or disable wildcard forwarding
416    pub fn with_wildcard(self, wildcard: bool) -> Self {
417        Self { wildcard, ..self }
418    }
419
420    /// Include or exclude the path in the forwarded request
421    pub fn include_path(self, include_path: bool) -> Self {
422        Self {
423            include_path,
424            ..self
425        }
426    }
427
428    /// Set the forward type to either temporary or permanent
429    pub fn with_forward_type(self, forward_type: ForwardType) -> Self {
430        Self {
431            forward_type,
432            ..self
433        }
434    }
435}
436
437/// The type of redirect to use
438#[allow(missing_docs)]
439#[derive(Deserialize, Serialize, Debug)]
440#[serde(rename_all = "lowercase")]
441pub enum ForwardType {
442    Temporary,
443    Permanent,
444}
445
446#[derive(Deserialize, Debug)]
447#[serde(rename_all = "camelCase")]
448struct GetUrlForwardingResponse {
449    forwards: Vec<ForwardWithID>,
450}
451
452/// An existing url forwarding configuration with an associated ID
453#[derive(Deserialize, Debug)]
454#[serde(rename_all = "camelCase")]
455pub struct ForwardWithID {
456    /// The unique ID of this forwarding configuration
457    #[serde(deserialize_with = "serde_util::string_or_int::deserialize")]
458    pub id: String,
459    /// The configuration
460    #[serde(flatten, rename = "forward")]
461    pub config: Forward,
462}
463
464/// The SSL certificate bundle for a domain
465#[derive(Deserialize, Debug)]
466pub struct SslBundle {
467    /// The complete certificate chain.
468    #[serde(rename = "certificatechain")]
469    pub certificate_chain: String,
470    /// The private key.
471    #[serde(rename = "privatekey")]
472    pub private_key: String,
473    /// The public key.
474    #[serde(rename = "publickey")]
475    pub public_key: String,
476}
477
478#[derive(Serialize)]
479struct WithApiKeys<'a, T: Serialize> {
480    #[serde(flatten)]
481    api_key: &'a ApiKey,
482    #[serde(flatten)]
483    inner: T,
484}
485
486/// A client for interfacing with the Porkbun API servers
487pub struct Client<P: MakeRequest> {
488    inner: P,
489    api_key: ApiKey,
490}
491
492#[cfg(feature = "default-client")]
493impl Client<DefaultTransport> {
494    /// creates a new client using the supplied api key and the default transport implementation.
495    ///
496    /// if you wish to change the transport layer, or you're not using tokio,  use [`new_with_transport`](Client::new_with_transport)
497    pub fn new(api_key: ApiKey) -> Self {
498        Client::new_with_transport(api_key, DefaultTransport::default())
499    }
500}
501
502impl<T> Client<T>
503where
504    T: MakeRequest,
505    <T::Body as Body>::Error: Into<T::Error>,
506{
507    /// creates a new client using the supplied api key and transport.
508    ///
509    /// if you don't care about the implementation details of the transport, consider using [`new`](Client::new) which uses a default implementation.
510    pub fn new_with_transport(api_key: ApiKey, transport: T) -> Self {
511        Self {
512            inner: transport,
513            api_key,
514        }
515    }
516    async fn post<D: for<'a> Deserialize<'a>>(
517        &self,
518        uri: Uri,
519        body: Full<Bytes>,
520    ) -> Result<D, Error<T::Error>> {
521        let request = Request::post(uri).body(body).unwrap(); //both uri and body are known at this point
522        let resp = self
523            .inner
524            .request(request)
525            .await
526            .map_err(ErrorImpl::TransportError)?;
527        let (head, body) = resp.into_parts();
528        let bytes = body
529            .collect()
530            .await
531            .map_err(|e| ErrorImpl::TransportError(e.into()))?
532            .to_bytes();
533        let result = std::result::Result::<_, ApiErrorMessage>::from(
534            serde_json::from_slice::<ApiResponse<_>>(&bytes)
535                .map_err(ErrorImpl::DeserializationError)?,
536        );
537
538        match (head.status, result) {
539            (StatusCode::OK, Ok(x)) => Ok(x),
540            (status, maybe_message) => Err((status, maybe_message.err()).into()),
541        }
542    }
543    async fn post_with_api_key<S: Serialize, D: for<'a> Deserialize<'a>>(
544        &self,
545        uri: Uri,
546        body: S,
547    ) -> Result<D, Error<T::Error>> {
548        let with_api_key = WithApiKeys {
549            api_key: &self.api_key,
550            inner: body,
551        };
552        let json = serde_json::to_string(&with_api_key).map_err(ErrorImpl::SerializationError)?;
553        let body = http_body_util::Full::new(Bytes::from(json));
554        self.post(uri, body).await
555    }
556
557    /// pings the api servers returning your ip address.
558    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
559    pub async fn ping(&self) -> Result<IpAddr, Error<T::Error>> {
560        #[derive(Deserialize)]
561        #[serde(rename_all = "camelCase")]
562        struct PingResponse {
563            your_ip: IpAddr,
564        }
565        let ping: PingResponse = self.post_with_api_key(uri::ping(), ()).await?;
566        Ok(ping.your_ip)
567    }
568
569    /// Get a mapping of available TLDs to their pricing structure.
570    /// This method does not require authentication, and it will work with any [ApiKey].
571    ///
572    /// This method includes all TLDs, including special ones like handshake domains.
573    /// If you only want ICANN TLDs, and you probably do, use [icann_domain_pricing](Client::icann_domain_pricing) instead.
574    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
575    pub async fn domain_pricing(&self) -> Result<HashMap<String, Pricing>, Error<T::Error>> {
576        let resp: DomainPricingResponse = self.post(uri::domain_pricing(), Full::default()).await?;
577        Ok(resp.pricing)
578    }
579
580    /// Get a mapping of available TLDs to their pricing structure, filtered to only include ICANN TLDs.
581    /// This method does not require authentication, and it will work with any [ApiKey].
582    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
583    pub async fn icann_domain_pricing(
584        &self,
585    ) -> Result<impl Iterator<Item = (String, Pricing)>, Error<T::Error>> {
586        let resp: DomainPricingResponse = self.post(uri::domain_pricing(), Full::default()).await?;
587        Ok(resp.pricing.into_iter().filter(|(_, v)| v.is_icann()))
588    }
589
590    async fn list_domains(&self, offset: usize) -> Result<Vec<DomainInfo>, Error<T::Error>> {
591        let resp: DomainListAllResponse = self
592            .post_with_api_key(
593                uri::domain_list_all(),
594                DomainListAll {
595                    start: offset,
596                    include_labels: true,
597                },
598            )
599            .await?;
600        Ok(resp.domains)
601    }
602
603    /// get all the domains associated with this account
604    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
605    pub async fn domains(&self) -> Result<Vec<DomainInfo>, Error<T::Error>> {
606        let mut all = self.list_domains(0).await?;
607        let mut last_len = all.len();
608        // if paginated by 1000, we could probably get away with checking if equal to 1000 and skipping the final check
609        while last_len != 0 {
610            let next = self.list_domains(all.len()).await?;
611            last_len = next.len();
612            all.extend(next.into_iter());
613        }
614        Ok(all)
615    }
616
617    /// Gets all the DNS records for a given domain
618    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
619    pub async fn get_all(&self, domain: &str) -> Result<Vec<DnsEntry>, Error<T::Error>> {
620        let rsp: DnsRecordsByDomainOrIDResponse = self
621            .post_with_api_key(uri::get_dns_record_by_domain_and_id(domain, None)?, ())
622            .await?;
623        Ok(rsp.records)
624    }
625
626    /// Gets a single DNS record for a given domain, by its unique ID.
627    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
628    pub async fn get_single(
629        &self,
630        domain: &str,
631        id: &str,
632    ) -> Result<Option<DnsEntry>, Error<T::Error>> {
633        let rsp: DnsRecordsByDomainOrIDResponse = self
634            .post_with_api_key(uri::get_dns_record_by_domain_and_id(domain, Some(id))?, ())
635            .await?;
636        let rsp = rsp.records.into_iter().next();
637        Ok(rsp)
638    }
639
640    /// Create a new DNS record for the given domain.
641    /// Will fail if there already exists a record with the same name and type.
642    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
643    pub async fn create(
644        &self,
645        domain: &str,
646        cmd: CreateOrEditDnsRecord<'_>,
647    ) -> Result<String, Error<T::Error>> {
648        let resp: EntryId = self
649            .post_with_api_key(uri::create_dns_record(domain)?, cmd)
650            .await?;
651        Ok(resp.id)
652    }
653
654    /// Edits an existing DNS record for a given domain, by its unique ID.
655    /// IDs can be discovered by first calling [get_all](Client::get_all).
656    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
657    pub async fn edit(
658        &self,
659        domain: &str,
660        id: &str,
661        cmd: CreateOrEditDnsRecord<'_>,
662    ) -> Result<(), Error<T::Error>> {
663        self.post_with_api_key(uri::edit_dns_record(domain, id)?, cmd)
664            .await
665    }
666
667    /// Deletes an existing DNS record for a given domain, by its unique ID.
668    /// IDs can be discovered by first calling [get_all](Client::get_all).
669    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
670    pub async fn delete(&self, domain: &str, id: &str) -> Result<(), Error<T::Error>> {
671        self.post_with_api_key(uri::delete_dns_record_by_id(domain, id)?, ())
672            .await
673    }
674
675    /// Gets the configured nameservers for a particular domain
676    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
677    pub async fn nameservers(&self, domain: &str) -> Result<Vec<String>, Error<T::Error>> {
678        let resp: UpdateNameServers = self
679            .post_with_api_key(uri::get_name_servers(domain)?, ())
680            .await?;
681        Ok(resp.ns)
682    }
683
684    /// Updates the nameservers for a particular domain
685    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
686    pub async fn update_nameservers(
687        &self,
688        domain: &str,
689        name_servers: Vec<String>,
690    ) -> Result<(), Error<T::Error>> {
691        self.post_with_api_key(
692            uri::update_name_servers(domain)?,
693            UpdateNameServers { ns: name_servers },
694        )
695        .await
696    }
697
698    /// Get all the url forwards for a given domain
699    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
700    pub async fn get_url_forwards(
701        &self,
702        domain: &str,
703    ) -> Result<Vec<ForwardWithID>, Error<T::Error>> {
704        let resp: GetUrlForwardingResponse = self
705            .post_with_api_key(uri::get_url_forward(domain)?, ())
706            .await?;
707        Ok(resp.forwards)
708    }
709
710    /// Add a new url forward to the given domain
711    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
712    pub async fn add_url_forward(&self, domain: &str, cmd: Forward) -> Result<(), Error<T::Error>> {
713        self.post_with_api_key(uri::add_url_forward(domain)?, cmd)
714            .await
715    }
716
717    /// Delete a url forward with the given id from the given domain
718    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
719    pub async fn delete_url_forward(&self, domain: &str, id: &str) -> Result<(), Error<T::Error>> {
720        self.post_with_api_key(uri::delete_url_forward(domain, id)?, ())
721            .await
722    }
723
724    /// Get the SSL certificate bundle for a given domain
725    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
726    pub async fn get_ssl_bundle(&mut self, domain: &str) -> Result<SslBundle, Error<T::Error>> {
727        self.post_with_api_key(uri::get_ssl_bundle(domain)?, ())
728            .await
729    }
730}