1use std::time::Duration;
6
7use reqwest::{Method, RequestBuilder};
8use serde::de::DeserializeOwned;
9use url::Url;
10
11use crate::api::{
12 AuditLogs, Certificates, ClientIdentities, FreeDomains, Geo, GeoBans, HealthChecks, Records,
13 Redirects, SinkholeLinks, SinkholeLists, SinkholeRoles, Stats, Users, ZoneMembers, Zones,
14};
15use crate::auth::{Credentials, Secret};
16use crate::envelope::{self, Meta};
17use crate::error::{Error, Result};
18use crate::models::Health;
19
20const DEFAULT_USER_AGENT: &str = concat!("dinio-sdk/", env!("CARGO_PKG_VERSION"));
21const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
22const API_PREFIX: [&str; 2] = ["api", "v1"];
24const HEALTH_PATH: [&str; 2] = ["api", "health"];
25
26#[derive(Debug, Clone)]
61pub struct Client {
62 http: reqwest::Client,
63 base: Url,
65 credentials: Option<Credentials>,
68 timeout: Duration,
70}
71
72impl Client {
73 pub fn builder() -> ClientBuilder {
75 ClientBuilder::default()
76 }
77
78 pub fn zones(&self) -> Zones<'_> {
80 Zones::new(self)
81 }
82
83 pub fn zone_members(&self) -> ZoneMembers<'_> {
85 ZoneMembers::new(self)
86 }
87
88 pub fn users(&self) -> Users<'_> {
90 Users::new(self)
91 }
92
93 pub fn redirects(&self) -> Redirects<'_> {
95 Redirects::new(self)
96 }
97
98 pub fn certificates(&self) -> Certificates<'_> {
101 Certificates::new(self)
102 }
103
104 pub fn records(&self) -> Records<'_> {
107 Records::new(self)
108 }
109
110 pub fn geo_bans(&self) -> GeoBans<'_> {
112 GeoBans::new(self)
113 }
114
115 pub fn audit_logs(&self) -> AuditLogs<'_> {
117 AuditLogs::new(self)
118 }
119
120 pub fn stats(&self) -> Stats<'_> {
122 Stats::new(self)
123 }
124
125 pub fn free_domains(&self) -> FreeDomains<'_> {
127 FreeDomains::new(self)
128 }
129
130 pub fn geo(&self) -> Geo<'_> {
132 Geo::new(self)
133 }
134
135 pub fn health_checks(&self) -> HealthChecks<'_> {
137 HealthChecks::new(self)
138 }
139
140 pub fn client_identities(&self) -> ClientIdentities<'_> {
143 ClientIdentities::new(self)
144 }
145
146 pub fn sinkhole_lists(&self) -> SinkholeLists<'_> {
148 SinkholeLists::new(self)
149 }
150
151 pub fn sinkhole_roles(&self) -> SinkholeRoles<'_> {
154 SinkholeRoles::new(self)
155 }
156
157 pub fn sinkhole_links(&self) -> SinkholeLinks<'_> {
159 SinkholeLinks::new(self)
160 }
161
162 pub async fn health(&self) -> Result<Health> {
175 let url = self.url(&HEALTH_PATH)?;
176 let (status, body) = execute(self.http.get(url)).await?;
177 serde_json::from_slice(&body).map_err(|_| envelope::decode_error(status, &body))
178 }
179
180 pub(crate) fn request(&self, method: Method, segments: &[&str]) -> Result<RequestBuilder> {
183 let mut path: Vec<&str> = API_PREFIX.to_vec();
184 path.extend_from_slice(segments);
185 let builder = self.http.request(method, self.url(&path)?);
186 Ok(match &self.credentials {
187 Some(Credentials::ApiKey(key)) => builder.bearer_auth(key.expose()),
188 None => builder,
189 })
190 }
191
192 pub(crate) async fn send<T: DeserializeOwned>(
194 &self,
195 request: RequestBuilder,
196 ) -> Result<(T, Option<Meta>)> {
197 let (status, body) = execute(request).await?;
198 envelope::decode(status, &body)
199 }
200
201 pub(crate) async fn send_cursor<T: DeserializeOwned>(
203 &self,
204 request: RequestBuilder,
205 ) -> Result<(T, Option<String>)> {
206 let (status, body) = execute(request).await?;
207 envelope::decode_cursor(status, &body)
208 }
209
210 pub(crate) async fn send_file(&self, request: RequestBuilder) -> Result<String> {
213 let (status, body) = execute(request).await?;
214 envelope::decode_file(status, body)
215 }
216
217 pub(crate) fn timeout(&self) -> Duration {
219 self.timeout
220 }
221
222 pub(crate) async fn send_empty(&self, request: RequestBuilder) -> Result<()> {
224 let (status, body) = execute(request).await?;
225 envelope::decode_empty(status, &body)
226 }
227
228 fn url(&self, segments: &[&str]) -> Result<Url> {
229 let mut url = self.base.clone();
230 url.path_segments_mut()
231 .map_err(|()| Error::InvalidInput(format!("base URL cannot be a base: {}", self.base)))?
232 .pop_if_empty()
233 .extend(segments);
234 Ok(url)
235 }
236}
237
238async fn execute(request: RequestBuilder) -> Result<(u16, Vec<u8>)> {
239 let response = request.send().await.map_err(Error::Transport)?;
240 let status = response.status().as_u16();
241 let body = response.bytes().await.map_err(Error::Transport)?;
242 Ok((status, body.to_vec()))
243}
244
245#[derive(Debug, Default)]
247pub struct ClientBuilder {
248 base_url: Option<String>,
249 credentials: Option<Credentials>,
250 user_agent: Option<String>,
251 timeout: Option<Duration>,
252}
253
254impl ClientBuilder {
255 pub fn base_url(mut self, url: impl Into<String>) -> Self {
257 self.base_url = Some(url.into());
258 self
259 }
260
261 pub fn credentials(mut self, credentials: Credentials) -> Self {
263 self.credentials = Some(credentials);
264 self
265 }
266
267 pub fn api_key(self, key: impl Into<String>) -> Self {
270 self.credentials(Credentials::ApiKey(Secret::new(key)))
271 }
272
273 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
275 self.user_agent = Some(user_agent.into());
276 self
277 }
278
279 pub fn timeout(mut self, timeout: Duration) -> Self {
281 self.timeout = Some(timeout);
282 self
283 }
284
285 pub fn build(self) -> Result<Client> {
293 let base_url = self
294 .base_url
295 .ok_or_else(|| Error::InvalidInput("base URL is not set".into()))?;
296 let base = parse_base(&base_url)?;
297
298 let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
299 let http = reqwest::Client::builder()
300 .user_agent(self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT))
301 .timeout(timeout)
302 .build()
303 .map_err(Error::Transport)?;
304
305 Ok(Client {
306 http,
307 base,
308 credentials: self.credentials,
309 timeout,
310 })
311 }
312}
313
314fn parse_base(base: &str) -> Result<Url> {
315 let bad = |reason: String| Error::InvalidInput(format!("bad base URL {base:?}: {reason}"));
316 let mut url = Url::parse(base).map_err(|e| bad(e.to_string()))?;
317 if !matches!(url.scheme(), "http" | "https") {
318 return Err(bad("must be http or https".into()));
319 }
320 if url.cannot_be_a_base() {
321 return Err(bad("cannot be a base".into()));
322 }
323 if url.query().is_some() || url.fragment().is_some() {
324 return Err(bad("must not have a query or fragment".into()));
325 }
326 if !url.path().ends_with('/') {
327 let path = format!("{}/", url.path());
328 url.set_path(&path);
329 }
330 Ok(url)
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 fn client(base: &str) -> Client {
338 Client::builder()
339 .base_url(base)
340 .api_key("k")
341 .build()
342 .unwrap()
343 }
344
345 #[test]
346 fn api_paths() {
347 let c = client("https://api.example.com");
348 assert_eq!(
349 c.url(&["api", "v1", "zones"]).unwrap().as_str(),
350 "https://api.example.com/api/v1/zones"
351 );
352 let c = client("http://localhost:3000/core/");
353 assert_eq!(
354 c.url(&HEALTH_PATH).unwrap().as_str(),
355 "http://localhost:3000/core/api/health"
356 );
357 }
358
359 #[test]
360 fn segments_are_encoded() {
361 let c = client("https://api.example.com");
362 let url = c.url(&["api", "v1", "zones", "a/b?c"]).unwrap();
363 assert_eq!(
364 url.as_str(),
365 "https://api.example.com/api/v1/zones/a%2Fb%3Fc"
366 );
367 }
368
369 #[test]
370 fn bad_base_urls() {
371 assert!(parse_base("ftp://example.com").is_err());
372 assert!(parse_base("not a url").is_err());
373 assert!(parse_base("https://example.com/?x=1").is_err());
374 }
375
376 #[test]
377 fn build_requires_url_only() {
378 assert!(Client::builder().api_key("k").build().is_err());
379 assert!(
380 Client::builder()
381 .base_url("https://api.example.com")
382 .build()
383 .is_ok()
384 );
385 }
386}