hetzner 1.0.0

A Rust library for managing DNS records and zones on Hetzner's platform, providing tools for networking and DNS management.
Documentation
use crate::api::{
    cloud::CloudApi,
    dns::{DnsApi, records::UpdateRecordInput},
};
use crate::error::{ApiError, ApiErrorEnvelope, HetznerError, Result};
use crate::types::{CreatedRecord, Record, RecordEnvelope, Zone};
use reqwest::{Method, StatusCode};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;

const DEFAULT_DNS_BASE_URL: &str = "https://dns.hetzner.com/api/v1";
const DEFAULT_CLOUD_BASE_URL: &str = "https://api.hetzner.cloud/v1";

#[derive(Debug, Clone)]
pub struct HetznerClient {
    pub(crate) http: reqwest::Client,
    pub(crate) auth_api_token: String,
    pub(crate) dns_base_url: String,
    pub(crate) cloud_base_url: String,
}

impl HetznerClient {
    pub fn new(auth_api_token: impl Into<String>) -> Self {
        Self {
            http: reqwest::Client::new(),
            auth_api_token: auth_api_token.into(),
            dns_base_url: DEFAULT_DNS_BASE_URL.to_string(),
            cloud_base_url: DEFAULT_CLOUD_BASE_URL.to_string(),
        }
    }

    pub fn with_dns_base_url(mut self, base_url: impl Into<String>) -> Self {
        self.dns_base_url = base_url.into();
        self
    }

    #[deprecated(note = "Use with_dns_base_url(...) or with_cloud_base_url(...) instead.")]
    pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
        self.with_dns_base_url(base_url)
    }

    pub fn with_cloud_base_url(mut self, base_url: impl Into<String>) -> Self {
        self.cloud_base_url = base_url.into();
        self
    }

    pub fn dns(&self) -> DnsApi<'_> {
        DnsApi { client: self }
    }

    pub fn cloud(&self) -> CloudApi<'_> {
        CloudApi { client: self }
    }

    pub(crate) async fn request_dns<T: DeserializeOwned>(
        &self,
        method: Method,
        path: &str,
        body: Option<Value>,
    ) -> Result<T> {
        self.request_to_base(
            &self.dns_base_url,
            "Auth-API-Token",
            "",
            method,
            path,
            Option::<&Vec<(String, String)>>::None,
            body,
        )
        .await
    }

    pub(crate) async fn request_dns_unit(
        &self,
        method: Method,
        path: &str,
        body: Option<Value>,
    ) -> Result<()> {
        self.request_unit_to_base(
            &self.dns_base_url,
            "Auth-API-Token",
            "",
            method,
            path,
            Option::<&Vec<(String, String)>>::None,
            body,
        )
        .await
    }

    pub(crate) async fn request_cloud<T: DeserializeOwned, Q: Serialize>(
        &self,
        method: Method,
        path: &str,
        query: Option<&Q>,
        body: Option<Value>,
    ) -> Result<T> {
        self.request_to_base(
            &self.cloud_base_url,
            "Authorization",
            "Bearer ",
            method,
            path,
            query,
            body,
        )
        .await
    }

    async fn request_to_base<T: DeserializeOwned, Q: Serialize>(
        &self,
        base_url: &str,
        auth_header: &str,
        auth_prefix: &str,
        method: Method,
        path: &str,
        query: Option<&Q>,
        body: Option<Value>,
    ) -> Result<T> {
        let url = format!("{}/{}", base_url.trim_end_matches('/'), path);
        let mut req = self
            .http
            .request(method, &url)
            .header(auth_header, format!("{auth_prefix}{}", self.auth_api_token));

        if let Some(params) = query {
            req = req.query(params);
        }

        if let Some(payload) = body {
            req = req.json(&payload);
        }

        let response = req.send().await?;
        let status = response.status();

        if status.is_success() {
            let parsed = response.json::<T>().await?;
            return Ok(parsed);
        }

        let body_text = response.text().await?;
        Err(HetznerError::Api(parse_api_error(status, body_text)))
    }

    async fn request_unit_to_base<Q: Serialize>(
        &self,
        base_url: &str,
        auth_header: &str,
        auth_prefix: &str,
        method: Method,
        path: &str,
        query: Option<&Q>,
        body: Option<Value>,
    ) -> Result<()> {
        let url = format!("{}/{}", base_url.trim_end_matches('/'), path);
        let mut req = self
            .http
            .request(method, &url)
            .header(auth_header, format!("{auth_prefix}{}", self.auth_api_token));

        if let Some(params) = query {
            req = req.query(params);
        }

        if let Some(payload) = body {
            req = req.json(&payload);
        }

        let response = req.send().await?;
        let status = response.status();

        if status.is_success() {
            return Ok(());
        }

        let body_text = response.text().await?;
        Err(HetznerError::Api(parse_api_error(status, body_text)))
    }

    #[deprecated(
        note = "Legacy DNS methods on HetznerClient are deprecated. Use client.dns().list_zones()."
    )]
    pub async fn get_all_zones(&self) -> Result<Vec<Zone>> {
        self.dns().list_zones().await
    }

    #[deprecated(
        note = "Legacy DNS methods on HetznerClient are deprecated. Use client.dns().records(zone_id).list()."
    )]
    pub async fn get_all_records(&self, zone_id: &str) -> Result<Vec<Record>> {
        self.dns().records(zone_id).list().await
    }

    #[deprecated(
        note = "Legacy DNS methods on HetznerClient are deprecated. Use client.dns().records(zone_id).create(...)."
    )]
    pub async fn create_record(
        &self,
        value: &str,
        ttl: u64,
        type_: &str,
        name: &str,
        zone_id: &str,
    ) -> Result<CreatedRecord> {
        self.dns()
            .records(zone_id)
            .create(name, type_, value, ttl)
            .await
    }

    #[deprecated(
        note = "Legacy DNS methods on HetznerClient are deprecated. Use client.dns().record(record_id)."
    )]
    pub async fn get_record(&self, record_id: &str) -> Result<RecordEnvelope> {
        self.dns().record(record_id).get().await
    }

    #[deprecated(
        note = "Legacy DNS methods on HetznerClient are deprecated. Use client.dns().record(record_id).update(...)."
    )]
    pub async fn update_record(
        &self,
        record_id: &str,
        zone_id: &str,
        type_: &str,
        name: &str,
        value: &str,
        ttl: u64,
    ) -> Result<RecordEnvelope> {
        self.dns()
            .record(record_id)
            .update(UpdateRecordInput {
                zone_id: zone_id.to_string(),
                record_type: type_.to_string(),
                name: name.to_string(),
                value: value.to_string(),
                ttl,
            })
            .await
    }

    #[deprecated(
        note = "Legacy DNS methods on HetznerClient are deprecated. Use client.dns().record(record_id).delete()."
    )]
    pub async fn delete_record(&self, record_id: &str) -> Result<()> {
        self.dns().record(record_id).delete().await
    }
}

fn parse_api_error(status: StatusCode, body_text: String) -> ApiError {
    let parsed_error = serde_json::from_str::<ApiErrorEnvelope>(&body_text);

    match parsed_error {
        Ok(envelope) => ApiError {
            status,
            code: envelope.error.code,
            message: envelope.error.message,
            details: envelope.error.details,
        },
        Err(_) => ApiError {
            status,
            code: status_code_to_default_code(status).to_string(),
            message: body_text,
            details: None,
        },
    }
}

fn status_code_to_default_code(status: StatusCode) -> &'static str {
    match status {
        StatusCode::BAD_REQUEST => "json_error",
        StatusCode::UNAUTHORIZED => "unauthorized",
        StatusCode::FORBIDDEN => "forbidden",
        StatusCode::NOT_FOUND => "not_found",
        StatusCode::METHOD_NOT_ALLOWED => "method_not_allowed",
        StatusCode::CONFLICT => "conflict",
        StatusCode::GONE => "deprecated_api_endpoint",
        StatusCode::PRECONDITION_FAILED => "resource_unavailable",
        StatusCode::UNPROCESSABLE_ENTITY => "invalid_input",
        StatusCode::LOCKED => "locked",
        StatusCode::TOO_MANY_REQUESTS => "rate_limit_exceeded",
        StatusCode::INTERNAL_SERVER_ERROR => "server_error",
        StatusCode::SERVICE_UNAVAILABLE => "unavailable",
        StatusCode::GATEWAY_TIMEOUT => "timeout",
        _ => "unknown_error",
    }
}