pub mod xml;
use std::cell::Cell;
use std::thread;
use std::time::{Duration, Instant};
use crate::config::Profile;
const MIN_SPACING: Duration = Duration::from_millis(3100);
const RETRY_BACKOFF: Duration = Duration::from_secs(5);
pub const READ_ONLY_COMMANDS: &[&str] = &[
"domains.getlist",
"domains.check",
"domains.getregistrarlock",
"domains.getinfo",
"domains.getcontacts",
"domains.gettldlist",
"domains.dns.getlist",
"domains.dns.gethosts",
"whoisguard.getlist",
"users.getbalances",
"users.getpricing",
];
pub fn canonical_command(command: &str) -> String {
let stripped = command
.strip_prefix("namecheap.")
.or_else(|| command.strip_prefix("Namecheap."))
.unwrap_or(command);
stripped.to_ascii_lowercase()
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{0}")]
Config(#[from] crate::config::ConfigError),
#[error("transport: {0}")]
Transport(String),
#[error("rate limited by Namecheap ({0}); retry later")]
RateLimited(String),
#[error("Namecheap API error {code}: {message}")]
Api { code: String, message: String },
#[error("unexpected API response: {0}")]
Parse(String),
#[error("{0}")]
Usage(String),
#[error("{0}")]
Policy(String),
}
impl Error {
pub fn exit_code(&self) -> u8 {
match self {
Error::Api { .. } | Error::Parse(_) => 1,
Error::Usage(_) => 2,
Error::Config(_) | Error::Policy(_) => 3,
Error::Transport(_) => 4,
Error::RateLimited(_) => 5,
}
}
pub fn kind(&self) -> &'static str {
match self {
Error::Api { .. } => "api",
Error::Parse(_) => "parse",
Error::Usage(_) => "usage",
Error::Config(_) | Error::Policy(_) => "config",
Error::Transport(_) => "transport",
Error::RateLimited(_) => "rate_limit",
}
}
pub fn code(&self) -> Option<&str> {
match self {
Error::Api { code, .. } => Some(code),
_ => None,
}
}
}
#[derive(Debug)]
pub enum TransportFailure {
Status(u16),
Other(String),
}
pub trait Transport {
fn send(&self, endpoint: &str, params: &[(String, String)])
-> Result<String, TransportFailure>;
}
pub struct HttpTransport {
agent: ureq::Agent,
}
impl HttpTransport {
pub fn new() -> Self {
let config = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(30)))
.https_only(cfg!(not(debug_assertions)))
.max_redirects(0)
.build();
Self {
agent: config.into(),
}
}
}
impl Default for HttpTransport {
fn default() -> Self {
Self::new()
}
}
impl Transport for HttpTransport {
fn send(
&self,
endpoint: &str,
params: &[(String, String)],
) -> Result<String, TransportFailure> {
let form: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
match self.agent.post(endpoint).send_form(form) {
Ok(resp) if resp.status().is_redirection() => {
Err(TransportFailure::Status(resp.status().as_u16()))
}
Ok(mut resp) => resp
.body_mut()
.read_to_string()
.map_err(|e| TransportFailure::Other(e.to_string())),
Err(ureq::Error::StatusCode(code)) => Err(TransportFailure::Status(code)),
Err(e) => Err(TransportFailure::Other(e.to_string())),
}
}
}
pub struct Client<T: Transport> {
transport: T,
profile: Profile,
last_call: Cell<Option<Instant>>,
calls: Cell<u32>,
spacing: Duration,
retry_backoff: Duration,
}
impl<T: Transport> Client<T> {
pub fn new(transport: T, profile: Profile) -> Self {
Self {
transport,
profile,
last_call: Cell::new(None),
calls: Cell::new(0),
spacing: MIN_SPACING,
retry_backoff: RETRY_BACKOFF,
}
}
pub fn set_timing(&mut self, spacing: Duration, retry_backoff: Duration) {
self.spacing = spacing;
self.retry_backoff = retry_backoff;
}
pub fn profile(&self) -> &Profile {
&self.profile
}
pub fn transport(&self) -> &T {
&self.transport
}
pub fn calls(&self) -> u32 {
self.calls.get()
}
pub fn call(&self, command: &str, params: &[(&str, &str)]) -> Result<String, Error> {
let canonical = canonical_command(command);
if !READ_ONLY_COMMANDS.contains(&canonical.as_str()) {
return Err(Error::Policy(format!(
"{command:?} is not a known read-only command; \
mutating commands must use the mutation path"
)));
}
self.dispatch(&canonical, params, true)
}
pub fn call_mut(&self, command: &str, params: &[(&str, &str)]) -> Result<String, Error> {
if !self.profile.sandbox && !self.profile.allow_production_mutations {
return Err(Error::Policy(
"mutations against production are disabled; use a sandbox profile \
or set allow_production_mutations = true in this profile"
.into(),
));
}
self.dispatch(&canonical_command(command), params, false)
}
fn dispatch(
&self,
canonical: &str,
params: &[(&str, &str)],
retry: bool,
) -> Result<String, Error> {
let mut all: Vec<(String, String)> = vec![
("ApiUser".into(), self.profile.api_user.clone()),
("ApiKey".into(), self.profile.api_key.expose().to_owned()),
("UserName".into(), self.profile.username.clone()),
("Command".into(), format!("namecheap.{canonical}")),
("ClientIp".into(), self.profile.client_ip.clone()),
];
all.extend(
params
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned())),
);
self.calls.set(self.calls.get() + 1);
self.throttle();
let mut attempt = self.transport.send(self.profile.endpoint(), &all);
if retry
&& matches!(attempt, Err(TransportFailure::Status(code)) if code == 429 || code >= 500)
{
thread::sleep(self.retry_backoff);
self.throttle();
attempt = self.transport.send(self.profile.endpoint(), &all);
}
match attempt {
Ok(body) => Ok(body),
Err(TransportFailure::Status(429)) => Err(Error::RateLimited("HTTP 429".into())),
Err(TransportFailure::Status(code)) => {
Err(Error::Transport(format!("HTTP status {code}")))
}
Err(TransportFailure::Other(msg)) => Err(Error::Transport(
msg.replace(self.profile.api_key.expose(), "<redacted>"),
)),
}
}
fn throttle(&self) {
if let Some(prev) = self.last_call.get()
&& prev.elapsed() < self.spacing
{
thread::sleep(self.spacing - prev.elapsed());
}
self.last_call.set(Some(Instant::now()));
}
}