#![warn(missing_docs)]
mod serde_util;
pub mod transport;
#[cfg(feature = "default-client")]
use transport::DefaultTransport;
use transport::MakeRequest;
mod error;
pub use error::Error;
use error::{ApiErrorMessage, ApiResponse, ErrorImpl};
mod uri;
use chrono::NaiveDateTime;
use http_body_util::{BodyExt, Full};
use hyper::{
body::{Body, Bytes},
Request, StatusCode, Uri,
};
use serde::{Deserialize, Serialize};
use std::{
borrow::Cow,
collections::HashMap,
fmt::Display,
net::{IpAddr, Ipv4Addr, Ipv6Addr},
time::Duration,
};
#[derive(Deserialize, Serialize, Clone)]
pub struct ApiKey {
secretapikey: String,
apikey: String,
}
impl ApiKey {
pub fn new(secret: impl Into<String>, api_key: impl Into<String>) -> Self {
Self {
secretapikey: secret.into(),
apikey: api_key.into(),
}
}
}
#[allow(missing_docs)]
#[derive(Clone, Copy, Deserialize, Serialize, Debug, PartialEq, Eq)]
pub enum DnsRecordType {
A,
MX,
CNAME,
ALIAS,
TXT,
NS,
AAAA,
SRV,
TLSA,
CAA,
HTTPS,
SVCB,
}
impl Display for DnsRecordType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::A => "A",
Self::MX => "MX",
Self::CNAME => "CNAME",
Self::ALIAS => "ALIAS",
Self::TXT => "TXT",
Self::NS => "NS",
Self::AAAA => "AAAA",
Self::SRV => "SRV",
Self::TLSA => "TLSA",
Self::CAA => "CAA",
Self::HTTPS => "HTTPS",
Self::SVCB => "SVCB",
})
}
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct CreateOrEditDnsRecord<'a> {
#[serde(rename = "name")]
pub subdomain: Option<&'a str>,
#[serde(rename = "type")]
pub record_type: DnsRecordType,
pub content: Cow<'a, str>,
pub ttl: Option<u64>,
pub prio: u32,
}
impl<'a> CreateOrEditDnsRecord<'a> {
pub fn new(
subdomain: Option<&'a str>,
record_type: DnsRecordType,
content: impl Into<Cow<'a, str>>,
) -> Self {
Self {
subdomain,
record_type,
content: content.into(),
ttl: None,
prio: 0,
}
}
#[allow(non_snake_case)]
pub fn A(subdomain: Option<&'a str>, ip: Ipv4Addr) -> Self {
Self::new(subdomain, DnsRecordType::A, Cow::Owned(ip.to_string()))
}
#[allow(non_snake_case)]
pub fn AAAA(subdomain: Option<&'a str>, ip: Ipv6Addr) -> Self {
Self::new(subdomain, DnsRecordType::AAAA, Cow::Owned(ip.to_string()))
}
#[allow(non_snake_case)]
pub fn A_or_AAAA(subdomain: Option<&'a str>, ip: IpAddr) -> Self {
match ip {
IpAddr::V4(my_ip) => Self::A(subdomain, my_ip),
IpAddr::V6(my_ip) => Self::AAAA(subdomain, my_ip),
}
}
#[must_use]
pub fn with_ttl(self, ttl: Option<Duration>) -> Self {
Self {
ttl: ttl.as_ref().map(Duration::as_secs),
..self
}
}
#[must_use]
pub fn with_priority(self, prio: u32) -> Self {
Self { prio, ..self }
}
}
#[derive(Deserialize, Debug)]
struct EntryId {
#[serde(with = "serde_util::string_or_int")]
id: String,
}
#[derive(Deserialize, Debug)]
pub struct DnsEntry {
#[serde(with = "serde_util::string_or_int")]
pub id: String,
pub name: String,
#[serde(rename = "type")]
pub record_type: DnsRecordType,
pub content: String,
#[serde(with = "serde_util::u64_from_string_or_int")]
pub ttl: u64,
#[serde(default, with = "serde_util::u64_from_string_or_int")]
pub prio: u64,
pub notes: Option<String>,
}
#[derive(Deserialize, Debug)]
struct DnsRecordsByDomainOrIDResponse {
records: Vec<DnsEntry>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Pricing {
pub registration: String,
pub renewal: String,
pub transfer: String,
pub special_type: TldType,
}
impl Pricing {
pub fn is_icann(&self) -> bool {
self.special_type.is_icann()
}
}
#[derive(Debug)]
pub enum TldType {
Normal,
Handshake,
Other(String),
}
impl TldType {
pub fn is_icann(&self) -> bool {
matches!(self, Self::Normal)
}
}
impl<'de> Deserialize<'de> for TldType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let string_value = Option::<String>::deserialize(deserializer)?;
Ok(match string_value {
None => Self::Normal,
Some(s) => {
if s.eq_ignore_ascii_case("handshake") {
Self::Handshake
} else {
Self::Other(s)
}
}
})
}
}
#[derive(Deserialize, Debug)]
struct DomainPricingResponse {
pricing: HashMap<String, Pricing>,
}
#[derive(Serialize, Deserialize)]
struct UpdateNameServers {
ns: Vec<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DomainListAll {
start: usize,
#[serde(default, with = "serde_util::yesno")]
include_labels: bool,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct DomainListAllResponse {
domains: Vec<DomainInfo>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DomainInfo {
pub domain: String,
pub status: String,
pub tld: String,
#[serde(with = "serde_util::datetime")]
pub create_date: NaiveDateTime,
#[serde(with = "serde_util::datetime")]
pub expire_date: NaiveDateTime,
#[serde(with = "serde_util::stringoneintzero")]
pub security_lock: bool,
#[serde(with = "serde_util::stringoneintzero")]
pub whois_privacy: bool,
#[serde(with = "serde_util::stringoneintzero")]
pub auto_renew: bool,
#[serde(with = "serde_util::stringoneintzero")]
pub not_local: bool,
#[serde(default)]
pub labels: Vec<Label>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Label {
#[serde(deserialize_with = "serde_util::string_or_int::deserialize")]
pub id: String,
pub title: String,
pub color: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Forward {
#[serde(skip_serializing_if = "Option::is_none")]
pub subdomain: Option<String>,
pub location: String,
#[serde(rename = "type")]
pub forward_type: ForwardType,
#[serde(with = "serde_util::yesno")]
pub include_path: bool,
#[serde(with = "serde_util::yesno")]
pub wildcard: bool,
}
impl Forward {
pub fn new(subdomain: Option<impl Into<String>>, to: impl Into<String>) -> Self {
Self {
subdomain: subdomain.map(|s| s.into()),
location: to.into(),
forward_type: ForwardType::Temporary,
include_path: true,
wildcard: false,
}
}
pub fn with_wildcard(self, wildcard: bool) -> Self {
Self { wildcard, ..self }
}
pub fn include_path(self, include_path: bool) -> Self {
Self {
include_path,
..self
}
}
pub fn with_forward_type(self, forward_type: ForwardType) -> Self {
Self {
forward_type,
..self
}
}
}
#[allow(missing_docs)]
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum ForwardType {
Temporary,
Permanent,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct GetUrlForwardingResponse {
forwards: Vec<ForwardWithID>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ForwardWithID {
#[serde(deserialize_with = "serde_util::string_or_int::deserialize")]
pub id: String,
#[serde(flatten, rename = "forward")]
pub config: Forward,
}
#[derive(Deserialize, Debug)]
pub struct SslBundle {
#[serde(rename = "certificatechain")]
pub certificate_chain: String,
#[serde(rename = "privatekey")]
pub private_key: String,
#[serde(rename = "publickey")]
pub public_key: String,
}
#[derive(Serialize)]
struct WithApiKeys<'a, T: Serialize> {
#[serde(flatten)]
api_key: &'a ApiKey,
#[serde(flatten)]
inner: T,
}
pub struct Client<P: MakeRequest> {
inner: P,
api_key: ApiKey,
}
#[cfg(feature = "default-client")]
impl Client<DefaultTransport> {
pub fn new(api_key: ApiKey) -> Self {
Client::new_with_transport(api_key, DefaultTransport::default())
}
}
impl<T> Client<T>
where
T: MakeRequest,
<T::Body as Body>::Error: Into<T::Error>,
{
pub fn new_with_transport(api_key: ApiKey, transport: T) -> Self {
Self {
inner: transport,
api_key,
}
}
async fn post<D: for<'a> Deserialize<'a>>(
&self,
uri: Uri,
body: Full<Bytes>,
) -> Result<D, Error<T::Error>> {
let request = Request::post(uri).body(body).unwrap(); let resp = self
.inner
.request(request)
.await
.map_err(ErrorImpl::TransportError)?;
let (head, body) = resp.into_parts();
let bytes = body
.collect()
.await
.map_err(|e| ErrorImpl::TransportError(e.into()))?
.to_bytes();
let result = std::result::Result::<_, ApiErrorMessage>::from(
serde_json::from_slice::<ApiResponse<_>>(&bytes)
.map_err(ErrorImpl::DeserializationError)?,
);
match (head.status, result) {
(StatusCode::OK, Ok(x)) => Ok(x),
(status, maybe_message) => Err((status, maybe_message.err()).into()),
}
}
async fn post_with_api_key<S: Serialize, D: for<'a> Deserialize<'a>>(
&self,
uri: Uri,
body: S,
) -> Result<D, Error<T::Error>> {
let with_api_key = WithApiKeys {
api_key: &self.api_key,
inner: body,
};
let json = serde_json::to_string(&with_api_key).map_err(ErrorImpl::SerializationError)?;
let body = http_body_util::Full::new(Bytes::from(json));
self.post(uri, body).await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn ping(&self) -> Result<IpAddr, Error<T::Error>> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PingResponse {
your_ip: IpAddr,
}
let ping: PingResponse = self.post_with_api_key(uri::ping(), ()).await?;
Ok(ping.your_ip)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn domain_pricing(&self) -> Result<HashMap<String, Pricing>, Error<T::Error>> {
let resp: DomainPricingResponse = self.post(uri::domain_pricing(), Full::default()).await?;
Ok(resp.pricing)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn icann_domain_pricing(
&self,
) -> Result<impl Iterator<Item = (String, Pricing)>, Error<T::Error>> {
let resp: DomainPricingResponse = self.post(uri::domain_pricing(), Full::default()).await?;
Ok(resp.pricing.into_iter().filter(|(_, v)| v.is_icann()))
}
async fn list_domains(&self, offset: usize) -> Result<Vec<DomainInfo>, Error<T::Error>> {
let resp: DomainListAllResponse = self
.post_with_api_key(
uri::domain_list_all(),
DomainListAll {
start: offset,
include_labels: true,
},
)
.await?;
Ok(resp.domains)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn domains(&self) -> Result<Vec<DomainInfo>, Error<T::Error>> {
let mut all = self.list_domains(0).await?;
let mut last_len = all.len();
while last_len != 0 {
let next = self.list_domains(all.len()).await?;
last_len = next.len();
all.extend(next.into_iter());
}
Ok(all)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn get_all(&self, domain: &str) -> Result<Vec<DnsEntry>, Error<T::Error>> {
let rsp: DnsRecordsByDomainOrIDResponse = self
.post_with_api_key(uri::get_dns_record_by_domain_and_id(domain, None)?, ())
.await?;
Ok(rsp.records)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn get_single(
&self,
domain: &str,
id: &str,
) -> Result<Option<DnsEntry>, Error<T::Error>> {
let rsp: DnsRecordsByDomainOrIDResponse = self
.post_with_api_key(uri::get_dns_record_by_domain_and_id(domain, Some(id))?, ())
.await?;
let rsp = rsp.records.into_iter().next();
Ok(rsp)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn create(
&self,
domain: &str,
cmd: CreateOrEditDnsRecord<'_>,
) -> Result<String, Error<T::Error>> {
let resp: EntryId = self
.post_with_api_key(uri::create_dns_record(domain)?, cmd)
.await?;
Ok(resp.id)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn edit(
&self,
domain: &str,
id: &str,
cmd: CreateOrEditDnsRecord<'_>,
) -> Result<(), Error<T::Error>> {
self.post_with_api_key(uri::edit_dns_record(domain, id)?, cmd)
.await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn delete(&self, domain: &str, id: &str) -> Result<(), Error<T::Error>> {
self.post_with_api_key(uri::delete_dns_record_by_id(domain, id)?, ())
.await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn nameservers(&self, domain: &str) -> Result<Vec<String>, Error<T::Error>> {
let resp: UpdateNameServers = self
.post_with_api_key(uri::get_name_servers(domain)?, ())
.await?;
Ok(resp.ns)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn update_nameservers(
&self,
domain: &str,
name_servers: Vec<String>,
) -> Result<(), Error<T::Error>> {
self.post_with_api_key(
uri::update_name_servers(domain)?,
UpdateNameServers { ns: name_servers },
)
.await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn get_url_forwards(
&self,
domain: &str,
) -> Result<Vec<ForwardWithID>, Error<T::Error>> {
let resp: GetUrlForwardingResponse = self
.post_with_api_key(uri::get_url_forward(domain)?, ())
.await?;
Ok(resp.forwards)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn add_url_forward(&self, domain: &str, cmd: Forward) -> Result<(), Error<T::Error>> {
self.post_with_api_key(uri::add_url_forward(domain)?, cmd)
.await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn delete_url_forward(&self, domain: &str, id: &str) -> Result<(), Error<T::Error>> {
self.post_with_api_key(uri::delete_url_forward(domain, id)?, ())
.await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub async fn get_ssl_bundle(&mut self, domain: &str) -> Result<SslBundle, Error<T::Error>> {
self.post_with_api_key(uri::get_ssl_bundle(domain)?, ())
.await
}
}