Skip to main content

dinio_sdk/
client.rs

1//! `Client`: one configured connection to a Core endpoint. Cheap to clone (the underlying
2//! HTTP client is reference-counted). The SDK starts no runtime and spawns nothing; the
3//! caller polls the returned futures in their own (tokio) runtime.
4
5use 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);
22/// Versioned API prefix, relative to the base URL.
23const API_PREFIX: [&str; 2] = ["api", "v1"];
24const HEALTH_PATH: [&str; 2] = ["api", "health"];
25
26/// A configured connection to one Core endpoint; the entry point of the SDK.
27///
28/// Built with [`Client::builder`]. Cheap to clone and safe to share between tasks: clones
29/// share one connection pool. Resource groups are reached through methods such as
30/// [`zones`](Client::zones) and [`records`](Client::records).
31///
32/// # Errors
33///
34/// Every call can fail with:
35///
36/// * [`Error::Transport`]: no response (DNS, TLS, refused connection, timeout).
37/// * [`Error::Decode`]: the response is not Core's envelope (e.g. a proxy error page).
38/// * [`Error::Api`] with [`ErrorCode::Unauthenticated`](crate::ErrorCode::Unauthenticated)
39///   or [`ErrorCode::InvalidApiKey`](crate::ErrorCode::InvalidApiKey) on `/api/v1` routes
40///   ([`ErrorCode::InvalidToken`](crate::ErrorCode::InvalidToken) for a key without the
41///   `dz_`/`du_` prefix, see [`ApiKeyKind::of`](crate::ApiKeyKind::of)),
42///   and [`ErrorCode::Forbidden`](crate::ErrorCode::Forbidden) when the key lacks access.
43///
44/// The method docs list only the errors specific to that call.
45///
46/// # Examples
47///
48/// ```no_run
49/// # async fn example() -> dinio_sdk::Result<()> {
50/// let client = dinio_sdk::Client::builder()
51///     .base_url("https://api-dinio.bitelex.com")
52///     .api_key("du_...")
53///     .build()?;
54/// for zone in client.zones().list_all().await? {
55///     println!("{}", zone.name);
56/// }
57/// # Ok(())
58/// # }
59/// ```
60#[derive(Debug, Clone)]
61pub struct Client {
62    http: reqwest::Client,
63    /// Base URL as given, path always ending with a slash.
64    base: Url,
65    /// `None` is allowed: unauthenticated routes (`health`) still work, and `/api/v1`
66    /// calls get Core's own 401.
67    credentials: Option<Credentials>,
68    /// Whole-request timeout the HTTP client was built with.
69    timeout: Duration,
70}
71
72impl Client {
73    /// Starts configuring a client; only the base URL is required.
74    pub fn builder() -> ClientBuilder {
75        ClientBuilder::default()
76    }
77
78    /// Zones: list, get, create, delete, issue and revoke the zone key.
79    pub fn zones(&self) -> Zones<'_> {
80        Zones::new(self)
81    }
82
83    /// Who has access to a zone (Owner, Administrators).
84    pub fn zone_members(&self) -> ZoneMembers<'_> {
85        ZoneMembers::new(self)
86    }
87
88    /// The caller's own user account: read it, issue or revoke the personal key.
89    pub fn users(&self) -> Users<'_> {
90        Users::new(self)
91    }
92
93    /// HTTP(S) redirects by host name, inside the caller's zones.
94    pub fn redirects(&self) -> Redirects<'_> {
95        Redirects::new(self)
96    }
97
98    /// TLS certificates of zones: upload, renew, delete; read the ones Core issues for
99    /// redirects.
100    pub fn certificates(&self) -> Certificates<'_> {
101        Certificates::new(self)
102    }
103
104    /// Records: groups (RRsets) and their values. See also the name-based
105    /// [`ops`](crate::ops) functions.
106    pub fn records(&self) -> Records<'_> {
107        Records::new(self)
108    }
109
110    /// Geo bans of record values, by uuid. See also [`ops::add_geo_bans`](crate::ops::add_geo_bans).
111    pub fn geo_bans(&self) -> GeoBans<'_> {
112        GeoBans::new(self)
113    }
114
115    /// Who did what, on the caller's account and zones.
116    pub fn audit_logs(&self) -> AuditLogs<'_> {
117        AuditLogs::new(self)
118    }
119
120    /// Query statistics of the caller's zones and client identities (Relay telemetry).
121    pub fn stats(&self) -> Stats<'_> {
122        Stats::new(self)
123    }
124
125    /// Free domains to lease a subdomain of; works without an API key.
126    pub fn free_domains(&self) -> FreeDomains<'_> {
127        FreeDomains::new(self)
128    }
129
130    /// The regions, sub-regions and countries geo bans can name.
131    pub fn geo(&self) -> Geo<'_> {
132        Geo::new(self)
133    }
134
135    /// Health checks, one per record value.
136    pub fn health_checks(&self) -> HealthChecks<'_> {
137        HealthChecks::new(self)
138    }
139
140    /// Client identities: clients Relay recognises (by IP, DoT hostname or DoH path), so
141    /// that sinkhole lists can apply to them.
142    pub fn client_identities(&self) -> ClientIdentities<'_> {
143        ClientIdentities::new(self)
144    }
145
146    /// Sinkhole lists: blacklists and whitelists of names.
147    pub fn sinkhole_lists(&self) -> SinkholeLists<'_> {
148        SinkholeLists::new(self)
149    }
150
151    /// Rules of sinkhole lists. See also
152    /// [`ops::select_sinkhole_roles`](crate::ops::select_sinkhole_roles).
153    pub fn sinkhole_roles(&self) -> SinkholeRoles<'_> {
154        SinkholeRoles::new(self)
155    }
156
157    /// Links between client identities and sinkhole lists.
158    pub fn sinkhole_links(&self) -> SinkholeLinks<'_> {
159        SinkholeLinks::new(self)
160    }
161
162    /// `GET /api/health`: unauthenticated liveness of the Core instance that answered.
163    /// Not enveloped, and works without credentials.
164    ///
165    /// # Examples
166    ///
167    /// ```no_run
168    /// # async fn example(client: &dinio_sdk::Client) -> dinio_sdk::Result<()> {
169    /// let health = client.health().await?;
170    /// println!("Core {} is {}", health.version.as_deref().unwrap_or("?"), health.status);
171    /// # Ok(())
172    /// # }
173    /// ```
174    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    /// A request to `/api/v1/<segments...>`. Each segment is percent-encoded, so names
181    /// (zones, records) can be passed as they are.
182    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    /// Sends a request whose response must carry `data`.
193    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    /// Sends a request for a cursor-paginated list: `data` and the next cursor.
202    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    /// Sends a request whose successful response is a file (zone export), not the
211    /// envelope; errors still come as the envelope.
212    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    /// The whole-request timeout, for calls that need a longer one.
218    pub(crate) fn timeout(&self) -> Duration {
219        self.timeout
220    }
221
222    /// Sends a request whose response carries no `data` (e.g. DELETE).
223    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/// Configuration for a [`Client`], from [`Client::builder`].
246#[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    /// Core endpoint without the API prefix, e.g. `https://api-dinio.bitelex.com`.
256    pub fn base_url(mut self, url: impl Into<String>) -> Self {
257        self.base_url = Some(url.into());
258        self
259    }
260
261    /// How to authenticate. Without credentials only [`Client::health`] works.
262    pub fn credentials(mut self, credentials: Credentials) -> Self {
263        self.credentials = Some(credentials);
264        self
265    }
266
267    /// Authenticate with an API key, personal or zone-scoped. Shorthand for
268    /// [`credentials`](Self::credentials) with [`Credentials::ApiKey`].
269    pub fn api_key(self, key: impl Into<String>) -> Self {
270        self.credentials(Credentials::ApiKey(Secret::new(key)))
271    }
272
273    /// `User-Agent` header; default `dinio-sdk/<version>`.
274    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    /// Whole-request timeout (connect + response). Default 30 s.
280    pub fn timeout(mut self, timeout: Duration) -> Self {
281        self.timeout = Some(timeout);
282        self
283    }
284
285    /// Creates the client. Nothing is sent yet.
286    ///
287    /// # Errors
288    ///
289    /// * [`Error::InvalidInput`] if the base URL is missing, not `http(s)`, or has a query
290    ///   or fragment.
291    /// * [`Error::Transport`] if the HTTP client cannot be set up (TLS initialization).
292    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}