Skip to main content

cloud/envoy/
dns_record.rs

1//! `dns.*` verb signatures — initial catalog (R409-T6).
2//!
3//! Three verbs that cover the DNS plane with Cloudflare as the exemplar
4//! tier-S provider (W144 §"dns.* — name resolution"):
5//!
6//! - `dns.record.upsert` — create or update a DNS record in a named zone
7//! - `dns.record.delete` — remove matching records from a zone
8//! - `dns.zone.list`    — enumerate accessible zones
9//!
10//! Zone resolution is by apex name (e.g. `"yah.dev"`), not by provider-issued
11//! zone ID — the adapter owns the name→id lookup so callers stay
12//! provider-agnostic. The `type` field follows the RFC 1035 convention
13//! (uppercase strings: `"A"`, `"CNAME"`, `"TXT"`, etc.).
14
15use serde::{Deserialize, Serialize};
16
17use super::{InternalVerb, VerbCategory};
18
19// ── dns.record.upsert ─────────────────────────────────────────────────────
20
21/// Marker type for the `dns.record.upsert` verb.
22pub struct DnsRecordUpsert;
23
24/// Request body for `dns.record.upsert`.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
27pub struct DnsRecordUpsertInput {
28    /// Zone apex name, e.g. `"yah.dev"`. The adapter resolves it to a
29    /// provider-issued zone ID.
30    pub zone: String,
31    /// Fully-qualified record name, e.g. `"yubaba.yah.dev"`. Apex records
32    /// may also be passed as `"@"` — adapters normalise as needed.
33    pub name: String,
34    /// DNS record type (`"A"`, `"AAAA"`, `"CNAME"`, `"TXT"`, `"MX"`, …).
35    #[serde(rename = "type")]
36    pub record_type: String,
37    /// Record value: for CNAME the target hostname; for A/AAAA the IP; for
38    /// TXT the verbatim string content.
39    pub content: String,
40    /// TTL in seconds. `1` means "automatic" (effective TTL chosen by the
41    /// provider). Defaults to `1`.
42    #[serde(default = "ttl_auto")]
43    pub ttl: u32,
44    /// Route through Cloudflare's reverse proxy (orange-cloud). Only
45    /// meaningful on Cloudflare for A/AAAA/CNAME records; adapters for
46    /// other providers should ignore this field. Defaults to `false`.
47    #[serde(default)]
48    pub proxied: bool,
49}
50
51fn ttl_auto() -> u32 {
52    1
53}
54
55/// Response body for `dns.record.upsert`.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
58pub struct DnsRecordUpsertOutput {
59    /// Provider-issued record ID. Stable for the lifetime of the record;
60    /// can be used in `dns.record.delete` to target a specific record by ID
61    /// instead of name+type once that verb shape grows an `id` field.
62    pub id: String,
63}
64
65impl InternalVerb for DnsRecordUpsert {
66    type Input = DnsRecordUpsertInput;
67    type Output = DnsRecordUpsertOutput;
68    const ID: &'static str = "dns.record.upsert";
69    const CATEGORY: VerbCategory = VerbCategory::Dns;
70}
71
72// ── dns.record.delete ─────────────────────────────────────────────────────
73
74/// Marker type for the `dns.record.delete` verb.
75pub struct DnsRecordDelete;
76
77/// Request body for `dns.record.delete`.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
80pub struct DnsRecordDeleteInput {
81    /// Zone apex name, e.g. `"yah.dev"`.
82    pub zone: String,
83    /// Record name to delete, e.g. `"yubaba.yah.dev"`.
84    pub name: String,
85    /// Filter by record type. When absent, all records matching `name` are
86    /// deleted regardless of type. Pass `"CNAME"` to delete only CNAME
87    /// records for the name, for example.
88    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
89    pub record_type: Option<String>,
90}
91
92/// Response body for `dns.record.delete`.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
95pub struct DnsRecordDeleteOutput {
96    /// Count of records actually deleted. `0` is not an error — the record
97    /// may already have been absent (idempotent).
98    pub deleted: u32,
99}
100
101impl InternalVerb for DnsRecordDelete {
102    type Input = DnsRecordDeleteInput;
103    type Output = DnsRecordDeleteOutput;
104    const ID: &'static str = "dns.record.delete";
105    const CATEGORY: VerbCategory = VerbCategory::Dns;
106}
107
108// ── dns.zone.list ─────────────────────────────────────────────────────────
109
110/// Marker type for the `dns.zone.list` verb.
111pub struct DnsZoneList;
112
113/// Request body for `dns.zone.list`. Empty — zone listing requires no
114/// parameters beyond the adapter's credential scope.
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
117pub struct DnsZoneListInput {}
118
119/// One zone entry in the `dns.zone.list` response.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
122pub struct DnsZoneEntry {
123    /// Provider-issued zone ID. Opaque; stable within a provider.
124    pub id: String,
125    /// Zone apex name, e.g. `"yah.dev"`.
126    pub name: String,
127}
128
129/// Response body for `dns.zone.list`.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
132pub struct DnsZoneListOutput {
133    pub zones: Vec<DnsZoneEntry>,
134}
135
136impl InternalVerb for DnsZoneList {
137    type Input = DnsZoneListInput;
138    type Output = DnsZoneListOutput;
139    const ID: &'static str = "dns.zone.list";
140    const CATEGORY: VerbCategory = VerbCategory::Dns;
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn verb_ids_match_canonical_namespace() {
149        assert_eq!(DnsRecordUpsert::ID, "dns.record.upsert");
150        assert_eq!(DnsRecordDelete::ID, "dns.record.delete");
151        assert_eq!(DnsZoneList::ID, "dns.zone.list");
152        for id in [DnsRecordUpsert::ID, DnsRecordDelete::ID, DnsZoneList::ID] {
153            assert!(id.starts_with("dns."), "{id}");
154        }
155    }
156
157    #[test]
158    fn verbs_are_under_dns_category() {
159        assert_eq!(DnsRecordUpsert::CATEGORY, VerbCategory::Dns);
160        assert_eq!(DnsRecordDelete::CATEGORY, VerbCategory::Dns);
161        assert_eq!(DnsZoneList::CATEGORY, VerbCategory::Dns);
162    }
163
164    #[test]
165    fn upsert_input_defaults_ttl_to_auto_and_proxied_false() {
166        let wire = r#"{"zone":"yah.dev","name":"yubaba.yah.dev","type":"CNAME","content":"t.cfargotunnel.com"}"#;
167        let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
168        assert_eq!(parsed.ttl, 1, "default TTL should be 1 (automatic)");
169        assert!(!parsed.proxied, "default proxied should be false");
170    }
171
172    #[test]
173    fn upsert_input_type_renamed_in_wire() {
174        let wire = r#"{"zone":"yah.dev","name":"a.yah.dev","type":"A","content":"1.2.3.4","ttl":300,"proxied":true}"#;
175        let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
176        assert_eq!(parsed.record_type, "A");
177        assert_eq!(parsed.ttl, 300);
178        assert!(parsed.proxied);
179        // Verify the Rust field serializes back as "type".
180        let back = serde_json::to_value(&parsed).unwrap();
181        assert!(back.get("type").is_some(), "should serialize as 'type'");
182        assert!(
183            back.get("record_type").is_none(),
184            "should not serialize as 'record_type'"
185        );
186    }
187
188    #[test]
189    fn delete_input_type_optional() {
190        let with_type = r#"{"zone":"yah.dev","name":"old.yah.dev","type":"CNAME"}"#;
191        let parsed: DnsRecordDeleteInput = serde_json::from_str(with_type).unwrap();
192        assert_eq!(parsed.record_type.as_deref(), Some("CNAME"));
193
194        let no_type = r#"{"zone":"yah.dev","name":"old.yah.dev"}"#;
195        let parsed: DnsRecordDeleteInput = serde_json::from_str(no_type).unwrap();
196        assert!(parsed.record_type.is_none());
197    }
198
199    #[test]
200    fn delete_input_omits_type_when_absent() {
201        let input = DnsRecordDeleteInput {
202            zone: "z".into(),
203            name: "n".into(),
204            record_type: None,
205        };
206        let wire = serde_json::to_value(&input).unwrap();
207        assert!(!wire.as_object().unwrap().contains_key("type"));
208    }
209
210    #[test]
211    fn delete_output_zero_is_not_an_error() {
212        let out = DnsRecordDeleteOutput { deleted: 0 };
213        let wire = serde_json::to_value(&out).unwrap();
214        assert_eq!(wire["deleted"], 0);
215    }
216
217    #[test]
218    fn zone_list_input_serializes_to_empty_object() {
219        let wire = serde_json::to_value(DnsZoneListInput::default()).unwrap();
220        assert_eq!(wire, serde_json::json!({}));
221    }
222
223    #[test]
224    fn zone_list_output_round_trips() {
225        let out = DnsZoneListOutput {
226            zones: vec![
227                DnsZoneEntry {
228                    id: "z1".into(),
229                    name: "yah.dev".into(),
230                },
231                DnsZoneEntry {
232                    id: "z2".into(),
233                    name: "noisetable.com".into(),
234                },
235            ],
236        };
237        let wire = serde_json::to_string(&out).unwrap();
238        let back: DnsZoneListOutput = serde_json::from_str(&wire).unwrap();
239        assert_eq!(back.zones.len(), 2);
240        assert_eq!(back.zones[0].name, "yah.dev");
241    }
242
243    #[cfg(feature = "json-schema")]
244    #[test]
245    fn verbs_emit_schemas_via_for_verb() {
246        use super::super::VerbDescriptor;
247
248        let upsert = VerbDescriptor::for_verb::<DnsRecordUpsert>();
249        assert_eq!(upsert.id, "dns.record.upsert");
250        assert!(upsert.input_schema.to_string().contains("content"));
251
252        let delete = VerbDescriptor::for_verb::<DnsRecordDelete>();
253        assert_eq!(delete.id, "dns.record.delete");
254        assert!(delete.output_schema.to_string().contains("deleted"));
255
256        let list = VerbDescriptor::for_verb::<DnsZoneList>();
257        assert_eq!(list.id, "dns.zone.list");
258        assert!(list.output_schema.to_string().contains("zones"));
259    }
260}