Skip to main content

dinio_sdk/api/
zones.rs

1//! `/api/v1/zones` (`../core/src/api/zones.ts`). A zone ref is its uuid or its name.
2
3use std::time::Duration;
4
5use reqwest::Method;
6use serde_json::json;
7
8use crate::api::{IssuedKey, MAX_PER_PAGE, PageRequest};
9use crate::auth::Secret;
10use crate::client::Client;
11use crate::envelope::Page;
12use crate::error::Result;
13use crate::models::{Discovery, ImportReport, Zone, ZoneFileFormat};
14
15/// Zone routes, from [`Client::zones`].
16///
17/// A zone is addressed by its uuid or its name (`"example.com"`). Access: a personal key
18/// reaches the zones its user is a member of; a zone key only its own zone.
19pub struct Zones<'a> {
20    client: &'a Client,
21}
22
23impl<'a> Zones<'a> {
24    pub(crate) fn new(client: &'a Client) -> Self {
25        Self { client }
26    }
27
28    /// Zones visible to the caller. Core paginates only for an Admin; everyone else gets
29    /// their whole list on one page. Prefer [`list_all`](Self::list_all) unless you page
30    /// yourself.
31    ///
32    /// `GET /api/v1/zones`
33    pub async fn list(&self, page: PageRequest) -> Result<Page<Zone>> {
34        let request = self.client.request(Method::GET, &["zones"])?.query(&page);
35        let (items, meta) = self.client.send(request).await?;
36        Ok(Page { items, meta })
37    }
38
39    /// All visible zones, following pagination.
40    ///
41    /// `GET /api/v1/zones`, as many times as there are pages.
42    pub async fn list_all(&self) -> Result<Vec<Zone>> {
43        let mut all = Vec::new();
44        let mut page = 1;
45        loop {
46            let request = PageRequest {
47                page: Some(page),
48                per_page: Some(MAX_PER_PAGE),
49            };
50            let Page { items, meta } = self.list(request).await?;
51            all.extend(items);
52            match meta {
53                Some(meta) if page < meta.total_pages => page += 1,
54                _ => return Ok(all),
55            }
56        }
57    }
58
59    /// One zone by uuid or name.
60    ///
61    /// `GET /api/v1/zones/:ref`
62    ///
63    /// # Errors
64    ///
65    /// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such zone.
66    ///
67    /// # Examples
68    ///
69    /// ```no_run
70    /// # async fn example(client: &dinio_sdk::Client) -> dinio_sdk::Result<()> {
71    /// let zone = client.zones().get("example.com").await?;
72    /// println!("{} is {}", zone.name, zone.uuid);
73    /// # Ok(())
74    /// # }
75    /// ```
76    pub async fn get(&self, zone: &str) -> Result<Zone> {
77        let request = self.client.request(Method::GET, &["zones", zone])?;
78        Ok(self.client.send(request).await?.0)
79    }
80
81    /// Creates a zone; the caller becomes its Owner and Core adds the apex SOA and NS.
82    /// Not allowed with a zone-scoped key.
83    ///
84    /// A name directly below a free domain
85    /// ([`FreeDomains::list`](crate::api::FreeDomains::list)) is a lease: one label of
86    /// 3–63 characters, not reserved, not taken, and at most the free domains' `limit`
87    /// leases per user. Core then also delegates the name from the free domain.
88    ///
89    /// `POST /api/v1/zones`
90    ///
91    /// # Errors
92    ///
93    /// * [`ErrorCode::Conflict`](crate::ErrorCode::Conflict) if the zone already exists,
94    ///   or a lease breaks a rule (`details` says which, e.g. the lease limit).
95    /// * [`ErrorCode::Validation`](crate::ErrorCode::Validation) if the name is not a valid
96    ///   domain name.
97    /// * [`ErrorCode::Forbidden`](crate::ErrorCode::Forbidden) with a zone key, or for a
98    ///   free domain itself (Admin only).
99    pub async fn create(&self, name: &str) -> Result<Zone> {
100        let request = self
101            .client
102            .request(Method::POST, &["zones"])?
103            .json(&json!({ "name": name }));
104        Ok(self.client.send(request).await?.0)
105    }
106
107    /// Issues a new zone-scoped key (`dz_…`) and returns it. Core shows the key only in
108    /// this response and keeps just its hash, so store it now. A previous key of the zone
109    /// stops working immediately; if it is the key this client uses, build a new client with
110    /// the new key. Allowed for the zone's own key, its Owner or Administrator, and Admin.
111    ///
112    /// `POST /api/v1/zones/:ref/api-key`
113    ///
114    /// # Errors
115    ///
116    /// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such zone.
117    ///
118    /// # Examples
119    ///
120    /// ```no_run
121    /// # async fn example(client: &dinio_sdk::Client) -> dinio_sdk::Result<()> {
122    /// let key = client.zones().create_api_key("example.com").await?;
123    /// // Show the key to the user now; Core never returns it again.
124    /// println!("{}", key.expose());
125    /// # Ok(())
126    /// # }
127    /// ```
128    pub async fn create_api_key(&self, zone: &str) -> Result<Secret> {
129        let request = self
130            .client
131            .request(Method::POST, &["zones", zone, "api-key"])?;
132        let (issued, _): (IssuedKey, _) = self.client.send(request).await?;
133        Ok(issued.api_key)
134    }
135
136    /// Revokes the zone's key; the zone then has none
137    /// ([`Zone::has_api_key`] is `false`). Same access as
138    /// [`create_api_key`](Self::create_api_key).
139    ///
140    /// `DELETE /api/v1/zones/:ref/api-key`
141    ///
142    /// # Errors
143    ///
144    /// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such zone.
145    pub async fn revoke_api_key(&self, zone: &str) -> Result<Zone> {
146        let request = self
147            .client
148            .request(Method::DELETE, &["zones", zone, "api-key"])?;
149        Ok(self.client.send(request).await?.0)
150    }
151
152    /// Deletes the zone with all its records, geo bans and health checks. Irreversible.
153    /// Allowed for the zone's Owner, an Admin or the zone's own key; a delegated
154    /// Administrator may not.
155    ///
156    /// `DELETE /api/v1/zones/:ref`
157    ///
158    /// # Errors
159    ///
160    /// * [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such zone.
161    /// * [`ErrorCode::Forbidden`](crate::ErrorCode::Forbidden) if the caller is not the
162    ///   Owner.
163    pub async fn delete(&self, zone: &str) -> Result<()> {
164        let request = self.client.request(Method::DELETE, &["zones", zone])?;
165        self.client.send_empty(request).await
166    }
167
168    /// The zone as a file, exactly as Core writes it. `Json` is the dinio-zone document
169    /// (parse it into [`ZoneDocument`](crate::models::ZoneDocument) if needed); `Bind`
170    /// and `Csv` carry only name, ttl, type and data. Reading is enough, so it works on
171    /// a suspended zone too.
172    ///
173    /// `GET /api/v1/zones/:ref/export?format=`
174    ///
175    /// # Errors
176    ///
177    /// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such zone.
178    ///
179    /// # Examples
180    ///
181    /// ```no_run
182    /// use dinio_sdk::models::ZoneFileFormat;
183    ///
184    /// # async fn example(client: &dinio_sdk::Client) -> dinio_sdk::Result<()> {
185    /// let zone_file = client.zones().export("example.com", ZoneFileFormat::Bind).await?;
186    /// std::fs::write("example.com.zone", zone_file).expect("write");
187    /// # Ok(())
188    /// # }
189    /// ```
190    pub async fn export(&self, zone: &str, format: ZoneFileFormat) -> Result<String> {
191        let request = self
192            .client
193            .request(Method::GET, &["zones", zone, "export"])?
194            .query(&[("format", format.as_wire())]);
195        self.client.send_file(request).await
196    }
197
198    /// Merges a zone file into the zone: adds what the zone lacks, never changes or
199    /// deletes anything. SOA and apex NS in the file are skipped. Records are written in
200    /// one transaction (all or none); redirects of a dinio-zone document afterwards, each
201    /// on its own. With `dry_run` Core checks everything and writes nothing.
202    ///
203    /// `POST /api/v1/zones/:ref/import?format=&dry_run=`
204    ///
205    /// # Errors
206    ///
207    /// * [`ErrorCode::ImportFailed`](crate::ErrorCode::ImportFailed) if any record is
208    ///   refused: nothing was written, and
209    ///   [`ApiError::import_report`](crate::ApiError::import_report) has the report. A dry
210    ///   run returns the same report as `Ok` instead.
211    /// * [`ErrorCode::PayloadTooLarge`](crate::ErrorCode::PayloadTooLarge) for a file over
212    ///   2 MiB (at most 10 000 records).
213    /// * [`ErrorCode::Validation`](crate::ErrorCode::Validation) if a dinio-zone document
214    ///   does not have the expected shape.
215    /// * [`ErrorCode::Suspended`](crate::ErrorCode::Suspended) for a suspended zone.
216    /// * [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such zone.
217    ///
218    /// # Examples
219    ///
220    /// ```no_run
221    /// use dinio_sdk::models::ZoneFileFormat;
222    ///
223    /// # async fn example(client: &dinio_sdk::Client) -> dinio_sdk::Result<()> {
224    /// let file = std::fs::read_to_string("example.com.zone").expect("read");
225    /// let zones = client.zones();
226    /// let preview = zones.import("example.com", ZoneFileFormat::Bind, file.clone(), true).await?;
227    /// if !preview.has_errors() {
228    ///     let report = zones.import("example.com", ZoneFileFormat::Bind, file, false).await?;
229    ///     println!("{} records created", report.records.created);
230    /// }
231    /// # Ok(())
232    /// # }
233    /// ```
234    pub async fn import(
235        &self,
236        zone: &str,
237        format: ZoneFileFormat,
238        file: String,
239        dry_run: bool,
240    ) -> Result<ImportReport> {
241        let mut request = self
242            .client
243            .request(Method::POST, &["zones", zone, "import"])?
244            .query(&[("format", format.as_wire())]);
245        if dry_run {
246            request = request.query(&[("dry_run", "true")]);
247        }
248        let content_type = match format {
249            ZoneFileFormat::Json => "application/json",
250            ZoneFileFormat::Bind => "text/dns; charset=utf-8",
251            ZoneFileFormat::Csv => "text/csv; charset=utf-8",
252        };
253        let request = request
254            .header(reqwest::header::CONTENT_TYPE, content_type)
255            .body(file);
256        Ok(self.client.send(request).await?.0)
257    }
258
259    /// Asks the zone's current nameservers (the previous provider's) about the names a
260    /// zone usually has and returns a proposal to review, trim and pass to
261    /// [`import`](Self::import). Writes nothing. Finds common names only, not every
262    /// record (AXFR is refused almost everywhere). Takes up to about a minute, so the
263    /// request waits at least 90 s whatever the client's timeout.
264    ///
265    /// `nameservers` (host names or addresses, at most 8) are needed once the delegation
266    /// already points to Dinio; empty means "ask the delegation".
267    ///
268    /// `POST /api/v1/zones/:ref/discover`
269    ///
270    /// # Errors
271    ///
272    /// * [`ErrorCode::DiscoveryFailed`](crate::ErrorCode::DiscoveryFailed) if no usable
273    ///   nameserver answered; typically the delegation already points to Dinio, so give the
274    ///   previous provider's nameservers.
275    /// * [`ErrorCode::Conflict`](crate::ErrorCode::Conflict) while a discovery of the zone
276    ///   is running.
277    /// * [`ErrorCode::Suspended`](crate::ErrorCode::Suspended) for a suspended zone.
278    /// * [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such zone.
279    ///
280    /// # Examples
281    ///
282    /// ```no_run
283    /// use dinio_sdk::models::ZoneFileFormat;
284    ///
285    /// # async fn example(client: &dinio_sdk::Client) -> dinio_sdk::Result<()> {
286    /// let found = client.zones().discover("example.com", &[]).await?;
287    /// let mut document = found.document;
288    /// document.records.retain(|r| r.record_type != "TXT");
289    /// let file = serde_json::to_string(&document).expect("serializable");
290    /// client.zones().import("example.com", ZoneFileFormat::Json, file, false).await?;
291    /// # Ok(())
292    /// # }
293    /// ```
294    pub async fn discover(&self, zone: &str, nameservers: &[String]) -> Result<Discovery> {
295        let mut request = self
296            .client
297            .request(Method::POST, &["zones", zone, "discover"])?
298            .timeout(self.client.timeout().max(DISCOVER_TIMEOUT));
299        if !nameservers.is_empty() {
300            request = request.json(&json!({ "nameservers": nameservers }));
301        }
302        Ok(self.client.send(request).await?.0)
303    }
304}
305
306/// Core's discovery budget is 60 s; the rest covers the request around it.
307const DISCOVER_TIMEOUT: Duration = Duration::from_secs(90);