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);