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::{
11    caps,
12    types::{
13        config::{BasinConfig, BasinReconfiguration},
14        resources::{ListItemsRequest, RequestToken},
15    },
16};
17
18pub static BASIN_HEADER: http::HeaderName = http::HeaderName::from_static("s2-basin");
19
20#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
21#[cfg_attr(
22    feature = "rkyv",
23    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
24)]
25pub struct BasinNameStr<T: StrProps>(CompactString, PhantomData<T>);
26
27impl<T: StrProps> BasinNameStr<T> {
28    fn validate_str(name: &str) -> Result<(), ValidationError> {
29        if name.len() > caps::MAX_BASIN_NAME_LEN {
30            return Err(format!(
31                "basin {} must not exceed {} bytes in length",
32                T::FIELD_NAME,
33                caps::MAX_BASIN_NAME_LEN
34            )
35            .into());
36        }
37
38        if !T::IS_PREFIX && name.len() < caps::MIN_BASIN_NAME_LEN {
39            return Err(format!(
40                "basin {} should be at least {} bytes in length",
41                T::FIELD_NAME,
42                caps::MIN_BASIN_NAME_LEN
43            )
44            .into());
45        }
46
47        let mut chars = name.chars();
48
49        let Some(first_char) = chars.next() else {
50            return Ok(());
51        };
52
53        if !first_char.is_ascii_lowercase() && !first_char.is_ascii_digit() {
54            return Err(format!(
55                "basin {} must begin with a lowercase letter or number",
56                T::FIELD_NAME
57            )
58            .into());
59        }
60
61        if !T::IS_PREFIX
62            && let Some(last_char) = chars.next_back()
63            && !last_char.is_ascii_lowercase()
64            && !last_char.is_ascii_digit()
65        {
66            return Err(format!(
67                "basin {} must end with a lowercase letter or number",
68                T::FIELD_NAME
69            )
70            .into());
71        }
72
73        if chars.any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-') {
74            return Err(format!(
75                "basin {} must comprise lowercase letters, numbers, and hyphens",
76                T::FIELD_NAME
77            )
78            .into());
79        }
80
81        Ok(())
82    }
83}
84
85#[cfg(feature = "utoipa")]
86impl<T> utoipa::PartialSchema for BasinNameStr<T>
87where
88    T: StrProps,
89{
90    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
91        utoipa::openapi::Object::builder()
92            .schema_type(utoipa::openapi::Type::String)
93            .min_length((!T::IS_PREFIX).then_some(caps::MIN_BASIN_NAME_LEN))
94            .max_length(Some(caps::MAX_BASIN_NAME_LEN))
95            .into()
96    }
97}
98
99#[cfg(feature = "utoipa")]
100impl<T> utoipa::ToSchema for BasinNameStr<T> where T: StrProps {}
101
102impl<T: StrProps> serde::Serialize for BasinNameStr<T> {
103    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
104    where
105        S: serde::Serializer,
106    {
107        serializer.serialize_str(&self.0)
108    }
109}
110
111impl<'de, T: StrProps> serde::Deserialize<'de> for BasinNameStr<T> {
112    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
113    where
114        D: serde::Deserializer<'de>,
115    {
116        let s = CompactString::deserialize(deserializer)?;
117        s.try_into().map_err(serde::de::Error::custom)
118    }
119}
120
121impl<T: StrProps> AsRef<str> for BasinNameStr<T> {
122    fn as_ref(&self) -> &str {
123        &self.0
124    }
125}
126
127impl<T: StrProps> Deref for BasinNameStr<T> {
128    type Target = str;
129
130    fn deref(&self) -> &Self::Target {
131        &self.0
132    }
133}
134
135impl<T: StrProps> TryFrom<CompactString> for BasinNameStr<T> {
136    type Error = ValidationError;
137
138    fn try_from(name: CompactString) -> Result<Self, Self::Error> {
139        Self::validate_str(&name)?;
140        Ok(Self(name, PhantomData))
141    }
142}
143
144impl<T: StrProps> FromStr for BasinNameStr<T> {
145    type Err = ValidationError;
146
147    fn from_str(s: &str) -> Result<Self, Self::Err> {
148        Self::validate_str(s)?;
149        Ok(Self(s.to_compact_string(), PhantomData))
150    }
151}
152
153impl<T: StrProps> std::fmt::Debug 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> std::fmt::Display for BasinNameStr<T> {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.write_str(&self.0)
162    }
163}
164
165impl<T: StrProps> From<BasinNameStr<T>> for CompactString {
166    fn from(value: BasinNameStr<T>) -> Self {
167        value.0
168    }
169}
170
171pub type BasinName = BasinNameStr<NameProps>;
172
173pub type BasinNamePrefix = BasinNameStr<PrefixProps>;
174
175impl Default for BasinNamePrefix {
176    fn default() -> Self {
177        BasinNameStr(CompactString::default(), PhantomData)
178    }
179}
180
181impl From<BasinName> for BasinNamePrefix {
182    fn from(value: BasinName) -> Self {
183        Self(value.0, PhantomData)
184    }
185}
186
187pub type BasinNameStartAfter = BasinNameStr<StartAfterProps>;
188
189impl Default for BasinNameStartAfter {
190    fn default() -> Self {
191        BasinNameStr(CompactString::default(), PhantomData)
192    }
193}
194
195impl From<BasinName> for BasinNameStartAfter {
196    fn from(value: BasinName) -> Self {
197        Self(value.0, PhantomData)
198    }
199}
200
201impl crate::http::ParseableHeader for BasinName {
202    fn name() -> &'static http::HeaderName {
203        &BASIN_HEADER
204    }
205}
206
207pub type ListBasinsRequest = ListItemsRequest<BasinNamePrefix, BasinNameStartAfter>;
208
209#[derive(
210    Debug, strum::Display, strum::EnumString, strum::IntoStaticStr, Clone, Copy, PartialEq, Eq,
211)]
212pub enum BasinScope {
213    #[strum(serialize = "aws:us-east-1")]
214    AwsUsEast1,
215    #[strum(serialize = "aws:us-west-2")]
216    AwsUsWest2,
217    #[strum(serialize = "aws:eu-north-1")]
218    AwsEuNorth1,
219}
220
221#[derive(Debug, Clone)]
222pub struct BasinInfo {
223    pub name: BasinName,
224    pub scope: Option<BasinScope>,
225    pub created_at: OffsetDateTime,
226    pub deleted_at: Option<OffsetDateTime>,
227}
228
229/// Basin creation operation intent.
230///
231/// Separates POST-style create-only requests, which carry a complete creation config and optional
232/// idempotency token, from PUT-style create-or-reconfigure requests, which carry only a
233/// reconfiguration patch.
234#[derive(Debug)]
235pub enum CreateBasinIntent {
236    /// Create a new basin.
237    ///
238    /// HTTP POST semantics: idempotent if a request token is provided and the basin was previously
239    /// created using the same token and config.
240    CreateOnly {
241        /// Complete basin configuration for a new basin.
242        config: BasinConfig,
243        /// Optional request token used to make create retries idempotent.
244        request_token: Option<RequestToken>,
245    },
246    /// Create a new basin or reconfigure it if it already exists.
247    ///
248    /// HTTP PUT semantics: always idempotent. When the basin already exists, unspecified fields in
249    /// the reconfiguration preserve the existing config.
250    CreateOrReconfigure {
251        /// Basin reconfiguration patch to apply on create-or-reconfigure.
252        reconfiguration: BasinReconfiguration,
253    },
254}
255
256#[cfg(test)]
257mod test {
258    use rstest::rstest;
259
260    use super::{BasinNameStr, NameProps, PrefixProps, StartAfterProps};
261
262    #[rstest]
263    #[case::min_len("abcdefgh".to_owned())]
264    #[case::starts_with_digit("1abcdefg".to_owned())]
265    #[case::contains_hyphen("abcd-efg".to_owned())]
266    #[case::max_len("a".repeat(crate::caps::MAX_BASIN_NAME_LEN))]
267    fn validate_name_ok(#[case] name: String) {
268        assert_eq!(BasinNameStr::<NameProps>::validate_str(&name), Ok(()));
269    }
270
271    #[rstest]
272    #[case::too_long("a".repeat(crate::caps::MAX_BASIN_NAME_LEN + 1))]
273    #[case::too_short("abcdefg".to_owned())]
274    #[case::empty("".to_owned())]
275    #[case::invalid_first_char("Abcdefgh".to_owned())]
276    #[case::invalid_last_char("abcdefg-".to_owned())]
277    #[case::invalid_characters("abcd_efg".to_owned())]
278    fn validate_name_err(#[case] name: String) {
279        BasinNameStr::<NameProps>::validate_str(&name).expect_err("expected validation error");
280    }
281
282    #[rstest]
283    #[case::empty("".to_owned())]
284    #[case::single_char("a".to_owned())]
285    #[case::trailing_hyphen("abcdefg-".to_owned())]
286    #[case::max_len("a".repeat(crate::caps::MAX_BASIN_NAME_LEN))]
287    fn validate_prefix_ok(#[case] prefix: String) {
288        assert_eq!(BasinNameStr::<PrefixProps>::validate_str(&prefix), Ok(()));
289    }
290
291    #[rstest]
292    #[case::too_long("a".repeat(crate::caps::MAX_BASIN_NAME_LEN + 1))]
293    #[case::invalid_first_char("-abc".to_owned())]
294    #[case::invalid_characters("ab_cd".to_owned())]
295    fn validate_prefix_err(#[case] prefix: String) {
296        BasinNameStr::<PrefixProps>::validate_str(&prefix).expect_err("expected validation error");
297    }
298
299    #[rstest]
300    #[case::empty("".to_owned())]
301    #[case::single_char("a".to_owned())]
302    #[case::trailing_hyphen("abcdefg-".to_owned())]
303    fn validate_start_after_ok(#[case] start_after: String) {
304        assert_eq!(
305            BasinNameStr::<StartAfterProps>::validate_str(&start_after),
306            Ok(())
307        );
308    }
309
310    #[rstest]
311    #[case::too_long("a".repeat(crate::caps::MAX_BASIN_NAME_LEN + 1))]
312    #[case::invalid_first_char("-abc".to_owned())]
313    #[case::invalid_characters("ab_cd".to_owned())]
314    fn validate_start_after_err(#[case] start_after: String) {
315        BasinNameStr::<StartAfterProps>::validate_str(&start_after)
316            .expect_err("expected validation error");
317    }
318}