hamsando 0.2.1

A simple and type-safe client for the Porkbun API.
Documentation
//! Type-safe DNS record.

use std::{
    error::Error,
    net::{IpAddr, Ipv4Addr, Ipv6Addr},
    str::FromStr,
};

use serde::Deserialize;
use strum::IntoStaticStr;

use crate::{ContentCreationError, domain::Domain};

/// Possible types a DNS record can have.
#[derive(Debug, Deserialize, PartialEq, Eq, IntoStaticStr)]
#[serde(rename_all = "UPPERCASE")]
#[strum(serialize_all = "UPPERCASE")]
pub enum Type {
    A,
    Mx,
    Cname,
    Alias,
    Txt,
    Ns,
    Aaaa,
    Srv,
    Tlsa,
    Caa,
    Https,
    Svcb,
}

impl Type {
    /// Gets the string representation of the type.
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        self.into()
    }
}

impl From<&Content> for Type {
    fn from(value: &Content) -> Self {
        match value {
            Content::A(_) => Type::A,
            Content::Mx(_) => Type::Mx,
            Content::Cname(_) => Type::Cname,
            Content::Alias(_) => Type::Alias,
            Content::Txt(_) => Type::Txt,
            Content::Ns(_) => Type::Ns,
            Content::Aaaa(_) => Type::Aaaa,
            Content::Srv(_) => Type::Srv,
            Content::Tlsa(_) => Type::Tlsa,
            Content::Caa(_) => Type::Caa,
            Content::Https(_) => Type::Https,
            Content::Svcb(_) => Type::Svcb,
        }
    }
}

/// The content value of a DNS record with type-safe variants for each type.
///
/// Ensures that each DNS record type contains the appropriate value format.
///
/// # Examples
///
/// ```
/// use hamsando::record::Content;
/// use std::net::{IpAddr, Ipv4Addr};
///
/// let ip: IpAddr = "127.0.0.1".parse().unwrap();
/// let content: Content = ip.into();
///
/// assert_eq!(content, Content::A(Ipv4Addr::new(127, 0, 0, 1)));
/// ```
#[derive(Debug, PartialEq, Eq)]
pub enum Content {
    A(Ipv4Addr),
    Mx(String),
    Cname(String),
    Alias(String),
    Txt(String),
    Ns(String),
    Aaaa(Ipv6Addr),
    Srv(String),
    Tlsa(String),
    Caa(String),
    Https(String),
    Svcb(String),
}

impl Content {
    /// Gets the string representation of the type of the content.
    #[must_use]
    pub fn type_as_str(&self) -> &'static str {
        Type::from(self).into()
    }

    /// Converts the value in the content to a string.
    #[must_use]
    pub fn value_to_string(&self) -> String {
        match self {
            Content::A(addr) => addr.to_string(),
            Content::Aaaa(addr) => addr.to_string(),
            Content::Mx(value)
            | Content::Cname(value)
            | Content::Alias(value)
            | Content::Txt(value)
            | Content::Ns(value)
            | Content::Srv(value)
            | Content::Tlsa(value)
            | Content::Caa(value)
            | Content::Https(value)
            | Content::Svcb(value) => value.clone(),
        }
    }

    /// Creates a `Content` from a [`Type`] and a string.
    ///
    /// # Errors
    ///
    /// If the content can't be parsed into the correct type, this function will return
    /// a `ContentCreationError`.
    pub fn from(type_: &Type, content: &str) -> Result<Content, ContentCreationError> {
        Ok(match type_ {
            Type::A => Content::A(content.parse()?),
            Type::Mx => Content::Mx(content.to_owned()),
            Type::Cname => Content::Cname(content.to_owned()),
            Type::Alias => Content::Alias(content.to_owned()),
            Type::Txt => Content::Txt(content.to_owned()),
            Type::Ns => Content::Ns(content.to_owned()),
            Type::Aaaa => Content::Aaaa(content.parse()?),
            Type::Srv => Content::Srv(content.to_owned()),
            Type::Tlsa => Content::Tlsa(content.to_owned()),
            Type::Caa => Content::Caa(content.to_owned()),
            Type::Https => Content::Https(content.to_owned()),
            Type::Svcb => Content::Svcb(content.to_owned()),
        })
    }
}

impl From<IpAddr> for Content {
    fn from(value: IpAddr) -> Self {
        match value {
            IpAddr::V4(addr) => Content::A(addr),
            IpAddr::V6(addr) => Content::Aaaa(addr),
        }
    }
}

impl<'de> Deserialize<'de> for Content {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        use serde::de::Error as _;

        #[derive(Deserialize)]
        struct ContentDeserializable {
            #[serde(rename = "type")]
            type_: Type,
            content: String,
        }

        let c = ContentDeserializable::deserialize(deserializer)?;
        Content::from(&c.type_, &c.content).map_err(D::Error::custom)
    }
}

/// A DNS record.
#[derive(Debug, Deserialize)]
pub struct Record {
    #[serde(deserialize_with = "deserialize_string_or_t")]
    pub id: i64,
    pub name: Box<Domain>,
    #[serde(flatten)]
    pub content: Content,
    #[serde(deserialize_with = "deserialize_string_or_t")]
    pub ttl: i64,
    #[serde(deserialize_with = "deserialize_option_string_or_t")]
    pub prio: Option<i64>,
    pub notes: Option<String>,
}

/// Helper type for deserializing a string or any T to a T.
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOr<T> {
    String(String),
    T(T),
}

pub(crate) fn deserialize_string_or_t<'de, D, T: FromStr + Deserialize<'de>>(
    deserializer: D,
) -> Result<T, D::Error>
where
    D: serde::Deserializer<'de>,
    <T as FromStr>::Err: Error,
{
    use serde::de::Error as _;

    let string_or_t = StringOr::<T>::deserialize(deserializer)?;
    Ok(match string_or_t {
        StringOr::<T>::String(s) => s.parse().map_err(D::Error::custom)?,
        StringOr::<T>::T(t) => t,
    })
}

pub(crate) fn deserialize_option_string_or_t<'de, D, T: FromStr + Deserialize<'de>>(
    deserializer: D,
) -> Result<Option<T>, D::Error>
where
    D: serde::Deserializer<'de>,
    <T as FromStr>::Err: Error,
{
    use serde::de::Error as _;

    let string_or_i64 = Option::<StringOr<T>>::deserialize(deserializer)?;
    Ok(match string_or_i64 {
        Some(StringOr::<T>::String(s)) => Some(s.parse().map_err(D::Error::custom)?),
        Some(StringOr::<T>::T(t)) => Some(t),
        None => None,
    })
}