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}