Skip to main content

s2_common/types/
basin.rs

1use std::{marker::PhantomData, ops::Deref, str::FromStr};
2
3use compact_str::{CompactString, ToCompactString};
4use time::OffsetDateTime;
5
6use super::{
7    ValidationError,
8    strings::{NameProps, PrefixProps, StartAfterProps, StrProps},
9};
10use crate::{caps, types::resources::ListItemsRequest};
11
12pub static BASIN_HEADER: http::HeaderName = http::HeaderName::from_static("s2-basin");
13
14#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
15#[cfg_attr(
16    feature = "rkyv",
17    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
18)]
19pub struct BasinNameStr<T: StrProps>(CompactString, PhantomData<T>);
20
21impl<T: StrProps> BasinNameStr<T> {
22    fn validate_str(name: &str) -> Result<(), ValidationError> {
23        if name.len() > caps::MAX_BASIN_NAME_LEN {
24            return Err(format!(
25                "basin {} must not exceed {} bytes in length",
26                T::FIELD_NAME,
27                caps::MAX_BASIN_NAME_LEN
28            )
29            .into());
30        }
31
32        if !T::IS_PREFIX && name.len() < caps::MIN_BASIN_NAME_LEN {
33            return Err(format!(
34                "basin {} should be at least {} bytes in length",
35                T::FIELD_NAME,
36                caps::MIN_BASIN_NAME_LEN
37            )
38            .into());
39        }
40
41        let mut chars = name.chars();
42
43        let Some(first_char) = chars.next() else {
44            return Ok(());
45        };
46
47        if !first_char.is_ascii_lowercase() && !first_char.is_ascii_digit() {
48            return Err(format!(
49                "basin {} must begin with a lowercase letter or number",
50                T::FIELD_NAME
51            )
52            .into());
53        }
54
55        if !T::IS_PREFIX
56            && let Some(last_char) = chars.next_back()
57            && !last_char.is_ascii_lowercase()
58            && !last_char.is_ascii_digit()
59        {
60            return Err(format!(
61                "basin {} must end with a lowercase letter or number",
62                T::FIELD_NAME
63            )
64            .into());
65        }
66
67        if chars.any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-') {
68            return Err(format!(
69                "basin {} must comprise lowercase letters, numbers, and hyphens",
70                T::FIELD_NAME
71            )
72            .into());
73        }
74
75        Ok(())
76    }
77}
78
79#[cfg(feature = "utoipa")]
80impl<T> utoipa::PartialSchema for BasinNameStr<T>
81where
82    T: StrProps,
83{
84    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
85        utoipa::openapi::Object::builder()
86            .schema_type(utoipa::openapi::Type::String)
87            .min_length((!T::IS_PREFIX).then_some(caps::MIN_BASIN_NAME_LEN))
88            .max_length(Some(caps::MAX_BASIN_NAME_LEN))
89            .into()
90    }
91}
92
93#[cfg(feature = "utoipa")]
94impl<T> utoipa::ToSchema for BasinNameStr<T> where T: StrProps {}
95
96impl<T: StrProps> serde::Serialize for BasinNameStr<T> {
97    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
98    where
99        S: serde::Serializer,
100    {
101        serializer.serialize_str(&self.0)
102    }
103}
104
105impl<'de, T: StrProps> serde::Deserialize<'de> for BasinNameStr<T> {
106    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
107    where
108        D: serde::Deserializer<'de>,
109    {
110        let s = CompactString::deserialize(deserializer)?;
111        s.try_into().map_err(serde::de::Error::custom)
112    }
113}
114
115impl<T: StrProps> AsRef<str> for BasinNameStr<T> {
116    fn as_ref(&self) -> &str {
117        &self.0
118    }
119}
120
121impl<T: StrProps> Deref for BasinNameStr<T> {
122    type Target = str;
123
124    fn deref(&self) -> &Self::Target {
125        &self.0
126    }
127}
128
129impl<T: StrProps> TryFrom<CompactString> for BasinNameStr<T> {
130    type Error = ValidationError;
131
132    fn try_from(name: CompactString) -> Result<Self, Self::Error> {
133        Self::validate_str(&name)?;
134        Ok(Self(name, PhantomData))
135    }
136}
137
138impl<T: StrProps> FromStr for BasinNameStr<T> {
139    type Err = ValidationError;
140
141    fn from_str(s: &str) -> Result<Self, Self::Err> {
142        Self::validate_str(s)?;
143        Ok(Self(s.to_compact_string(), PhantomData))
144    }
145}
146
147impl<T: StrProps> std::fmt::Debug for BasinNameStr<T> {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.write_str(&self.0)
150    }
151}
152
153impl<T: StrProps> std::fmt::Display for BasinNameStr<T> {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        f.write_str(&self.0)
156    }
157}
158
159impl<T: StrProps> From<BasinNameStr<T>> for CompactString {
160    fn from(value: BasinNameStr<T>) -> Self {
161        value.0
162    }
163}
164
165pub type BasinName = BasinNameStr<NameProps>;
166
167pub type BasinNamePrefix = BasinNameStr<PrefixProps>;
168
169impl Default for BasinNamePrefix {
170    fn default() -> Self {
171        BasinNameStr(CompactString::default(), PhantomData)
172    }
173}
174
175impl From<BasinName> for BasinNamePrefix {
176    fn from(value: BasinName) -> Self {
177        Self(value.0, PhantomData)
178    }
179}
180
181pub type BasinNameStartAfter = BasinNameStr<StartAfterProps>;
182
183impl Default for BasinNameStartAfter {
184    fn default() -> Self {
185        BasinNameStr(CompactString::default(), PhantomData)
186    }
187}
188
189impl From<BasinName> for BasinNameStartAfter {
190    fn from(value: BasinName) -> Self {
191        Self(value.0, PhantomData)
192    }
193}
194
195impl crate::http::ParseableHeader for BasinName {
196    fn name() -> &'static http::HeaderName {
197        &BASIN_HEADER
198    }
199}
200
201pub type ListBasinsRequest = ListItemsRequest<BasinNamePrefix, BasinNameStartAfter>;
202
203#[derive(Debug, strum::Display, Clone, Copy, PartialEq, Eq)]
204pub enum BasinScope {
205    #[strum(serialize = "aws:us-east-1")]
206    AwsUsEast1,
207}
208
209#[derive(Debug, Clone)]
210pub struct BasinInfo {
211    pub name: BasinName,
212    pub scope: Option<BasinScope>,
213    pub created_at: OffsetDateTime,
214    pub deleted_at: Option<OffsetDateTime>,
215}
216
217#[cfg(test)]
218mod test {
219    use rstest::rstest;
220
221    use super::{BasinNameStr, NameProps, PrefixProps, StartAfterProps};
222
223    #[rstest]
224    #[case::min_len("abcdefgh".to_owned())]
225    #[case::starts_with_digit("1abcdefg".to_owned())]
226    #[case::contains_hyphen("abcd-efg".to_owned())]
227    #[case::max_len("a".repeat(crate::caps::MAX_BASIN_NAME_LEN))]
228    fn validate_name_ok(#[case] name: String) {
229        assert_eq!(BasinNameStr::<NameProps>::validate_str(&name), Ok(()));
230    }
231
232    #[rstest]
233    #[case::too_long("a".repeat(crate::caps::MAX_BASIN_NAME_LEN + 1))]
234    #[case::too_short("abcdefg".to_owned())]
235    #[case::empty("".to_owned())]
236    #[case::invalid_first_char("Abcdefgh".to_owned())]
237    #[case::invalid_last_char("abcdefg-".to_owned())]
238    #[case::invalid_characters("abcd_efg".to_owned())]
239    fn validate_name_err(#[case] name: String) {
240        BasinNameStr::<NameProps>::validate_str(&name).expect_err("expected validation error");
241    }
242
243    #[rstest]
244    #[case::empty("".to_owned())]
245    #[case::single_char("a".to_owned())]
246    #[case::trailing_hyphen("abcdefg-".to_owned())]
247    #[case::max_len("a".repeat(crate::caps::MAX_BASIN_NAME_LEN))]
248    fn validate_prefix_ok(#[case] prefix: String) {
249        assert_eq!(BasinNameStr::<PrefixProps>::validate_str(&prefix), Ok(()));
250    }
251
252    #[rstest]
253    #[case::too_long("a".repeat(crate::caps::MAX_BASIN_NAME_LEN + 1))]
254    #[case::invalid_first_char("-abc".to_owned())]
255    #[case::invalid_characters("ab_cd".to_owned())]
256    fn validate_prefix_err(#[case] prefix: String) {
257        BasinNameStr::<PrefixProps>::validate_str(&prefix).expect_err("expected validation error");
258    }
259
260    #[rstest]
261    #[case::empty("".to_owned())]
262    #[case::single_char("a".to_owned())]
263    #[case::trailing_hyphen("abcdefg-".to_owned())]
264    fn validate_start_after_ok(#[case] start_after: String) {
265        assert_eq!(
266            BasinNameStr::<StartAfterProps>::validate_str(&start_after),
267            Ok(())
268        );
269    }
270
271    #[rstest]
272    #[case::too_long("a".repeat(crate::caps::MAX_BASIN_NAME_LEN + 1))]
273    #[case::invalid_first_char("-abc".to_owned())]
274    #[case::invalid_characters("ab_cd".to_owned())]
275    fn validate_start_after_err(#[case] start_after: String) {
276        BasinNameStr::<StartAfterProps>::validate_str(&start_after)
277            .expect_err("expected validation error");
278    }
279}