use chrono::{DateTime, Utc};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::error::{InvalidValue, Result, check_path_segment};
use crate::page::ListRequest;
use crate::ratelimit::{Scope, ScopeSet};
use crate::types::{RecordType, Subname};
pub const MAX_TTL: u32 = 86_400;
pub const MAX_RECORDS: usize = 4_091;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct Rrset {
pub domain: String,
pub subname: Subname,
#[serde(rename = "type")]
pub record_type: RecordType,
pub name: String,
pub records: Vec<String>,
pub ttl: u32,
pub created: DateTime<Utc>,
#[serde(default)]
pub touched: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NewRrset {
pub subname: Subname,
#[serde(rename = "type")]
pub record_type: RecordType,
pub ttl: u32,
pub records: Vec<String>,
}
impl NewRrset {
pub fn new(
subname: Subname,
record_type: RecordType,
ttl: u32,
records: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
subname,
record_type,
ttl,
records: records.into_iter().map(Into::into).collect(),
}
}
pub fn at_apex(
record_type: RecordType,
ttl: u32,
records: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self::new(Subname::apex(), record_type, ttl, records)
}
fn validate(&self) -> Result<(), InvalidValue> {
validate_ttl(self.ttl)?;
validate_records(&self.records)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct RrsetPatch {
#[serde(skip_serializing_if = "Option::is_none")]
ttl: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
records: Option<Vec<String>>,
}
impl RrsetPatch {
pub fn new() -> Self {
Self::default()
}
pub fn ttl(mut self, ttl: u32) -> Self {
self.ttl = Some(ttl);
self
}
pub fn records(mut self, records: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.records = Some(records.into_iter().map(Into::into).collect());
self
}
pub fn is_empty(&self) -> bool {
self.ttl.is_none() && self.records.is_none()
}
fn validate(&self) -> Result<(), InvalidValue> {
if let Some(ttl) = self.ttl {
validate_ttl(ttl)?;
}
if let Some(records) = &self.records {
validate_records(records)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BulkPatch {
pub subname: Subname,
#[serde(rename = "type")]
pub record_type: RecordType,
#[serde(skip_serializing_if = "Option::is_none")]
ttl: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
records: Option<Vec<String>>,
}
impl BulkPatch {
pub fn new(subname: Subname, record_type: RecordType) -> Self {
Self {
subname,
record_type,
ttl: None,
records: None,
}
}
pub fn ttl(mut self, ttl: u32) -> Self {
self.ttl = Some(ttl);
self
}
pub fn records(mut self, records: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.records = Some(records.into_iter().map(Into::into).collect());
self
}
pub fn delete(subname: Subname, record_type: RecordType) -> Self {
Self::new(subname, record_type).records(Vec::<String>::new())
}
fn validate(&self) -> Result<(), InvalidValue> {
if let Some(ttl) = self.ttl {
validate_ttl(ttl)?;
}
if let Some(records) = &self.records {
validate_records(records)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BulkPut {
pub subname: Subname,
#[serde(rename = "type")]
pub record_type: RecordType,
pub ttl: u32,
pub records: Vec<String>,
}
impl BulkPut {
pub fn new(
subname: Subname,
record_type: RecordType,
ttl: u32,
records: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
subname,
record_type,
ttl,
records: records.into_iter().map(Into::into).collect(),
}
}
fn validate(&self) -> Result<(), InvalidValue> {
validate_ttl(self.ttl)?;
validate_records(&self.records)
}
}
fn validate_ttl(ttl: u32) -> Result<(), InvalidValue> {
if ttl == 0 || ttl > MAX_TTL {
return Err(InvalidValue::new(
"ttl",
"must be between 1 and 86400 seconds",
ttl.to_string(),
));
}
Ok(())
}
fn validate_records(records: &[String]) -> Result<(), InvalidValue> {
if records.len() > MAX_RECORDS {
return Err(InvalidValue::new(
"records",
"an RRset holds at most 4091 records",
records.len().to_string(),
));
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub struct RrsetsApi<'a> {
client: &'a Client,
domain: &'a str,
}
impl<'a> RrsetsApi<'a> {
pub(crate) fn new(client: &'a Client, domain: &'a str) -> Self {
Self { client, domain }
}
fn write_scope(&self) -> ScopeSet {
ScopeSet::per_domain(Scope::DnsApiPerDomainExpensive, self.domain)
}
fn collection_url(&self) -> Result<url::Url> {
check_path_segment("domain", self.domain)?;
Ok(self.client.url(&["domains", self.domain, "rrsets"]))
}
fn item_url(&self, subname: &Subname, record_type: &RecordType) -> Result<url::Url> {
check_path_segment("domain", self.domain)?;
Ok(self.client.url(&[
"domains",
self.domain,
"rrsets",
subname.as_path(),
record_type.as_str(),
]))
}
pub async fn create(&self, rrset: &NewRrset) -> Result<Rrset> {
rrset.validate()?;
let req = self
.client
.request(Method::POST, self.collection_url()?, self.write_scope())
.json(rrset)?;
self.client.send_json(req).await
}
pub async fn create_bulk(&self, rrsets: &[NewRrset]) -> Result<Vec<Rrset>> {
for rrset in rrsets {
rrset.validate()?;
}
let req = self
.client
.request(Method::POST, self.collection_url()?, self.write_scope())
.json(rrsets)?;
self.client.send_json(req).await
}
pub fn list(&self) -> ListRequest<Rrset> {
ListRequest::new(
self.client.clone(),
self.client.url(&["domains", self.domain, "rrsets"]),
ScopeSet::new(Scope::DnsApiCheap),
)
}
pub async fn get(&self, subname: &Subname, record_type: &RecordType) -> Result<Rrset> {
let req = self.client.request(
Method::GET,
self.item_url(subname, record_type)?,
ScopeSet::new(Scope::DnsApiCheap),
);
self.client.send_json(req).await
}
pub async fn try_get(
&self,
subname: &Subname,
record_type: &RecordType,
) -> Result<Option<Rrset>> {
let req = self.client.request(
Method::GET,
self.item_url(subname, record_type)?,
ScopeSet::new(Scope::DnsApiCheap),
);
self.client.send_json_opt(req).await
}
pub async fn patch(
&self,
subname: &Subname,
record_type: &RecordType,
patch: &RrsetPatch,
) -> Result<Rrset> {
patch.validate()?;
let req = self
.client
.request(
Method::PATCH,
self.item_url(subname, record_type)?,
self.write_scope(),
)
.json(patch)?;
self.client.send_json(req).await
}
pub async fn replace(
&self,
subname: &Subname,
record_type: &RecordType,
ttl: u32,
records: impl IntoIterator<Item = impl Into<String>>,
) -> Result<Rrset> {
let body = BulkPut::new(subname.clone(), record_type.clone(), ttl, records);
body.validate()?;
let req = self
.client
.request(
Method::PUT,
self.item_url(subname, record_type)?,
self.write_scope(),
)
.json(&body)?;
self.client.send_json(req).await
}
pub async fn delete(&self, subname: &Subname, record_type: &RecordType) -> Result<()> {
let req = self.client.request(
Method::DELETE,
self.item_url(subname, record_type)?,
self.write_scope(),
);
self.client.send_empty(req).await
}
pub async fn patch_bulk(&self, patches: &[BulkPatch]) -> Result<Vec<Rrset>> {
for patch in patches {
patch.validate()?;
}
let req = self
.client
.request(Method::PATCH, self.collection_url()?, self.write_scope())
.json(patches)?;
self.client.send_json(req).await
}
pub async fn replace_bulk(&self, rrsets: &[BulkPut]) -> Result<Vec<Rrset>> {
for rrset in rrsets {
rrset.validate()?;
}
let req = self
.client
.request(Method::PUT, self.collection_url()?, self.write_scope())
.json(rrsets)?;
self.client.send_json(req).await
}
pub async fn delete_bulk(
&self,
rrsets: impl IntoIterator<Item = (Subname, RecordType)>,
) -> Result<Vec<Rrset>> {
let patches: Vec<_> = rrsets
.into_iter()
.map(|(subname, record_type)| BulkPatch::delete(subname, record_type))
.collect();
self.patch_bulk(&patches).await
}
}
impl ListRequest<Rrset> {
pub fn subname(self, subname: &Subname) -> Self {
self.with_filter("subname", subname.as_payload())
}
pub fn record_type(self, record_type: &RecordType) -> Self {
self.with_filter("type", record_type.as_str())
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
fn json<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).expect("serializes")
}
#[test]
fn a_ttl_only_patch_omits_records() {
assert_eq!(json(&RrsetPatch::new().ttl(3600)), r#"{"ttl":3600}"#);
}
#[test]
fn a_records_only_patch_omits_ttl() {
assert_eq!(
json(&RrsetPatch::new().records(["127.0.0.1"])),
r#"{"records":["127.0.0.1"]}"#
);
}
#[test]
fn no_patch_can_serialize_a_null() {
for patch in [
RrsetPatch::new(),
RrsetPatch::new().ttl(60),
RrsetPatch::new().records(Vec::<String>::new()),
] {
assert!(!json(&patch).contains("null"), "{}", json(&patch));
}
}
#[test]
fn an_empty_record_list_is_the_deletion_signal() {
assert_eq!(
json(&RrsetPatch::new().records(Vec::<String>::new())),
r#"{"records":[]}"#
);
}
#[test]
fn the_apex_serializes_as_an_empty_string_in_a_bulk_body() {
let patch = BulkPatch::delete(Subname::apex(), RecordType::A);
assert_eq!(json(&patch), r#"{"subname":"","type":"A","records":[]}"#);
}
#[test]
fn bulk_patch_always_sends_the_identifying_fields() {
let patch = BulkPatch::new("www".parse().expect("valid"), RecordType::AAAA);
assert_eq!(json(&patch), r#"{"subname":"www","type":"AAAA"}"#);
}
#[test]
fn bulk_put_sends_every_field() {
let put = BulkPut::new(
Subname::apex(),
RecordType::MX,
3600,
["10 mx.example.com."],
);
assert_eq!(
json(&put),
r#"{"subname":"","type":"MX","ttl":3600,"records":["10 mx.example.com."]}"#
);
}
#[test]
fn rejects_a_ttl_outside_the_documented_range() {
assert!(
NewRrset::at_apex(RecordType::A, 0, ["127.0.0.1"])
.validate()
.is_err()
);
assert!(
NewRrset::at_apex(RecordType::A, MAX_TTL + 1, ["127.0.0.1"])
.validate()
.is_err()
);
assert!(
NewRrset::at_apex(RecordType::A, MAX_TTL, ["127.0.0.1"])
.validate()
.is_ok()
);
}
#[test]
fn rejects_too_many_records() {
let records = vec!["127.0.0.1".to_owned(); MAX_RECORDS + 1];
assert!(
NewRrset::at_apex(RecordType::A, 3600, records)
.validate()
.is_err()
);
}
#[test]
fn an_rrset_from_the_api_round_trips_to_a_path_and_back() {
let body = r#"{
"domain": "example.com",
"subname": "",
"type": "A",
"name": "example.com.",
"records": ["127.0.0.1"],
"ttl": 3600,
"created": "2019-09-18T16:32:16.510368Z",
"touched": "2019-09-18T16:32:16.510368Z"
}"#;
let rrset: Rrset = serde_json::from_str(body).expect("valid RRset");
assert!(rrset.subname.is_apex());
assert_eq!(rrset.subname.as_path(), "@");
assert_eq!(rrset.record_type, RecordType::A);
}
}