Skip to main content

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}