use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaginationParams {
pub page: u32,
pub page_size: u32,
}
impl Default for PaginationParams {
fn default() -> Self {
Self {
page: 1,
page_size: 20,
}
}
}
impl PaginationParams {
#[must_use]
pub fn validated(&self, max_page_size: u32) -> Self {
Self {
page: self.page.max(1),
page_size: self.page_size.clamp(1, max_page_size),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordQueryParams {
pub page: u32,
pub page_size: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub keyword: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub record_type: Option<DnsRecordType>,
}
impl Default for RecordQueryParams {
fn default() -> Self {
Self {
page: 1,
page_size: 20,
keyword: None,
record_type: None,
}
}
}
impl RecordQueryParams {
pub fn to_pagination(&self) -> PaginationParams {
PaginationParams {
page: self.page,
page_size: self.page_size,
}
}
#[must_use]
pub fn validated(&self, max_page_size: u32) -> Self {
Self {
page: self.page.max(1),
page_size: self.page_size.clamp(1, max_page_size),
keyword: self.keyword.clone(),
record_type: self.record_type.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaginatedResponse<T> {
pub items: Vec<T>,
pub page: u32,
pub page_size: u32,
pub total_count: u32,
pub has_more: bool,
}
impl<T> PaginatedResponse<T> {
pub fn new(items: Vec<T>, page: u32, page_size: u32, total_count: u32) -> Self {
let has_more = (page * page_size) < total_count;
Self {
items,
page,
page_size,
total_count,
has_more,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ProviderType {
#[cfg(feature = "cloudflare")]
Cloudflare,
#[cfg(feature = "aliyun")]
Aliyun,
#[cfg(feature = "dnspod")]
Dnspod,
#[cfg(feature = "huaweicloud")]
Huaweicloud,
}
impl std::fmt::Display for ProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#[cfg(feature = "cloudflare")]
Self::Cloudflare => write!(f, "cloudflare"),
#[cfg(feature = "aliyun")]
Self::Aliyun => write!(f, "aliyun"),
#[cfg(feature = "dnspod")]
Self::Dnspod => write!(f, "dnspod"),
#[cfg(feature = "huaweicloud")]
Self::Huaweicloud => write!(f, "huaweicloud"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DomainStatus {
Active,
Paused,
Pending,
Error,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderDomain {
pub id: String,
pub name: String,
pub provider: ProviderType,
pub status: DomainStatus,
#[serde(rename = "recordCount", skip_serializing_if = "Option::is_none")]
pub record_count: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum DnsRecordType {
A,
Aaaa,
Cname,
Mx,
Txt,
Ns,
Srv,
Caa,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "content")]
pub enum RecordData {
A {
address: String,
},
AAAA {
address: String,
},
CNAME {
target: String,
},
MX {
priority: u16,
exchange: String,
},
TXT {
text: String,
},
NS {
nameserver: String,
},
SRV {
priority: u16,
weight: u16,
port: u16,
target: String,
},
CAA {
flags: u8,
tag: String,
value: String,
},
}
impl RecordData {
pub fn record_type(&self) -> DnsRecordType {
match self {
Self::A { .. } => DnsRecordType::A,
Self::AAAA { .. } => DnsRecordType::Aaaa,
Self::CNAME { .. } => DnsRecordType::Cname,
Self::MX { .. } => DnsRecordType::Mx,
Self::TXT { .. } => DnsRecordType::Txt,
Self::NS { .. } => DnsRecordType::Ns,
Self::SRV { .. } => DnsRecordType::Srv,
Self::CAA { .. } => DnsRecordType::Caa,
}
}
pub fn display_value(&self) -> &str {
match self {
Self::A { address } | Self::AAAA { address } => address,
Self::CNAME { target } | Self::SRV { target, .. } => target,
Self::MX { exchange, .. } => exchange,
Self::TXT { text } => text,
Self::NS { nameserver } => nameserver,
Self::CAA { value, .. } => value,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DnsRecord {
pub id: String,
pub domain_id: String,
pub name: String,
pub ttl: u32,
pub data: RecordData,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxied: Option<bool>,
#[serde(with = "crate::utils::datetime")]
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(with = "crate::utils::datetime")]
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDnsRecordRequest {
pub domain_id: String,
pub name: String,
pub ttl: u32,
pub data: RecordData,
pub proxied: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateDnsRecordRequest {
pub domain_id: String,
pub name: String,
pub ttl: u32,
pub data: RecordData,
pub proxied: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchCreateResult {
pub success_count: usize,
pub failed_count: usize,
pub created_records: Vec<DnsRecord>,
pub failures: Vec<BatchCreateFailure>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchCreateFailure {
pub request_index: usize,
pub record_name: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchUpdateResult {
pub success_count: usize,
pub failed_count: usize,
pub updated_records: Vec<DnsRecord>,
pub failures: Vec<BatchUpdateFailure>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchUpdateFailure {
pub record_id: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchUpdateItem {
pub record_id: String,
pub request: UpdateDnsRecordRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchDeleteResult {
pub success_count: usize,
pub failed_count: usize,
pub failures: Vec<BatchDeleteFailure>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchDeleteFailure {
pub record_id: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FieldType {
Text,
Password,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderCredentialField {
pub key: String,
pub label: String,
#[serde(rename = "type")]
pub field_type: FieldType,
#[serde(skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub help_text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ProviderFeatures {
pub proxy: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderLimits {
pub max_page_size_domains: u32,
pub max_page_size_records: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderMetadata {
pub id: ProviderType,
pub name: String,
pub description: String,
pub required_fields: Vec<ProviderCredentialField>,
pub features: ProviderFeatures,
pub limits: ProviderLimits,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum CredentialValidationError {
MissingField {
provider: ProviderType,
field: String,
label: String,
},
EmptyField {
provider: ProviderType,
field: String,
label: String,
},
InvalidFormat {
provider: ProviderType,
field: String,
label: String,
reason: String,
},
}
impl std::fmt::Display for CredentialValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingField { label, .. } => write!(f, "Missing required field: {label}"),
Self::EmptyField { label, .. } => write!(f, "Field must not be empty: {label}"),
Self::InvalidFormat { label, reason, .. } => write!(f, "{label}: {reason}"),
}
}
}
impl std::error::Error for CredentialValidationError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "provider", content = "credentials")]
pub enum ProviderCredentials {
#[cfg(feature = "cloudflare")]
#[serde(rename = "cloudflare")]
Cloudflare {
api_token: String,
},
#[cfg(feature = "aliyun")]
#[serde(rename = "aliyun")]
Aliyun {
access_key_id: String,
access_key_secret: String,
},
#[cfg(feature = "dnspod")]
#[serde(rename = "dnspod")]
Dnspod {
secret_id: String,
secret_key: String,
},
#[cfg(feature = "huaweicloud")]
#[serde(rename = "huaweicloud")]
Huaweicloud {
access_key_id: String,
secret_access_key: String,
},
}
impl ProviderCredentials {
pub fn from_map(
provider: &ProviderType,
map: &std::collections::HashMap<String, String>,
) -> Result<Self, CredentialValidationError> {
match provider {
#[cfg(feature = "cloudflare")]
ProviderType::Cloudflare => Ok(Self::Cloudflare {
api_token: Self::get_required_field(provider, map, "apiToken", "API Token")?,
}),
#[cfg(feature = "aliyun")]
ProviderType::Aliyun => Ok(Self::Aliyun {
access_key_id: Self::get_required_field(
provider,
map,
"accessKeyId",
"Access Key ID",
)?,
access_key_secret: Self::get_required_field(
provider,
map,
"accessKeySecret",
"Access Key Secret",
)?,
}),
#[cfg(feature = "dnspod")]
ProviderType::Dnspod => Ok(Self::Dnspod {
secret_id: Self::get_required_field(provider, map, "secretId", "Secret ID")?,
secret_key: Self::get_required_field(provider, map, "secretKey", "Secret Key")?,
}),
#[cfg(feature = "huaweicloud")]
ProviderType::Huaweicloud => Ok(Self::Huaweicloud {
access_key_id: Self::get_required_field(
provider,
map,
"accessKeyId",
"Access Key ID",
)?,
secret_access_key: Self::get_required_field(
provider,
map,
"secretAccessKey",
"Secret Access Key",
)?,
}),
#[allow(unreachable_patterns)]
_ => Err(CredentialValidationError::InvalidFormat {
provider: provider.clone(),
field: "provider".to_string(),
label: "Provider".to_string(),
reason: format!(
"Provider '{provider}' is not supported or its feature is not enabled."
),
}),
}
}
fn get_required_field(
provider: &ProviderType,
map: &std::collections::HashMap<String, String>,
key: &str,
label: &str,
) -> Result<String, CredentialValidationError> {
match map.get(key) {
None => Err(CredentialValidationError::MissingField {
provider: provider.clone(),
field: key.to_string(),
label: label.to_string(),
}),
Some(v) if v.trim().is_empty() => Err(CredentialValidationError::EmptyField {
provider: provider.clone(),
field: key.to_string(),
label: label.to_string(),
}),
Some(v) => Ok(v.clone()),
}
}
pub fn to_map(&self) -> std::collections::HashMap<String, String> {
match self {
Self::Cloudflare { api_token } => [("apiToken".to_string(), api_token.clone())].into(),
Self::Aliyun {
access_key_id,
access_key_secret,
} => [
("accessKeyId".to_string(), access_key_id.clone()),
("accessKeySecret".to_string(), access_key_secret.clone()),
]
.into(),
Self::Dnspod {
secret_id,
secret_key,
} => [
("secretId".to_string(), secret_id.clone()),
("secretKey".to_string(), secret_key.clone()),
]
.into(),
Self::Huaweicloud {
access_key_id,
secret_access_key,
} => [
("accessKeyId".to_string(), access_key_id.clone()),
("secretAccessKey".to_string(), secret_access_key.clone()),
]
.into(),
}
}
pub fn provider_type(&self) -> ProviderType {
match self {
Self::Cloudflare { .. } => ProviderType::Cloudflare,
Self::Aliyun { .. } => ProviderType::Aliyun,
Self::Dnspod { .. } => ProviderType::Dnspod,
Self::Huaweicloud { .. } => ProviderType::Huaweicloud,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn credentials_cloudflare_roundtrip() {
let map: HashMap<String, String> =
[("apiToken".to_string(), "my-token".to_string())].into();
let res = ProviderCredentials::from_map(&ProviderType::Cloudflare, &map);
assert!(res.is_ok(), "expected Ok(..), got {res:?}");
let Ok(cred) = res else {
return;
};
let back = cred.to_map();
assert_eq!(back.get("apiToken").map(String::as_str), Some("my-token"));
assert_eq!(cred.provider_type(), ProviderType::Cloudflare);
}
#[test]
fn credentials_aliyun_roundtrip() {
let map: HashMap<String, String> = [
("accessKeyId".to_string(), "id123".to_string()),
("accessKeySecret".to_string(), "secret456".to_string()),
]
.into();
let res = ProviderCredentials::from_map(&ProviderType::Aliyun, &map);
assert!(res.is_ok(), "expected Ok(..), got {res:?}");
let Ok(cred) = res else {
return;
};
let back = cred.to_map();
assert_eq!(back.get("accessKeyId").map(String::as_str), Some("id123"));
assert_eq!(
back.get("accessKeySecret").map(String::as_str),
Some("secret456")
);
}
#[test]
fn credentials_dnspod_roundtrip() {
let map: HashMap<String, String> = [
("secretId".to_string(), "sid".to_string()),
("secretKey".to_string(), "skey".to_string()),
]
.into();
let res = ProviderCredentials::from_map(&ProviderType::Dnspod, &map);
assert!(res.is_ok(), "expected Ok(..), got {res:?}");
let Ok(cred) = res else {
return;
};
let back = cred.to_map();
assert_eq!(back.get("secretId").map(String::as_str), Some("sid"));
assert_eq!(back.get("secretKey").map(String::as_str), Some("skey"));
}
#[test]
fn credentials_huaweicloud_roundtrip() {
let map: HashMap<String, String> = [
("accessKeyId".to_string(), "ak".to_string()),
("secretAccessKey".to_string(), "sk".to_string()),
]
.into();
let res = ProviderCredentials::from_map(&ProviderType::Huaweicloud, &map);
assert!(res.is_ok(), "expected Ok(..), got {res:?}");
let Ok(cred) = res else {
return;
};
let back = cred.to_map();
assert_eq!(back.get("accessKeyId").map(String::as_str), Some("ak"));
assert_eq!(back.get("secretAccessKey").map(String::as_str), Some("sk"));
}
#[test]
fn credentials_missing_field() {
let map: HashMap<String, String> = HashMap::new();
let res = ProviderCredentials::from_map(&ProviderType::Cloudflare, &map);
assert!(
matches!(&res, Err(CredentialValidationError::MissingField { .. })),
"unexpected result: {res:?}"
);
}
#[test]
fn credentials_empty_field() {
let map: HashMap<String, String> = [("apiToken".to_string(), " ".to_string())].into();
let res = ProviderCredentials::from_map(&ProviderType::Cloudflare, &map);
assert!(
matches!(&res, Err(CredentialValidationError::EmptyField { .. })),
"unexpected result: {res:?}"
);
}
#[test]
fn paginated_response_has_more() {
let resp = PaginatedResponse::new(vec![1, 2, 3], 1, 3, 10);
assert!(resp.has_more);
assert_eq!(resp.total_count, 10);
}
#[test]
fn paginated_response_no_more() {
let resp = PaginatedResponse::new(vec![1, 2], 2, 3, 5);
assert!(!resp.has_more); }
#[test]
fn paginated_response_exact_boundary() {
let resp = PaginatedResponse::new(vec![1, 2, 3], 1, 3, 3);
assert!(!resp.has_more); }
#[test]
fn paginated_response_empty() {
let resp: PaginatedResponse<i32> = PaginatedResponse::new(vec![], 1, 20, 0);
assert!(!resp.has_more);
assert_eq!(resp.items.len(), 0);
}
#[test]
fn dns_record_type_serialize() {
let a = DnsRecordType::A;
let json_res = serde_json::to_string(&a);
assert!(
json_res.is_ok(),
"serde_json::to_string failed: {json_res:?}"
);
let Ok(json) = json_res else {
return;
};
assert_eq!(json, "\"A\"");
}
#[test]
fn dns_record_type_deserialize() {
let a_res: serde_json::Result<DnsRecordType> = serde_json::from_str("\"AAAA\"");
assert!(a_res.is_ok(), "serde_json::from_str failed: {a_res:?}");
let Ok(a) = a_res else {
return;
};
assert_eq!(a, DnsRecordType::Aaaa);
}
#[test]
fn dns_record_type_roundtrip_all() {
let types = vec![
DnsRecordType::A,
DnsRecordType::Aaaa,
DnsRecordType::Cname,
DnsRecordType::Mx,
DnsRecordType::Txt,
DnsRecordType::Ns,
DnsRecordType::Srv,
DnsRecordType::Caa,
];
for t in types {
let json_res = serde_json::to_string(&t);
assert!(
json_res.is_ok(),
"serde_json::to_string failed: {json_res:?}"
);
let Ok(json) = json_res else {
return;
};
let back_res: serde_json::Result<DnsRecordType> = serde_json::from_str(&json);
assert!(
back_res.is_ok(),
"serde_json::from_str failed: {back_res:?}"
);
let Ok(back) = back_res else {
return;
};
assert_eq!(back, t);
}
}
#[test]
fn record_data_srv_serde_roundtrip() {
let data = RecordData::SRV {
priority: 10,
weight: 20,
port: 443,
target: "example.com".to_string(),
};
let json_res = serde_json::to_string(&data);
assert!(
json_res.is_ok(),
"serde_json::to_string failed: {json_res:?}"
);
let Ok(json) = json_res else {
return;
};
let back_res: serde_json::Result<RecordData> = serde_json::from_str(&json);
assert!(
back_res.is_ok(),
"serde_json::from_str failed: {back_res:?}"
);
let Ok(back) = back_res else {
return;
};
assert_eq!(back, data);
}
#[test]
fn record_data_caa_serde_roundtrip() {
let data = RecordData::CAA {
flags: 0,
tag: "issue".to_string(),
value: "letsencrypt.org".to_string(),
};
let json_res = serde_json::to_string(&data);
assert!(
json_res.is_ok(),
"serde_json::to_string failed: {json_res:?}"
);
let Ok(json) = json_res else {
return;
};
let back_res: serde_json::Result<RecordData> = serde_json::from_str(&json);
assert!(
back_res.is_ok(),
"serde_json::from_str failed: {back_res:?}"
);
let Ok(back) = back_res else {
return;
};
assert_eq!(back, data);
}
#[test]
fn record_data_record_type() {
assert_eq!(
RecordData::A {
address: "1.2.3.4".into()
}
.record_type(),
DnsRecordType::A
);
assert_eq!(
RecordData::SRV {
priority: 0,
weight: 0,
port: 0,
target: ".".into()
}
.record_type(),
DnsRecordType::Srv
);
}
#[test]
fn record_data_display_value() {
assert_eq!(
RecordData::A {
address: "1.2.3.4".into()
}
.display_value(),
"1.2.3.4"
);
assert_eq!(
RecordData::MX {
priority: 10,
exchange: "mail.x.com".into()
}
.display_value(),
"mail.x.com"
);
assert_eq!(
RecordData::CAA {
flags: 0,
tag: "issue".into(),
value: "le.org".into()
}
.display_value(),
"le.org"
);
}
#[test]
fn pagination_validated_clamps_page_zero() {
let p = PaginationParams {
page: 0,
page_size: 20,
};
let v = p.validated(100);
assert_eq!(v.page, 1);
assert_eq!(v.page_size, 20);
}
#[test]
fn pagination_validated_clamps_page_size_over_max() {
let p = PaginationParams {
page: 1,
page_size: 9999,
};
let v = p.validated(100);
assert_eq!(v.page_size, 100);
}
#[test]
fn pagination_validated_clamps_page_size_zero() {
let p = PaginationParams {
page: 1,
page_size: 0,
};
let v = p.validated(100);
assert_eq!(v.page_size, 1);
}
#[test]
fn pagination_validated_normal_values_unchanged() {
let p = PaginationParams {
page: 3,
page_size: 50,
};
let v = p.validated(100);
assert_eq!(v.page, 3);
assert_eq!(v.page_size, 50);
}
#[test]
fn record_query_validated_preserves_filters() {
let p = RecordQueryParams {
page: 0,
page_size: 9999,
keyword: Some("test".to_string()),
record_type: Some(DnsRecordType::A),
};
let v = p.validated(100);
assert_eq!(v.page, 1);
assert_eq!(v.page_size, 100);
assert_eq!(v.keyword.as_deref(), Some("test"));
assert_eq!(v.record_type, Some(DnsRecordType::A));
}
}