dinio_sdk/models/record.rs
1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use crate::error::{Error, Result};
8use crate::models::GeoBan;
9
10/// An RRset ("group"): every value with the same (zone_id, name, type), all with the same
11/// `ttl`. Core stores no RRset of its own (no uuid, no timestamps); it assembles one for
12/// [`Records::get`](crate::api::Records::get) and [`Records::set_ttl`](crate::api::Records::set_ttl),
13/// and [`ops::group_values`](crate::ops::group_values) does the same for a list of values.
14/// `name` is the full owner name, lowercase, without trailing dot.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct RecordSet {
17 /// Zone the group belongs to.
18 pub zone_id: Uuid,
19 /// Full owner name (`www.example.com`).
20 pub name: String,
21 /// Record type, uppercase (`A`, `MX`, ...).
22 #[serde(rename = "type")]
23 pub record_type: String,
24 /// TTL in seconds, shared by all values.
25 pub ttl: u32,
26 /// The values; never empty.
27 #[serde(default)]
28 pub values: Vec<RecordValue>,
29}
30
31/// One member of an RRset, with its own uuid. `data` is in Core's canonical text form
32/// (e.g. names inside rdata as FQDNs with a trailing dot, TXT quoted).
33///
34/// `geo_routing`, `balance_ratio`, `host_health` and geo bans take effect on A and AAAA
35/// values only.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct RecordValue {
38 /// Value id; what writes address.
39 pub uuid: Uuid,
40 /// Zone the value belongs to.
41 pub zone_id: Uuid,
42 /// Full owner name of the group.
43 pub name: String,
44 /// Record type of the group, uppercase.
45 #[serde(rename = "type")]
46 pub record_type: String,
47 /// TTL in seconds; the same on every value of the group, changed only through
48 /// [`Records::set_ttl`](crate::api::Records::set_ttl).
49 pub ttl: u32,
50 /// Record data in canonical form (`10 mail.example.com.`).
51 pub data: String,
52 /// `true`: Relay prefers this value for clients near the address's location.
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub geo_routing: Option<bool>,
55 /// Weight for picking which value is answered first; `None` or 0 means no weight.
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub balance_ratio: Option<u16>,
58 /// How the value's health is decided: `None`, `Up`, `Down` or `Auto` (kept as a string
59 /// for forward compatibility; see [`HostHealthMode`] and
60 /// [`RecordValue::host_health_mode`]). Core fills `None` by default.
61 #[serde(default = "default_host_health")]
62 pub host_health: String,
63 /// Who keeps the value when it is not the user: `System`, `Redirect` or `Acme` (kept as
64 /// a string for forward compatibility); `None` for the user's own values. See
65 /// [`RecordManager`], [`RecordValue::manager`] and [`RecordValue::is_locked`].
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub managed_by: Option<String>,
68 /// Unix seconds.
69 pub created_at: i64,
70 /// Unix seconds.
71 pub updated_at: i64,
72 /// Only with `include=geoBans`.
73 #[serde(rename = "geoBans", default, skip_serializing_if = "Option::is_none")]
74 pub geo_bans: Option<Vec<GeoBan>>,
75}
76
77fn default_host_health() -> String {
78 HostHealthMode::None.as_wire().to_owned()
79}
80
81impl RecordValue {
82 /// `host_health` in its typed form; `None` for a label this SDK version does not know.
83 pub fn host_health_mode(&self) -> Option<HostHealthMode> {
84 self.host_health.parse().ok()
85 }
86
87 /// `managed_by` in its typed form; `None` for the user's own values and for labels this
88 /// SDK version does not know.
89 pub fn manager(&self) -> Option<RecordManager> {
90 self.managed_by.as_deref().and_then(|m| m.parse().ok())
91 }
92
93 /// Whether Core refuses to change or delete the value through `/records` (409
94 /// `MANAGED_RECORD`): it belongs to a redirect or a certificate issuance. A manager
95 /// label this SDK version does not know counts as locked.
96 pub fn is_locked(&self) -> bool {
97 self.managed_by.is_some() && self.manager() != Some(RecordManager::System)
98 }
99}
100
101/// Who keeps a record value on the user's behalf (`RecordValue.managed_by`).
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
103pub enum RecordManager {
104 /// The zone's own apex SOA and NS, created with the zone. Editable, but other NS
105 /// values take the zone off the platform's nameservers.
106 System,
107 /// The CNAME or A/AAAA of a redirect; changes only through the redirect.
108 Redirect,
109 /// The `_acme-challenge` TXT of a certificate being issued (exists for minutes).
110 Acme,
111}
112
113impl RecordManager {
114 /// The label Core uses (`System`, `Redirect`, `Acme`).
115 pub fn as_wire(self) -> &'static str {
116 match self {
117 Self::System => "System",
118 Self::Redirect => "Redirect",
119 Self::Acme => "Acme",
120 }
121 }
122}
123
124impl fmt::Display for RecordManager {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 f.write_str(self.as_wire())
127 }
128}
129
130impl FromStr for RecordManager {
131 type Err = Error;
132
133 /// Case-insensitive wire label (`system`, `Redirect`).
134 fn from_str(text: &str) -> Result<Self> {
135 match text.trim().to_ascii_lowercase().as_str() {
136 "system" => Ok(Self::System),
137 "redirect" => Ok(Self::Redirect),
138 "acme" => Ok(Self::Acme),
139 _ => Err(Error::InvalidInput(format!(
140 "unknown record manager {text:?} (System, Redirect or Acme)"
141 ))),
142 }
143 }
144}
145
146/// How Relay decides whether a record value is healthy (`RecordValue.host_health`). An
147/// unhealthy value is left out of answers.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
149pub enum HostHealthMode {
150 /// Not managed: always served as healthy. Core's default.
151 None,
152 /// Forced healthy; the value's health check stays configured but does not run.
153 Up,
154 /// Forced unhealthy (e.g. maintenance); the health check stays configured but does not
155 /// run.
156 Down,
157 /// The value's health check decides; the only mode in which the check runs. Needs a
158 /// health check: [`HealthChecks::create`](crate::api::HealthChecks::create) switches the
159 /// value to `Auto`, [`HealthChecks::delete`](crate::api::HealthChecks::delete) turns
160 /// `Auto` back into `None`.
161 Auto,
162}
163
164impl HostHealthMode {
165 /// The label Core uses (`None`, `Up`, `Down`, `Auto`).
166 pub fn as_wire(self) -> &'static str {
167 match self {
168 Self::None => "None",
169 Self::Up => "Up",
170 Self::Down => "Down",
171 Self::Auto => "Auto",
172 }
173 }
174}
175
176impl fmt::Display for HostHealthMode {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178 f.write_str(self.as_wire())
179 }
180}
181
182impl FromStr for HostHealthMode {
183 type Err = Error;
184
185 /// Case-insensitive wire label (`auto`, `Down`).
186 fn from_str(text: &str) -> Result<Self> {
187 match text.trim().to_ascii_lowercase().as_str() {
188 "none" => Ok(Self::None),
189 "up" => Ok(Self::Up),
190 "down" => Ok(Self::Down),
191 "auto" => Ok(Self::Auto),
192 _ => Err(Error::InvalidInput(format!(
193 "unknown host health mode {text:?} (None, Up, Down or Auto)"
194 ))),
195 }
196 }
197}
198
199/// `POST /records` body. `ttl` is required when the group does not exist yet (also after
200/// its last value was deleted), and must match the group's TTL otherwise.
201#[derive(Debug, Clone, Serialize)]
202pub struct NewRecordValue {
203 /// Zone to add to.
204 pub zone_id: Uuid,
205 /// Full owner name (see [`crate::ops::qualify_name`]).
206 pub name: String,
207 /// Record type, any case.
208 #[serde(rename = "type")]
209 pub record_type: String,
210 /// Record data as a user would write it in a zone file; Core canonicalizes it.
211 pub data: String,
212 /// TTL in seconds; see the type's docs for when it is required.
213 #[serde(skip_serializing_if = "Option::is_none")]
214 pub ttl: Option<u32>,
215 /// See [`RecordValue::geo_routing`].
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub geo_routing: Option<bool>,
218 /// See [`RecordValue::balance_ratio`].
219 #[serde(skip_serializing_if = "Option::is_none")]
220 pub balance_ratio: Option<u16>,
221 /// See [`HostHealthMode`]; Core's default (`None`) when omitted. `Auto` is rejected
222 /// with [`ErrorCode::Validation`](crate::ErrorCode::Validation): a value gets `Auto`
223 /// when a health check is created for it.
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub host_health: Option<HostHealthMode>,
226}
227
228/// `PATCH /records/:uuid` body; `None` fields are left unchanged. TTL, name and type cannot
229/// be changed here.
230#[derive(Debug, Clone, Default, Serialize)]
231pub struct RecordValuePatch {
232 /// New record data.
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub data: Option<String>,
235 /// See [`RecordValue::geo_routing`].
236 #[serde(skip_serializing_if = "Option::is_none")]
237 pub geo_routing: Option<bool>,
238 /// See [`RecordValue::balance_ratio`].
239 #[serde(skip_serializing_if = "Option::is_none")]
240 pub balance_ratio: Option<u16>,
241 /// See [`HostHealthMode`]. `Auto` needs a health check on the value, otherwise
242 /// [`ErrorCode::Validation`](crate::ErrorCode::Validation). Every mode change resets the
243 /// check's status to `UNKNOWN`.
244 #[serde(skip_serializing_if = "Option::is_none")]
245 pub host_health: Option<HostHealthMode>,
246}
247
248impl RecordValuePatch {
249 /// Whether the patch changes nothing.
250 pub fn is_empty(&self) -> bool {
251 self.data.is_none()
252 && self.geo_routing.is_none()
253 && self.balance_ratio.is_none()
254 && self.host_health.is_none()
255 }
256}