Skip to main content

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}