zone-update 0.11.0

A library of CRUD-like operations on DNS zones for multiple providers
Documentation
use std::str::FromStr;

use serde::{de::DeserializeOwned, Deserialize, Deserializer};
use tracing::{error, warn};
use ureq::{
    http::{
        header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
        HeaderName, HeaderValue, Response, StatusCode
    },
    tls::TlsConfig,
    Agent, Body, RequestBuilder, ResponseExt
};

use crate::{errors::{Error, Result}};


/// Extension trait for converting ureq HTTP responses to optional
/// values or error information.
///
/// This trait provides methods for handling HTTP responses in a way that:
/// - Converts successful responses (status 200 OK) into deserialized values
/// - Treats not found responses (status 404) as `None` values
/// - Converts other error statuses into appropriate error types
pub(crate) trait ResponseToOption: Sized {
    /// Converts the HTTP response body to an optional value based on the status code.
    ///
    /// This method handles different HTTP status codes as follows:
    /// - `200 OK`: Deserializes the response body into the requested type `T`
    /// - `404 NOT_FOUND`: Returns `None` without erroring (useful for optional lookups)
    /// - All other status codes: Returns an `ApiError` with the status and response body
    ///
    /// # Type Parameters
    ///
    /// * `T` - The type to deserialize the response body into. Must implement `DeserializeOwned`.
    ///
    /// # Returns
    ///
    /// Returns a `Result` containing:
    /// - `Ok(Some(T))` if the response status is 200 OK and deserialization succeeds
    /// - `Ok(None)` if the response status is 404 NOT_FOUND
    /// - `Err(Error)` if the response status is not 200 or 404, or if deserialization fails
    fn to_option<T>(&mut self) -> Result<Option<T>>
    where
        T: DeserializeOwned;

    /// Checks for HTTP errors in the response.
    ///
    /// This method provides custom error handling for `ureq` responses. Since the
    /// default `http_status_as_error` feature is disabled to allow for more
    /// granular control (like treating 404 as `None`), this function is used to
    /// explicitly check for other error-level status codes.
    ///
    /// If the response status is successful (2xx), it returns the response as-is.
    /// Otherwise, it reads the response body, logs the error, and returns an
    /// `Error::HttpError` with the status code and body content.
    ///
    /// # Returns
    ///
    /// - `Ok(Self)` if the response was successful.
    /// - `Err(Error::HttpError)` if the response status indicates an error.
    fn check_error(self) -> Result<Self>;

}



impl ResponseToOption for Response<Body> {

    fn to_option<T>(&mut self) -> Result<Option<T>>
    where
        T: DeserializeOwned
    {
        let body = self.body_mut().read_to_string()?;
        match self.status() {
            StatusCode::OK => {
                let obj: T = serde_json::from_str(&body)?;
                Ok(Some(obj))
            }
            StatusCode::NOT_FOUND => {
                warn!("Record doesn't exist: {} -> {body}", self.get_uri());
                Ok(None)
            }
            _ => {
                Err(Error::ApiError(format!("Api Error: {} -> {body}", self.status())))
            }
        }
    }

    fn check_error(mut self) -> Result<Self> {
        // This roughly duplicates the effect of ureq's
        // `http_status_as_error` flag, which we disable to simplify
        // finer-grained status handling above.
        let code = self.status();
        if code.is_success() {
            return Ok(self)
        }

        let err = self.body_mut()
            .read_to_string()?;
        error!("REST op failed: {code} {err:?}");

        Err(Error::HttpError(format!("REST op failed: {code} {err:?}")))
    }

}


/// Extension trait for adding headers and authentication to a `ureq` request builder.
///
/// This trait provides convenient methods for adding multiple headers, authentication tokens,
/// and common JSON headers to a `RequestBuilder`.
pub(crate) trait WithHeaders<T> {
    /// Adds a collection of headers to the request builder.
    ///
    /// This method takes a vector of key-value pairs and adds them as headers to the request.
    /// It validates both the header names and values, returning an error if either is invalid.
    ///
    /// # Arguments
    ///
    /// * `headers` - A vector of `(&str, String)` tuples representing header key-value pairs.
    ///
    /// # Returns
    ///
    /// Returns a `Result` containing the modified `RequestBuilder` on success, or an `Error`
    /// if header validation fails.
    fn with_headers(self, headers: Vec<(&str, String)>) -> Result<RequestBuilder<T>>;

    /// Adds an `AUTHORIZATION` header to the request builder.
    ///
    /// This is a convenience method for setting the `AUTHORIZATION` header, commonly used for
    /// bearer tokens or other authentication schemes.
    ///
    /// # Arguments
    ///
    /// * `auth` - The authentication token or value as a `String`.
    ///
    /// # Returns
    ///
    /// Returns the modified `RequestBuilder`.
    fn with_auth(self, auth: String) -> RequestBuilder<T>;

    /// Adds `ACCEPT` and `CONTENT_TYPE` headers for JSON content.
    ///
    /// This method sets the `ACCEPT` and `CONTENT_TYPE` headers to `application/json`,
    /// which is a common requirement for REST APIs.
    ///
    /// # Returns
    ///
    /// Returns the modified `RequestBuilder`.
    fn with_json_headers(self) -> RequestBuilder<T>;
}

/// Implementation of the `WithHeaders` trait for `RequestBuilder`.
impl<Any> WithHeaders<Any> for RequestBuilder<Any> {

    fn with_headers(mut self, headers: Vec<(&str, String)>) -> Result<Self> {
        let reqh = self.headers_mut()
            .ok_or(Error::HttpError("Failed to get headers from ureq".to_string()))?;

        for (k, v) in headers {
            reqh.insert(HeaderName::from_str(k)?, HeaderValue::from_str(&v)?);
        }

        Ok(self)
    }

    fn with_auth(self, auth: String) -> Self {
        self.header(AUTHORIZATION, auth)
    }

    fn with_json_headers(self) -> Self {
        self.header(ACCEPT, "application/json")
            .header(CONTENT_TYPE, "application/json")
    }
}

/// Create and return a configured HTTP ureq agent.
///
/// This function sets up a ureq Agent with specific configuration options
/// that are suitable for DNS provider APIs, including support for legacy
/// TLS protocol versions that some providers may still use.
///
/// The client is configured with:
/// - HTTP status codes not treated as errors (http_status_as_error(false))
/// - Native TLS provider to support legacy TLS versions that Rustls doesn't support
///
/// # Returns
///
/// Returns a configured `Agent` instance that can be used to make HTTP requests.
pub(crate) fn client() -> Agent {
    Agent::config_builder()
        .http_status_as_error(false)
        .tls_config(
            // At least one provider (DnsMadeEasy) uses legacy TLS
            // protocol versions that Rustls doesn't support on their
            // sandbox.
            TlsConfig::builder()
                .provider(ureq::tls::TlsProvider::Rustls)
                .build()
        )
        .build()
        .new_agent()
}



pub(crate) fn de_str<'de, T, D>(destr: D) -> std::result::Result<T, D::Error>
where
    T: FromStr,
    T::Err: std::fmt::Display,
    D: Deserializer<'de>,
{
    let s = String::deserialize(destr)?;
    T::from_str(&s)
        .map_err(serde::de::Error::custom)
}