pub mod api;
use std::error::Error as StdErr;
use std::sync::Arc;
pub use api::{
get_element_attr, parse_host_records, ApiError, Client, ClientConfig, HostRecord,
NamecheapError,
};
use crate::{
CreateRecord, CreateRecordError, DeleteRecord, DeleteRecordError, HttpClientConfig, Provider,
Record, RecordData, RetrieveRecordError, RetrieveZoneError, Zone,
};
#[derive(Clone)]
pub struct NamecheapProvider {
api_client: Arc<Client>,
}
pub struct NamecheapZone {
api_client: Arc<Client>,
domain: String,
sld: String,
tld: String,
}
impl NamecheapZone {
pub fn domain(&self) -> &str {
&self.domain
}
pub fn sld(&self) -> &str {
&self.sld
}
pub fn tld(&self) -> &str {
&self.tld
}
pub async fn fetch_records(&self) -> Result<Vec<HostRecord>, NamecheapError> {
self.api_client.get_hosts(&self.sld, &self.tld).await
}
pub async fn save_records(&self, records: &[HostRecord]) -> Result<(), NamecheapError> {
self.api_client
.set_hosts(&self.sld, &self.tld, records)
.await
}
}
pub fn split_domain(domain: &str) -> Option<(String, String)> {
let domain = domain.trim_end_matches('.');
let parts: Vec<&str> = domain.split('.').collect();
if parts.len() < 2 {
return None;
}
let two_part_tlds = [
"co.uk",
"org.uk",
"me.uk",
"net.uk",
"ac.uk",
"gov.uk",
"ltd.uk",
"plc.uk",
"com.au",
"net.au",
"org.au",
"edu.au",
"gov.au",
"asn.au",
"id.au",
"co.nz",
"net.nz",
"org.nz",
"govt.nz",
"ac.nz",
"school.nz",
"geek.nz",
"co.jp",
"ne.jp",
"or.jp",
"ac.jp",
"go.jp",
"com.cn",
"net.cn",
"org.cn",
"gov.cn",
"edu.cn",
"com.br",
"net.br",
"org.br",
"gov.br",
"edu.br",
"co.in",
"net.in",
"org.in",
"gov.in",
"ac.in",
];
if parts.len() >= 3 {
let potential_two_part = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]);
if two_part_tlds.contains(&potential_two_part.as_str()) {
let sld = parts[..parts.len() - 2].join(".");
return Some((sld, potential_two_part));
}
}
let sld = parts[..parts.len() - 1].join(".");
let tld = parts[parts.len() - 1].to_string();
Some((sld, tld))
}
impl NamecheapProvider {
pub fn new(config: ClientConfig) -> Result<Self, Box<dyn StdErr + Send + Sync>> {
let api_client = Client::new(config)?;
Ok(Self {
api_client: Arc::new(api_client),
})
}
pub fn with_http_config(
config: ClientConfig,
http_config: HttpClientConfig,
) -> Result<Self, Box<dyn StdErr + Send + Sync>> {
let api_client = Client::with_http_config(config, http_config)?;
Ok(Self {
api_client: Arc::new(api_client),
})
}
}
impl Provider for NamecheapProvider {
type Zone = NamecheapZone;
type CustomRetrieveError = NamecheapError;
async fn get_zone(
&self,
zone_id: &str,
) -> Result<Self::Zone, RetrieveZoneError<Self::CustomRetrieveError>> {
let (sld, tld) = split_domain(zone_id).ok_or_else(|| {
RetrieveZoneError::Custom(NamecheapError::Parse(format!(
"Invalid domain format: {}",
zone_id
)))
})?;
let zone = NamecheapZone {
api_client: self.api_client.clone(),
domain: zone_id.to_string(),
sld,
tld,
};
zone.fetch_records().await.map_err(|e| match e {
NamecheapError::DomainNotFound => RetrieveZoneError::NotFound,
NamecheapError::Unauthorized => RetrieveZoneError::Unauthorized,
other => RetrieveZoneError::Custom(other),
})?;
Ok(zone)
}
async fn list_zones(
&self,
) -> Result<Vec<Self::Zone>, RetrieveZoneError<Self::CustomRetrieveError>> {
Ok(vec![])
}
}
impl Zone for NamecheapZone {
type CustomRetrieveError = NamecheapError;
fn id(&self) -> &str {
&self.domain
}
fn domain(&self) -> &str {
&self.domain
}
async fn list_records(
&self,
) -> Result<Vec<Record>, RetrieveRecordError<Self::CustomRetrieveError>> {
let host_records = self.fetch_records().await.map_err(|e| match e {
NamecheapError::Unauthorized => RetrieveRecordError::Unauthorized,
other => RetrieveRecordError::Custom(other),
})?;
let records = host_records
.into_iter()
.map(|hr| host_record_to_record(hr, &self.domain))
.collect();
Ok(records)
}
async fn get_record(
&self,
record_id: &str,
) -> Result<Record, RetrieveRecordError<Self::CustomRetrieveError>> {
let host_records = self.fetch_records().await.map_err(|e| match e {
NamecheapError::Unauthorized => RetrieveRecordError::Unauthorized,
other => RetrieveRecordError::Custom(other),
})?;
host_records
.into_iter()
.find(|hr| hr.host_id == record_id)
.map(|hr| host_record_to_record(hr, &self.domain))
.ok_or(RetrieveRecordError::NotFound)
}
}
impl CreateRecord for NamecheapZone {
type CustomCreateError = NamecheapError;
async fn create_record(
&self,
host: &str,
data: &RecordData,
ttl: u64,
) -> Result<Record, CreateRecordError<Self::CustomCreateError>> {
let mut records = self
.fetch_records()
.await
.map_err(CreateRecordError::Custom)?;
let new_record = HostRecord {
host_id: String::new(), name: host.to_string(),
record_type: data.get_type().to_string(),
address: data.get_api_value(),
mx_pref: if let RecordData::MX { priority, .. } = data {
Some(*priority)
} else {
None
},
ttl: ttl.clamp(60, 60000), };
records.push(new_record);
self.save_records(&records).await.map_err(|e| match e {
NamecheapError::Unauthorized => CreateRecordError::Unauthorized,
other => CreateRecordError::Custom(other),
})?;
let updated = self
.fetch_records()
.await
.map_err(CreateRecordError::Custom)?;
let expected_address = data.get_api_value().trim_end_matches('.').to_lowercase();
let expected_type = data.get_type();
#[cfg(debug_assertions)]
{
eprintln!(
"DEBUG: Looking for: host='{}', type='{}', address='{}'",
host, expected_type, expected_address
);
for r in &updated {
eprintln!(
"DEBUG: Found: host='{}', type='{}', address='{}'",
r.name,
r.record_type,
r.address.trim_end_matches('.').to_lowercase()
);
}
}
updated
.into_iter()
.rfind(|r| {
r.name == host
&& r.record_type == expected_type
&& r.address.trim_end_matches('.').to_lowercase() == expected_address
})
.map(|hr| host_record_to_record(hr, &self.domain))
.ok_or_else(|| {
CreateRecordError::Custom(NamecheapError::Parse(
"Failed to find created record".to_string(),
))
})
}
}
impl DeleteRecord for NamecheapZone {
type CustomDeleteError = NamecheapError;
async fn delete_record(
&self,
record_id: &str,
) -> Result<(), DeleteRecordError<Self::CustomDeleteError>> {
let records = self
.fetch_records()
.await
.map_err(DeleteRecordError::Custom)?;
let original_count = records.len();
let remaining: Vec<_> = records
.into_iter()
.filter(|r| r.host_id != record_id)
.collect();
if remaining.len() == original_count {
return Err(DeleteRecordError::NotFound);
}
self.save_records(&remaining).await.map_err(|e| match e {
NamecheapError::Unauthorized => DeleteRecordError::Unauthorized,
other => DeleteRecordError::Custom(other),
})?;
Ok(())
}
}
pub fn host_record_to_record(hr: HostRecord, domain: &str) -> Record {
let host = if hr.name == "@" {
domain.to_string()
} else {
format!("{}.{}", hr.name, domain)
};
let data = match hr.record_type.as_str() {
"MX" => RecordData::MX {
priority: hr.mx_pref.unwrap_or(10),
mail_server: hr.address,
},
_ => RecordData::from_raw(&hr.record_type, &hr.address),
};
Record {
id: hr.host_id,
host,
data,
ttl: hr.ttl,
}
}