dinio_sdk/ops/geo_bans.rs
1//! Geo bans of one record value, addressed by area (`HR`, `Europe`) instead of ban uuid.
2//!
3//! The functions work on a [`RecordValue`] read together with its bans, i.e. from
4//! [`find_value_with_geo_bans`](crate::ops::find_value_with_geo_bans) or
5//! [`Records::get`](crate::api::Records::get) with `with_geo_bans = true`. Choosing what to
6//! remove ([`select_geo_bans`]) is separate from removing it ([`remove_geo_bans`]), so a
7//! caller can show the selection and ask for confirmation in between.
8
9use uuid::Uuid;
10
11use crate::client::Client;
12use crate::error::{Error, Result};
13use crate::models::{GeoBan, RecordValue};
14use crate::ops::GeoArea;
15
16/// What [`add_geo_bans`] did.
17#[derive(Debug, Clone)]
18pub struct AddedGeoBans {
19 /// Bans created by this call, in the order the areas were given.
20 pub created: Vec<GeoBan>,
21 /// Areas that were already banned (or given more than once) and were left alone.
22 pub skipped: Vec<GeoArea>,
23}
24
25/// Which bans of a value [`select_geo_bans`] picks.
26#[derive(Debug, Clone)]
27pub enum GeoBanSelector {
28 /// Every ban of the value.
29 All,
30 /// The ban of each area; every area must be banned.
31 Areas(Vec<GeoArea>),
32 /// One ban by its uuid.
33 Id(Uuid),
34}
35
36/// Bans `areas` for `value`, skipping areas that are already banned.
37///
38/// Idempotent: calling it again with the same areas creates nothing and reports them all
39/// as skipped. Not atomic: one `POST /api/v1/geo-bans` per new area, and on an error midway
40/// the bans already created stay. Calling it again then finishes the job.
41///
42/// # Errors
43///
44/// * [`Error::InvalidInput`] if `value` was read without its geo bans (`geo_bans` is
45/// `None`), because existing bans could not be skipped.
46/// * Any error of [`GeoBans::create`](crate::api::GeoBans::create), e.g.
47/// [`ErrorCode::Forbidden`](crate::ErrorCode::Forbidden) if the key may not write the zone.
48///
49/// # Examples
50///
51/// ```no_run
52/// use dinio_sdk::ops::{self, ValueSelector};
53///
54/// # async fn example(client: &dinio_sdk::Client, zone: &dinio_sdk::models::Zone) -> dinio_sdk::Result<()> {
55/// let catalog = client.geo().catalog().await?;
56/// let areas = [catalog.parse_area("HR")?, catalog.parse_area("Europe")?];
57/// let selector = ValueSelector::Data("192.0.2.10".into());
58/// let (_, value) =
59/// ops::find_value_with_geo_bans(client, zone, "www.example.com", "A", &selector).await?;
60///
61/// let added = ops::add_geo_bans(client, &value, &areas).await?;
62/// println!("{} created, {} already banned", added.created.len(), added.skipped.len());
63/// # Ok(())
64/// # }
65/// ```
66pub async fn add_geo_bans(
67 client: &Client,
68 value: &RecordValue,
69 areas: &[GeoArea],
70) -> Result<AddedGeoBans> {
71 let (new, skipped) = split_new_areas(current_bans(value)?, areas);
72 let mut created = Vec::with_capacity(new.len());
73 for area in new {
74 created.push(client.geo_bans().create(value.uuid, area).await?);
75 }
76 Ok(AddedGeoBans { created, skipped })
77}
78
79/// Picks bans of `value` without changing anything. Each ban is returned once, even if
80/// its area is given more than once.
81///
82/// # Errors
83///
84/// * [`Error::InvalidInput`] if `value` was read without its geo bans.
85/// * [`Error::NotFound`] if an area is not banned or no ban has the uuid. Nothing is
86/// returned then, so a typo cannot lead to removing only part of the selection.
87///
88/// # Examples
89///
90/// ```
91/// use dinio_sdk::models::{GeoCatalog, RecordValue};
92/// use dinio_sdk::ops::{self, GeoBanSelector};
93///
94/// # fn example(value: &RecordValue, catalog: &GeoCatalog) -> dinio_sdk::Result<()> {
95/// let selector = GeoBanSelector::Areas(vec![catalog.parse_area("HR")?]);
96/// for ban in ops::select_geo_bans(value, &selector)? {
97/// println!("would remove {}", ban.describe(catalog));
98/// }
99/// # Ok(())
100/// # }
101/// ```
102pub fn select_geo_bans(value: &RecordValue, selector: &GeoBanSelector) -> Result<Vec<GeoBan>> {
103 let bans = current_bans(value)?;
104 let selected = match selector {
105 GeoBanSelector::All => bans.to_vec(),
106 GeoBanSelector::Id(id) => {
107 let ban = bans
108 .iter()
109 .find(|b| b.uuid == *id)
110 .ok_or_else(|| Error::NotFound(format!("{} has no geo ban {id}", label(value))))?;
111 vec![ban.clone()]
112 }
113 GeoBanSelector::Areas(areas) => {
114 let mut selected: Vec<GeoBan> = Vec::with_capacity(areas.len());
115 for area in areas {
116 let ban = bans
117 .iter()
118 .find(|b| b.area() == Some(*area))
119 .ok_or_else(|| {
120 Error::NotFound(format!("{area} is not banned for {}", label(value)))
121 })?;
122 if !selected.iter().any(|s| s.uuid == ban.uuid) {
123 selected.push(ban.clone());
124 }
125 }
126 selected
127 }
128 };
129 Ok(selected)
130}
131
132/// Removes `bans` of `value`, usually the result of [`select_geo_bans`].
133///
134/// Each delete carries `value`'s uuid, and Core refuses a ban that belongs to another
135/// value. Not atomic: on an error midway the bans already removed stay removed.
136///
137/// # Errors
138///
139/// Any error of [`GeoBans::delete`](crate::api::GeoBans::delete), e.g.
140/// [`ErrorCode::NotFound`](crate::ErrorCode::NotFound) if a ban was removed meanwhile.
141///
142/// # Examples
143///
144/// ```no_run
145/// use dinio_sdk::ops::{self, GeoBanSelector};
146///
147/// # async fn example(client: &dinio_sdk::Client, value: &dinio_sdk::models::RecordValue) -> dinio_sdk::Result<()> {
148/// let bans = ops::select_geo_bans(value, &GeoBanSelector::All)?;
149/// ops::remove_geo_bans(client, value, &bans).await?;
150/// # Ok(())
151/// # }
152/// ```
153pub async fn remove_geo_bans(client: &Client, value: &RecordValue, bans: &[GeoBan]) -> Result<()> {
154 for ban in bans {
155 client.geo_bans().delete(ban.uuid, Some(value.uuid)).await?;
156 }
157 Ok(())
158}
159
160fn current_bans(value: &RecordValue) -> Result<&[GeoBan]> {
161 value.geo_bans.as_deref().ok_or_else(|| {
162 Error::InvalidInput(format!(
163 "{} was read without its geo bans; read it with find_value_with_geo_bans",
164 label(value)
165 ))
166 })
167}
168
169/// Splits `wanted` into areas to ban and areas to skip (already banned or repeated).
170fn split_new_areas(bans: &[GeoBan], wanted: &[GeoArea]) -> (Vec<GeoArea>, Vec<GeoArea>) {
171 let mut banned: Vec<GeoArea> = bans.iter().filter_map(GeoBan::area).collect();
172 let mut new = Vec::new();
173 let mut skipped = Vec::new();
174 for &area in wanted {
175 if banned.contains(&area) {
176 skipped.push(area);
177 } else {
178 banned.push(area);
179 new.push(area);
180 }
181 }
182 (new, skipped)
183}
184
185fn label(value: &RecordValue) -> String {
186 format!("{} {} {}", value.name, value.record_type, value.data)
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::ops::GeoLevel;
193
194 const HR: GeoArea = GeoArea {
195 level: GeoLevel::Country,
196 code: 191,
197 };
198 const DE: GeoArea = GeoArea {
199 level: GeoLevel::Country,
200 code: 276,
201 };
202 const EUROPE: GeoArea = GeoArea {
203 level: GeoLevel::Region,
204 code: 150,
205 };
206
207 fn ban(area: GeoArea) -> GeoBan {
208 GeoBan {
209 uuid: Uuid::from_u128(u128::from(area.code)),
210 record_id: Uuid::nil(),
211 level: area.level.as_wire().to_owned(),
212 code: u32::from(area.code),
213 created_at: 0,
214 updated_at: 0,
215 }
216 }
217
218 fn value(bans: Option<Vec<GeoBan>>) -> RecordValue {
219 RecordValue {
220 uuid: Uuid::nil(),
221 zone_id: Uuid::nil(),
222 name: "www.example.com".into(),
223 record_type: "A".into(),
224 ttl: 300,
225 data: "192.0.2.10".into(),
226 geo_routing: None,
227 balance_ratio: None,
228 host_health: "None".into(),
229 managed_by: None,
230 created_at: 0,
231 updated_at: 0,
232 geo_bans: bans,
233 }
234 }
235
236 #[test]
237 fn split_skips_banned_and_repeated_areas() {
238 let (new, skipped) = split_new_areas(&[ban(HR)], &[HR, DE, DE, EUROPE]);
239 assert_eq!(new, vec![DE, EUROPE]);
240 assert_eq!(skipped, vec![HR, DE]);
241 }
242
243 #[test]
244 fn select_by_areas_returns_each_ban_once() {
245 let v = value(Some(vec![ban(HR), ban(DE)]));
246 let selected = select_geo_bans(&v, &GeoBanSelector::Areas(vec![HR, HR])).unwrap();
247 assert_eq!(selected.len(), 1);
248 assert_eq!(selected[0].area(), Some(HR));
249 }
250
251 #[test]
252 fn select_fails_whole_if_one_area_is_not_banned() {
253 let v = value(Some(vec![ban(HR)]));
254 let err = select_geo_bans(&v, &GeoBanSelector::Areas(vec![HR, DE])).unwrap_err();
255 assert!(matches!(err, Error::NotFound(_)));
256 }
257
258 #[test]
259 fn select_all_and_by_id() {
260 let v = value(Some(vec![ban(HR), ban(DE)]));
261 assert_eq!(select_geo_bans(&v, &GeoBanSelector::All).unwrap().len(), 2);
262 let id = ban(DE).uuid;
263 let selected = select_geo_bans(&v, &GeoBanSelector::Id(id)).unwrap();
264 assert_eq!(selected[0].uuid, id);
265 assert!(select_geo_bans(&v, &GeoBanSelector::Id(Uuid::max())).is_err());
266 }
267
268 #[test]
269 fn value_without_bans_loaded_is_rejected() {
270 let v = value(None);
271 assert!(matches!(
272 select_geo_bans(&v, &GeoBanSelector::All),
273 Err(Error::InvalidInput(_))
274 ));
275 }
276}