Skip to main content

dinio_sdk/api/
records.rs

1//! `/api/v1/records` (`../core/src/api/records.ts`). The list is flat (one item per value),
2//! a single group (RRset) is read by name and type, writes are per value (uuid); TTL
3//! changes only through [`Records::set_ttl`]. Names are full owner names.
4
5use reqwest::Method;
6use serde::Serialize;
7use serde_json::json;
8use uuid::Uuid;
9
10use crate::api::{MAX_PER_PAGE, PageRequest};
11use crate::client::Client;
12use crate::envelope::Page;
13use crate::error::Result;
14use crate::models::{NewRecordValue, RecordSet, RecordValue, RecordValuePatch};
15
16/// Optional filters for [`Records::list`].
17#[derive(Debug, Clone, Default)]
18pub struct RecordFilter {
19    /// Only values with this full owner name.
20    pub name: Option<String>,
21    /// Only values of this type (`A`, `MX`, ...).
22    pub record_type: Option<String>,
23}
24
25#[derive(Serialize)]
26struct ListQuery<'a> {
27    zone_id: Uuid,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    name: Option<&'a str>,
30    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
31    record_type: Option<&'a str>,
32    #[serde(flatten)]
33    page: PageRequest,
34}
35
36/// Record routes, from [`Client::records`].
37///
38/// With a zone key, Core always uses the key's own zone, whatever `zone_id` is passed.
39/// For name-based work (a value by its data), see [`ops`](crate::ops).
40pub struct Records<'a> {
41    client: &'a Client,
42}
43
44impl<'a> Records<'a> {
45    pub(crate) fn new(client: &'a Client) -> Self {
46        Self { client }
47    }
48
49    /// One page of the zone's record values, ordered by name, type and creation, so the
50    /// values of a group are next to each other. A page boundary can split a group: fetch
51    /// every page ([`list_all`](Self::list_all)) or read one group with
52    /// [`get`](Self::get).
53    ///
54    /// `GET /api/v1/records?zone_id=...`
55    pub async fn list(
56        &self,
57        zone_id: Uuid,
58        filter: &RecordFilter,
59        page: PageRequest,
60    ) -> Result<Page<RecordValue>> {
61        let query = ListQuery {
62            zone_id,
63            name: filter.name.as_deref(),
64            record_type: filter.record_type.as_deref(),
65            page,
66        };
67        let request = self
68            .client
69            .request(Method::GET, &["records"])?
70            .query(&query);
71        let (items, meta) = self.client.send(request).await?;
72        Ok(Page { items, meta })
73    }
74
75    /// All record values of a zone matching `filter`, following pagination. For a grouped
76    /// view, pass the result to [`ops::group_values`](crate::ops::group_values).
77    ///
78    /// `GET /api/v1/records?zone_id=...`, as many times as there are pages.
79    ///
80    /// # Examples
81    ///
82    /// ```no_run
83    /// use dinio_sdk::api::RecordFilter;
84    /// use dinio_sdk::ops;
85    ///
86    /// # async fn example(client: &dinio_sdk::Client, zone: &dinio_sdk::models::Zone) -> dinio_sdk::Result<()> {
87    /// let filter = RecordFilter { record_type: Some("MX".into()), ..Default::default() };
88    /// let values = client.records().list_all(zone.uuid, &filter).await?;
89    /// for group in ops::group_values(values) {
90    ///     println!("{} {} {} ({} values)", group.name, group.ttl, group.record_type, group.values.len());
91    /// }
92    /// # Ok(())
93    /// # }
94    /// ```
95    pub async fn list_all(&self, zone_id: Uuid, filter: &RecordFilter) -> Result<Vec<RecordValue>> {
96        let mut all = Vec::new();
97        let mut page = 1;
98        loop {
99            let request = PageRequest {
100                page: Some(page),
101                per_page: Some(MAX_PER_PAGE),
102            };
103            let Page { items, meta } = self.list(zone_id, filter, request).await?;
104            all.extend(items);
105            match meta {
106                Some(meta) if page < meta.total_pages => page += 1,
107                _ => return Ok(all),
108            }
109        }
110    }
111
112    /// One group with its values. `name` is the full owner name. With `with_geo_bans`,
113    /// each value's [`geo_bans`](crate::models::RecordValue::geo_bans) is filled in.
114    ///
115    /// `GET /api/v1/records/:name?zone_id=...&type=...`
116    ///
117    /// # Errors
118    ///
119    /// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if the group does not exist
120    /// ([`ops::find_group`](crate::ops::find_group) turns that into `Ok(None)`).
121    pub async fn get(
122        &self,
123        zone_id: Uuid,
124        name: &str,
125        record_type: &str,
126        with_geo_bans: bool,
127    ) -> Result<RecordSet> {
128        let mut request = self
129            .client
130            .request(Method::GET, &["records", name])?
131            .query(&[
132                ("zone_id", zone_id.to_string().as_str()),
133                ("type", record_type),
134            ]);
135        if with_geo_bans {
136            request = request.query(&[("include", "geoBans")]);
137        }
138        Ok(self.client.send(request).await?.0)
139    }
140
141    /// Adds a value (returned with the group's `ttl`); creates the group if it does not
142    /// exist. Core validates and
143    /// canonicalizes `data` and checks the zone rules (e.g. CNAME next to other types).
144    /// [`ops::add_value`](crate::ops::add_value) handles the TTL rule for you.
145    ///
146    /// `POST /api/v1/records`
147    ///
148    /// # Errors
149    ///
150    /// * [`ErrorCode::Validation`](crate::ErrorCode::Validation) for bad data, a name
151    ///   outside the zone, or a missing `ttl` for a new group.
152    /// * [`ErrorCode::Conflict`](crate::ErrorCode::Conflict) for a duplicate value, a TTL
153    ///   different from the group's, or a violated zone rule (listed in `details`).
154    pub async fn create(&self, value: &NewRecordValue) -> Result<RecordValue> {
155        let request = self.client.request(Method::POST, &["records"])?.json(value);
156        Ok(self.client.send(request).await?.0)
157    }
158
159    /// Changes one value; fields left `None` stay as they are.
160    ///
161    /// `PATCH /api/v1/records/:uuid`
162    ///
163    /// # Errors
164    ///
165    /// * [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such value.
166    /// * [`ErrorCode::Validation`](crate::ErrorCode::Validation) or
167    ///   [`ErrorCode::Conflict`](crate::ErrorCode::Conflict) for new data, as in
168    ///   [`create`](Self::create).
169    pub async fn update(&self, uuid: Uuid, patch: &RecordValuePatch) -> Result<RecordValue> {
170        let request = self
171            .client
172            .request(Method::PATCH, &["records", &uuid.to_string()])?
173            .json(patch);
174        Ok(self.client.send(request).await?.0)
175    }
176
177    /// Sets the TTL shared by every value of the group (0 to 2^31-1 seconds).
178    ///
179    /// `PATCH /api/v1/records/:name/ttl?zone_id=...`
180    ///
181    /// # Errors
182    ///
183    /// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if the group does not exist.
184    pub async fn set_ttl(
185        &self,
186        zone_id: Uuid,
187        name: &str,
188        record_type: &str,
189        ttl: u32,
190    ) -> Result<RecordSet> {
191        let request = self
192            .client
193            .request(Method::PATCH, &["records", name, "ttl"])?
194            .query(&[("zone_id", zone_id.to_string())])
195            .json(&json!({ "type": record_type, "ttl": ttl }));
196        Ok(self.client.send(request).await?.0)
197    }
198
199    /// Deletes one value, with its geo bans and health check; the group goes with its last
200    /// value (a new first value then needs a `ttl` again).
201    ///
202    /// `DELETE /api/v1/records/:uuid`
203    ///
204    /// # Errors
205    ///
206    /// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if there is no such value.
207    pub async fn delete(&self, uuid: Uuid) -> Result<()> {
208        let request = self
209            .client
210            .request(Method::DELETE, &["records", &uuid.to_string()])?;
211        self.client.send_empty(request).await
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn list_query_serializes() {
221        let client = Client::builder()
222            .base_url("https://api.example.com")
223            .build()
224            .unwrap();
225        let zone_id = Uuid::nil();
226        let query = ListQuery {
227            zone_id,
228            name: Some("www.example.com"),
229            record_type: Some("A"),
230            page: PageRequest {
231                page: Some(2),
232                per_page: None,
233            },
234        };
235        let request = client
236            .request(Method::GET, &["records"])
237            .unwrap()
238            .query(&query)
239            .build()
240            .unwrap();
241        assert_eq!(
242            request.url().query(),
243            Some("zone_id=00000000-0000-0000-0000-000000000000&name=www.example.com&type=A&page=2")
244        );
245    }
246}