1use crate::{DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder};
13use serde::{Deserialize, Serialize};
14use std::time::Duration;
15
16const DEFAULT_ENDPOINT: &str = "https://api.netlify.com/api/v1";
17
18#[derive(Clone)]
19pub struct NetlifyProvider {
20 client: HttpClientBuilder,
21 endpoint: String,
22}
23
24#[derive(Serialize, Debug)]
25struct CreateRecord<'a> {
26 hostname: &'a str,
27 #[serde(rename = "type")]
28 record_type: &'a str,
29 value: String,
30 ttl: u32,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 priority: Option<u16>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 weight: Option<u16>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 port: Option<u16>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 flag: Option<u8>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 tag: Option<String>,
41}
42
43#[derive(Deserialize, Debug, Clone)]
44#[allow(dead_code)]
45struct ListedRecord {
46 #[serde(default)]
47 id: String,
48 #[serde(default)]
49 hostname: String,
50 #[serde(default, rename = "type")]
51 record_type: String,
52 #[serde(default)]
53 value: String,
54}
55
56impl NetlifyProvider {
57 pub(crate) fn new(access_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
58 let client = HttpClientBuilder::default()
59 .with_header("Authorization", format!("Bearer {}", access_token.as_ref()))
60 .with_header("Accept", "application/json")
61 .with_timeout(timeout);
62 Self {
63 client,
64 endpoint: DEFAULT_ENDPOINT.to_string(),
65 }
66 }
67
68 #[cfg(test)]
69 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
70 Self {
71 endpoint: endpoint.as_ref().trim_end_matches('/').to_string(),
72 ..self
73 }
74 }
75
76 pub(crate) async fn create(
77 &self,
78 name: impl IntoFqdn<'_>,
79 record: DnsRecord,
80 ttl: u32,
81 origin: impl IntoFqdn<'_>,
82 ) -> crate::Result<()> {
83 let name = name.into_name().into_owned();
84 let zone_id = zone_id_from_origin(&origin.into_name());
85 let payload = build_create(&record, &name, ttl)?;
86
87 self.client
88 .post(format!(
89 "{}/dns_zones/{}/dns_records",
90 self.endpoint, zone_id
91 ))
92 .with_body(payload)?
93 .send_raw()
94 .await
95 .map(|_| ())
96 }
97
98 pub(crate) async fn update(
99 &self,
100 name: impl IntoFqdn<'_>,
101 record: DnsRecord,
102 ttl: u32,
103 origin: impl IntoFqdn<'_>,
104 ) -> crate::Result<()> {
105 let name = name.into_name().into_owned();
106 let origin = origin.into_name().into_owned();
107 let zone_id = zone_id_from_origin(&origin);
108 let record_type = record.as_type();
109 let record_id = self
110 .find_record_id(&zone_id, &name, record_type.as_str())
111 .await?;
112
113 self.client
114 .delete(format!(
115 "{}/dns_zones/{}/dns_records/{}",
116 self.endpoint, zone_id, record_id
117 ))
118 .send_raw()
119 .await?;
120
121 let payload = build_create(&record, &name, ttl)?;
122 self.client
123 .post(format!(
124 "{}/dns_zones/{}/dns_records",
125 self.endpoint, zone_id
126 ))
127 .with_body(payload)?
128 .send_raw()
129 .await
130 .map(|_| ())
131 }
132
133 pub(crate) async fn delete(
134 &self,
135 name: impl IntoFqdn<'_>,
136 origin: impl IntoFqdn<'_>,
137 record_type: DnsRecordType,
138 ) -> crate::Result<()> {
139 let name = name.into_name().into_owned();
140 let zone_id = zone_id_from_origin(&origin.into_name());
141 let record_id = self
142 .find_record_id(&zone_id, &name, record_type.as_str())
143 .await?;
144
145 self.client
146 .delete(format!(
147 "{}/dns_zones/{}/dns_records/{}",
148 self.endpoint, zone_id, record_id
149 ))
150 .send_raw()
151 .await
152 .map(|_| ())
153 }
154
155 async fn find_record_id(
156 &self,
157 zone_id: &str,
158 name: &str,
159 record_type: &str,
160 ) -> crate::Result<String> {
161 let records: Vec<ListedRecord> = self
162 .client
163 .get(format!(
164 "{}/dns_zones/{}/dns_records",
165 self.endpoint, zone_id
166 ))
167 .send()
168 .await?;
169 records
170 .into_iter()
171 .find(|r| {
172 r.hostname.trim_end_matches('.').eq_ignore_ascii_case(name)
173 && r.record_type.eq_ignore_ascii_case(record_type)
174 })
175 .map(|r| r.id)
176 .ok_or_else(|| {
177 Error::Api(format!(
178 "DNS Record {} of type {} not found in Netlify zone",
179 name, record_type
180 ))
181 })
182 }
183}
184
185fn zone_id_from_origin(origin: &str) -> String {
186 origin.trim_end_matches('.').replace('.', "_")
187}
188
189fn build_create<'a>(
190 record: &'a DnsRecord,
191 name: &'a str,
192 ttl: u32,
193) -> crate::Result<CreateRecord<'a>> {
194 let mut payload = CreateRecord {
195 hostname: name,
196 record_type: "",
197 value: String::new(),
198 ttl,
199 priority: None,
200 weight: None,
201 port: None,
202 flag: None,
203 tag: None,
204 };
205
206 match record {
207 DnsRecord::A(addr) => {
208 payload.record_type = "A";
209 payload.value = addr.to_string();
210 }
211 DnsRecord::AAAA(addr) => {
212 payload.record_type = "AAAA";
213 payload.value = addr.to_string();
214 }
215 DnsRecord::CNAME(value) => {
216 payload.record_type = "CNAME";
217 payload.value = value.clone();
218 }
219 DnsRecord::NS(value) => {
220 payload.record_type = "NS";
221 payload.value = value.clone();
222 }
223 DnsRecord::MX(mx) => {
224 payload.record_type = "MX";
225 payload.value = mx.exchange.clone();
226 payload.priority = Some(mx.priority);
227 }
228 DnsRecord::TXT(value) => {
229 payload.record_type = "TXT";
230 payload.value = value.clone();
231 }
232 DnsRecord::SRV(srv) => {
233 payload.record_type = "SRV";
234 payload.value = srv.target.clone();
235 payload.priority = Some(srv.priority);
236 payload.weight = Some(srv.weight);
237 payload.port = Some(srv.port);
238 }
239 DnsRecord::CAA(caa) => {
240 payload.record_type = "CAA";
241 let (flags, tag, value) = caa.clone().decompose();
242 payload.flag = Some(flags);
243 payload.tag = Some(tag);
244 payload.value = value;
245 }
246 DnsRecord::TLSA(_) => {
247 return Err(Error::Api(
248 "TLSA records are not supported by Netlify".to_string(),
249 ));
250 }
251 }
252
253 Ok(payload)
254}