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