technitium 0.4.0

Typed async Rust client for the Technitium DNS Server API
Documentation
use serde::Deserialize;

/// Request parameters attached to errors for debugging.
///
/// Sensitive fields (token, password, TOTP) are excluded.
#[derive(Debug, Clone, Default)]
pub struct RequestParams(pub Vec<(String, String)>);

impl std::fmt::Display for RequestParams {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if self.0.is_empty() {
            return write!(f, "no params");
        }
        for (i, (k, v)) in self.0.iter().enumerate() {
            if i > 0 {
                write!(f, ", ")?;
            }
            write!(f, "{k}={v}")?;
        }
        Ok(())
    }
}

#[allow(clippy::trivially_copy_pass_by_ref, clippy::ref_option)]
fn format_status(code: &Option<u16>) -> String {
    match code {
        Some(c) => format!(" [HTTP {c}] "),
        None => " ".to_string(),
    }
}

/// All errors that can occur when using the Technitium client.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// No authentication token is set on the client.
    #[error("not authenticated — call login() or set a token")]
    NotAuthenticated,

    /// Login failed due to bad credentials.
    #[error("authentication failed: {message}")]
    Authentication {
        /// Server-provided error message.
        message: String,
    },

    /// The session token is invalid or has expired.
    #[error("session token is invalid or expired")]
    InvalidToken,

    /// The server requires a two-factor authentication code.
    #[error("two-factor authentication required")]
    TwoFactorRequired,

    /// The server returned an error response.
    #[error(
        "server error on {path}{}({params}): {message}",
        format_status(status_code)
    )]
    Server {
        /// Server-provided error message.
        message: String,
        /// HTTP status code, if available.
        status_code: Option<u16>,
        /// The API endpoint path that returned the error.
        path: String,
        /// Request parameters that were sent (excluding sensitive fields).
        params: RequestParams,
    },

    /// An HTTP transport error occurred.
    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),

    /// The response body could not be deserialized.
    #[error("failed to parse response: {0}")]
    Deserialization(#[from] serde_json::Error),

    /// The client was configured incorrectly.
    #[error("invalid configuration: {reason}")]
    Config {
        /// Description of the configuration problem.
        reason: String,
    },
}

impl Error {
    /// Whether this error was caused by a network timeout.
    #[must_use]
    pub fn is_timeout(&self) -> bool {
        matches!(self, Self::Http(e) if e.is_timeout())
    }

    /// Whether this error was caused by a connection failure.
    #[must_use]
    pub fn is_connection_error(&self) -> bool {
        matches!(self, Self::Http(e) if e.is_connect())
    }

    /// Whether this error is transient and safe to retry.
    #[must_use]
    pub fn is_retryable(&self) -> bool {
        match self {
            Self::Http(e) => {
                if e.is_connect() || e.is_timeout() {
                    return true;
                }
                if let Some(status) = e.status() {
                    return matches!(status.as_u16(), 429 | 502 | 503);
                }
                e.is_request()
            }
            Self::NotAuthenticated
            | Self::Authentication { .. }
            | Self::InvalidToken
            | Self::TwoFactorRequired
            | Self::Server { .. }
            | Self::Deserialization(_)
            | Self::Config { .. } => false,
        }
    }
}

/// Raw JSON envelope returned by every Technitium API endpoint.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ApiResponse<T> {
    pub status: String,
    #[serde(default)]
    pub error_message: Option<String>,
    /// Data nested under `"response"` key.
    pub response: Option<T>,
    /// HTTP status code from the response (set by caller, not deserialized).
    #[serde(skip)]
    pub http_status: Option<u16>,
    /// API path that produced this response (set by caller, not deserialized).
    #[serde(skip)]
    pub path: String,
    /// Request parameters (set by caller, not deserialized). Excludes sensitive fields.
    #[serde(skip)]
    pub params: RequestParams,
}

impl<T> ApiResponse<T> {
    /// Check the status field only, ignoring the response data.
    pub fn check_status(&self) -> Result<(), Error> {
        match self.status.as_str() {
            "ok" => Ok(()),
            "error" => Err(Error::Server {
                message: self
                    .error_message
                    .clone()
                    .unwrap_or_else(|| "unknown server error".to_string()),
                status_code: self.http_status,
                path: self.path.clone(),
                params: self.params.clone(),
            }),
            "invalid-token" => Err(Error::InvalidToken),
            "2fa-required" => Err(Error::TwoFactorRequired),
            other => Err(Error::Server {
                message: format!("unexpected status: {other}"),
                status_code: self.http_status,
                path: self.path.clone(),
                params: self.params.clone(),
            }),
        }
    }

    /// Extract the response data.
    pub fn into_result(self) -> Result<T, Error> {
        match self.status.as_str() {
            "ok" => self.response.ok_or_else(|| Error::Server {
                message: "response body missing expected data".to_string(),
                status_code: self.http_status,
                path: self.path.clone(),
                params: self.params.clone(),
            }),
            "error" => Err(Error::Server {
                message: self
                    .error_message
                    .unwrap_or_else(|| "unknown server error".to_string()),
                status_code: self.http_status,
                path: self.path,
                params: self.params,
            }),
            "invalid-token" => Err(Error::InvalidToken),
            "2fa-required" => Err(Error::TwoFactorRequired),
            other => Err(Error::Server {
                message: format!("unexpected status: {other}"),
                status_code: self.http_status,
                path: self.path,
                params: self.params,
            }),
        }
    }
}