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