Skip to main content

namecheap_client/
client.rs

1//! The asynchronous Namecheap API client and its builder.
2
3use serde::de::DeserializeOwned;
4
5use crate::domains::Domains;
6use crate::error::{Error, Result};
7use crate::response;
8use crate::users::Users;
9
10/// Which Namecheap environment to talk to.
11///
12/// The sandbox is a free, isolated environment that mirrors the production API.
13/// Use it while developing: calls such as
14/// [`domains().create()`](crate::domains::Domains::create) place real, billable
15/// orders against production. Sandbox accounts are created separately at
16/// <https://www.sandbox.namecheap.com>.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum Environment {
19    /// The live API at `api.namecheap.com`. Commands here have real effects.
20    #[default]
21    Production,
22    /// The sandbox API at `api.sandbox.namecheap.com`. Safe for testing.
23    Sandbox,
24}
25
26impl Environment {
27    /// The base endpoint URL for this environment.
28    fn endpoint(self) -> &'static str {
29        match self {
30            Environment::Production => "https://api.namecheap.com/xml.response",
31            Environment::Sandbox => "https://api.sandbox.namecheap.com/xml.response",
32        }
33    }
34}
35
36/// An asynchronous client for the Namecheap API.
37///
38/// A `Client` bundles your API credentials, the whitelisted client IP, and the
39/// chosen [`Environment`], and exposes the supported commands through grouped
40/// accessors: [`domains()`](Client::domains) and [`users()`](Client::users).
41///
42/// The client is cheap to clone and is safe to share across tasks; it wraps a
43/// connection-pooling [`reqwest::Client`] internally.
44///
45/// # Example
46///
47/// ```no_run
48/// # async fn run() -> Result<(), namecheap_client::Error> {
49/// use namecheap_client::{Client, Environment};
50///
51/// let client = Client::builder()
52///     .api_user("your-api-user")
53///     .api_key("your-api-key")
54///     .client_ip("203.0.113.10") // your whitelisted public IPv4
55///     .environment(Environment::Sandbox)
56///     .build()?;
57///
58/// let balances = client.users().get_balances().await?;
59/// println!("available: {} {}", balances.available_balance, balances.currency);
60/// # Ok(())
61/// # }
62/// ```
63#[derive(Debug, Clone)]
64pub struct Client {
65    http: reqwest::Client,
66    api_user: String,
67    api_key: String,
68    user_name: String,
69    client_ip: String,
70    environment: Environment,
71}
72
73impl Client {
74    /// Starts building a client. See [`ClientBuilder`].
75    #[must_use]
76    pub fn builder() -> ClientBuilder {
77        ClientBuilder::default()
78    }
79
80    /// Creates a production client with the minimum required fields.
81    ///
82    /// `user_name` defaults to `api_user`. To target the sandbox or to set a
83    /// distinct username, use [`Client::builder`] instead.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`Error::Configuration`] if the underlying HTTP client cannot be
88    /// constructed.
89    pub fn new(
90        api_user: impl Into<String>,
91        api_key: impl Into<String>,
92        client_ip: impl Into<String>,
93    ) -> Result<Self> {
94        Self::builder()
95            .api_user(api_user)
96            .api_key(api_key)
97            .client_ip(client_ip)
98            .build()
99    }
100
101    /// Commands in the `namecheap.domains` namespace.
102    #[must_use]
103    pub fn domains(&self) -> Domains<'_> {
104        Domains::new(self)
105    }
106
107    /// Commands in the `namecheap.users` namespace.
108    #[must_use]
109    pub fn users(&self) -> Users<'_> {
110        Users::new(self)
111    }
112
113    /// The environment this client targets.
114    #[must_use]
115    pub fn environment(&self) -> Environment {
116        self.environment
117    }
118
119    /// Sends a command and decodes the response into the payload type `T`.
120    ///
121    /// The global parameters (`ApiUser`, `ApiKey`, `UserName`, `ClientIp`, and
122    /// `Command`) are added automatically; `params` carries the command-specific
123    /// arguments.
124    pub(crate) async fn send<T>(&self, command: &str, params: Vec<(String, String)>) -> Result<T>
125    where
126        T: DeserializeOwned,
127    {
128        let mut query: Vec<(String, String)> = Vec::with_capacity(params.len() + 5);
129        query.push(("ApiUser".to_owned(), self.api_user.clone()));
130        query.push(("ApiKey".to_owned(), self.api_key.clone()));
131        query.push(("UserName".to_owned(), self.user_name.clone()));
132        query.push(("ClientIp".to_owned(), self.client_ip.clone()));
133        query.push(("Command".to_owned(), command.to_owned()));
134        query.extend(params);
135
136        let response = self
137            .http
138            .get(self.environment.endpoint())
139            .query(&query)
140            .send()
141            .await?;
142
143        let status = response.status();
144        // Decode as UTF-8 (Namecheap responses are UTF-8). Using `bytes()` keeps
145        // the dependency surface small by avoiding reqwest's `charset` feature.
146        let bytes = response.bytes().await?;
147        let body = String::from_utf8_lossy(&bytes);
148        response::parse(status, &body)
149    }
150}
151
152/// A builder for [`Client`].
153///
154/// Construct one with [`Client::builder`], set the credentials and the
155/// whitelisted client IP, then call [`build`](ClientBuilder::build).
156#[derive(Debug, Default, Clone)]
157pub struct ClientBuilder {
158    api_user: Option<String>,
159    api_key: Option<String>,
160    user_name: Option<String>,
161    client_ip: Option<String>,
162    environment: Environment,
163    http: Option<reqwest::Client>,
164}
165
166impl ClientBuilder {
167    /// Sets the API username (the `ApiUser` parameter).
168    #[must_use]
169    pub fn api_user(mut self, value: impl Into<String>) -> Self {
170        self.api_user = Some(value.into());
171        self
172    }
173
174    /// Sets the API key (the `ApiKey` parameter).
175    #[must_use]
176    pub fn api_key(mut self, value: impl Into<String>) -> Self {
177        self.api_key = Some(value.into());
178        self
179    }
180
181    /// Sets the account username (the `UserName` parameter).
182    ///
183    /// When omitted, this defaults to the value of [`api_user`](Self::api_user).
184    /// It only differs when acting on behalf of another account.
185    #[must_use]
186    pub fn user_name(mut self, value: impl Into<String>) -> Self {
187        self.user_name = Some(value.into());
188        self
189    }
190
191    /// Sets the whitelisted client IP (the `ClientIp` parameter).
192    ///
193    /// This must be the public IPv4 address you have whitelisted in the
194    /// Namecheap API settings, and it must match the address the request
195    /// actually originates from.
196    #[must_use]
197    pub fn client_ip(mut self, value: impl Into<String>) -> Self {
198        self.client_ip = Some(value.into());
199        self
200    }
201
202    /// Selects the [`Environment`]. Defaults to [`Environment::Production`].
203    #[must_use]
204    pub fn environment(mut self, environment: Environment) -> Self {
205        self.environment = environment;
206        self
207    }
208
209    /// Supplies a preconfigured [`reqwest::Client`] (custom timeouts, proxy, and
210    /// so on). When omitted, a default client is created.
211    #[must_use]
212    pub fn http_client(mut self, client: reqwest::Client) -> Self {
213        self.http = Some(client);
214        self
215    }
216
217    /// Builds the [`Client`].
218    ///
219    /// # Errors
220    ///
221    /// Returns [`Error::Configuration`] if `api_user`, `api_key`, or `client_ip`
222    /// is missing, or if a default HTTP client could not be constructed.
223    pub fn build(self) -> Result<Client> {
224        let api_user = self
225            .api_user
226            .ok_or_else(|| Error::Configuration("`api_user` is required".to_owned()))?;
227        let api_key = self
228            .api_key
229            .ok_or_else(|| Error::Configuration("`api_key` is required".to_owned()))?;
230        let client_ip = self
231            .client_ip
232            .ok_or_else(|| Error::Configuration("`client_ip` is required".to_owned()))?;
233        let user_name = self.user_name.unwrap_or_else(|| api_user.clone());
234
235        let http = match self.http {
236            Some(client) => client,
237            None => reqwest::Client::builder()
238                .user_agent(concat!("namecheap-client/", env!("CARGO_PKG_VERSION")))
239                .build()
240                .map_err(|error| Error::Configuration(error.to_string()))?,
241        };
242
243        Ok(Client {
244            http,
245            api_user,
246            api_key,
247            user_name,
248            client_ip,
249            environment: self.environment,
250        })
251    }
252}