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}