cts_common/
region.rs

1use fake::{Dummy, Faker};
2use serde::{Deserialize, Serialize};
3use std::fmt::Display;
4use std::str::FromStr;
5use thiserror::Error;
6
7/// Defines the region of a CipherStash service.
8/// A region in CipherStash is defined by the region identifier and the provider separated by a dot.
9/// For example, `us-west-2.aws` is a valid region identifier and refers to the AWS region `us-west-2`.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(untagged)]
12pub enum Region {
13    #[serde(
14        serialize_with = "AwsRegion::serialize_with_suffix",
15        deserialize_with = "AwsRegion::deserialize_with_suffix"
16    )]
17    Aws(AwsRegion),
18}
19
20impl FromStr for Region {
21    type Err = String;
22
23    fn from_str(s: &str) -> Result<Self, Self::Err> {
24        Region::new(s).map_err(|e| e.to_string())
25    }
26}
27
28impl Display for Region {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Region::Aws(region) => write!(f, "{}", region),
32        }
33    }
34}
35
36#[derive(Debug, Error)]
37pub enum RegionError {
38    #[error("Invalid region: {0}")]
39    InvalidRegion(String),
40
41    #[error("Host or endpoint does not contain a valid region: `{0}`")]
42    InvalidHostFqdn(String),
43}
44
45impl Region {
46    pub fn all() -> Vec<Self> {
47        AwsRegion::all().into_iter().map(Self::Aws).collect()
48    }
49
50    /// Creates a new region from an identifier.
51    /// Region identifiers are in the format `<region>.<provider>`.
52    ///
53    /// For example, `us-west-2.aws` is a valid region identifier.
54    ///
55    /// # Example
56    ///
57    /// ```
58    /// use cts_common::{AwsRegion, Region};
59    /// let region = Region::new("us-west-2.aws").unwrap();
60    /// assert_eq!(region, Region::Aws(AwsRegion::UsWest2));
61    /// ```
62    ///
63    pub fn new(identifier: &str) -> Result<Self, RegionError> {
64        if identifier.ends_with(".aws") {
65            let region = identifier.trim_end_matches(".aws");
66            Self::aws(region)
67        } else {
68            Err(RegionError::InvalidRegion(format!(
69                "Missing provider (e.g. '.aws' suffix on '{identifier}')"
70            )))
71        }
72    }
73
74    /// Creates a new AWS region from an identifier.
75    /// Note that this is not the complete list of AWS regions, only the ones that are currently supported by CipherStash.
76    ///
77    /// # Example
78    ///
79    /// ```
80    /// use cts_common::{Region, AwsRegion};
81    /// let region = Region::aws("us-west-2").unwrap();
82    /// assert_eq!(region, Region::Aws(AwsRegion::UsWest2));
83    /// ```
84    ///
85    pub fn aws(identifier: &str) -> Result<Self, RegionError> {
86        AwsRegion::try_from(identifier).map(Self::Aws)
87    }
88
89    pub fn identifier(&self) -> String {
90        match self {
91            Region::Aws(region) => format!("{}.aws", region.identifier()),
92        }
93    }
94}
95
96#[cfg(feature = "test_utils")]
97impl Dummy<Faker> for Region {
98    fn dummy_with_rng<R>(_: &Faker, rng: &mut R) -> Self
99    where
100        R: rand::Rng + ?Sized,
101    {
102        let aws_regions = AwsRegion::all();
103        let choice = rng.gen_range(0..aws_regions.len());
104        Region::Aws(aws_regions[choice])
105    }
106}
107
108#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "kebab-case")]
110pub enum AwsRegion {
111    ApSoutheast2,
112    EuCentral1,
113    EuWest1,
114    UsEast1,
115    UsEast2,
116    UsWest1,
117    UsWest2,
118}
119
120impl AwsRegion {
121    pub fn all() -> Vec<Self> {
122        vec![
123            Self::ApSoutheast2,
124            Self::EuCentral1,
125            Self::EuWest1,
126            Self::UsEast1,
127            Self::UsEast2,
128            Self::UsWest1,
129            Self::UsWest2,
130        ]
131    }
132
133    pub fn identifier(&self) -> &'static str {
134        match self {
135            Self::ApSoutheast2 => "ap-southeast-2",
136            Self::EuCentral1 => "eu-central-1",
137            Self::EuWest1 => "eu-west-1",
138            Self::UsEast1 => "us-east-1",
139            Self::UsEast2 => "us-east-2",
140            Self::UsWest1 => "us-west-1",
141            Self::UsWest2 => "us-west-2",
142        }
143    }
144
145    pub fn serialize_with_suffix<S>(region: &AwsRegion, serializer: S) -> Result<S::Ok, S::Error>
146    where
147        S: serde::Serializer,
148    {
149        serializer.serialize_str(&format!("{}.aws", region.identifier()))
150    }
151
152    pub fn deserialize_with_suffix<'de, D>(deserializer: D) -> Result<AwsRegion, D::Error>
153    where
154        D: serde::Deserializer<'de>,
155    {
156        let region = String::deserialize(deserializer)?;
157        region
158            .trim_end_matches(".aws")
159            .try_into()
160            .map_err(serde::de::Error::custom)
161    }
162}
163
164impl TryFrom<&str> for AwsRegion {
165    type Error = RegionError;
166
167    fn try_from(value: &str) -> Result<Self, Self::Error> {
168        match value {
169            "ap-southeast-2" => Ok(AwsRegion::ApSoutheast2),
170            "eu-central-1" => Ok(AwsRegion::EuCentral1),
171            "eu-west-1" => Ok(AwsRegion::EuWest1),
172            "us-east-1" => Ok(AwsRegion::UsEast1),
173            "us-east-2" => Ok(AwsRegion::UsEast2),
174            "us-west-1" => Ok(AwsRegion::UsWest1),
175            "us-west-2" => Ok(AwsRegion::UsWest2),
176
177            _ => Err(RegionError::InvalidRegion(value.to_string())),
178        }
179    }
180}
181
182impl Display for AwsRegion {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        write!(f, "{}", self.identifier())
185    }
186}
187
188#[cfg(test)]
189mod test {
190    use super::*;
191
192    #[test]
193    fn test_region_new() {
194        assert_eq!(
195            Region::new("us-west-1.aws").unwrap(),
196            Region::Aws(AwsRegion::UsWest1)
197        );
198        assert_eq!(
199            Region::new("us-west-2.aws").unwrap(),
200            Region::Aws(AwsRegion::UsWest2)
201        );
202        assert_eq!(
203            Region::new("us-east-1.aws").unwrap(),
204            Region::Aws(AwsRegion::UsEast1)
205        );
206        assert_eq!(
207            Region::new("us-east-2.aws").unwrap(),
208            Region::Aws(AwsRegion::UsEast2)
209        );
210        assert_eq!(
211            Region::new("eu-west-1.aws").unwrap(),
212            Region::Aws(AwsRegion::EuWest1)
213        );
214        assert_eq!(
215            Region::new("eu-central-1.aws").unwrap(),
216            Region::Aws(AwsRegion::EuCentral1)
217        );
218        assert_eq!(
219            Region::new("ap-southeast-2.aws").unwrap(),
220            Region::Aws(AwsRegion::ApSoutheast2)
221        );
222    }
223
224    #[test]
225    fn test_region_new_invalid() {
226        let region = Region::new("us-west-2");
227        assert!(region.is_err());
228    }
229
230    #[test]
231    fn test_region_aws() {
232        let region = Region::aws("us-west-2").unwrap();
233        assert_eq!(region, Region::Aws(AwsRegion::UsWest2));
234    }
235
236    #[test]
237    fn test_region_aws_invalid() {
238        let region = Region::aws("us-west-3");
239        assert!(region.is_err());
240    }
241
242    #[test]
243    fn test_region_identifier() {
244        let region = Region::aws("us-west-2").unwrap();
245        assert_eq!(region.identifier(), "us-west-2.aws");
246    }
247
248    #[test]
249    fn test_region_from_string() {
250        let region = Region::from_str("us-west-2.aws").unwrap();
251        assert_eq!(region, Region::Aws(AwsRegion::UsWest2));
252
253        let region = Region::from_str("ap-southeast-2.aws").unwrap();
254        assert_eq!(region, Region::Aws(AwsRegion::ApSoutheast2));
255    }
256
257    #[test]
258    fn test_region_from_string_invalid_provider() {
259        let region = Region::from_str("us-west-2.gcp");
260        assert_eq!(
261            region,
262            Err(
263                "Invalid region: Missing provider (e.g. '.aws' suffix on 'us-west-2.gcp')"
264                    .to_string()
265            )
266        );
267    }
268
269    #[test]
270    fn test_region_from_string_invalid_region() {
271        let region = Region::from_str("us-invalid-2.aws");
272        assert_eq!(region, Err("Invalid region: us-invalid-2".to_string()));
273    }
274
275    mod aws {
276        use super::*;
277
278        #[test]
279        fn test_aws_region_identifier() {
280            assert_eq!(AwsRegion::UsWest1.identifier(), "us-west-1");
281            assert_eq!(AwsRegion::UsWest2.identifier(), "us-west-2");
282            assert_eq!(AwsRegion::UsEast1.identifier(), "us-east-1");
283            assert_eq!(AwsRegion::UsEast2.identifier(), "us-east-2");
284            assert_eq!(AwsRegion::EuWest1.identifier(), "eu-west-1");
285            assert_eq!(AwsRegion::EuCentral1.identifier(), "eu-central-1");
286            assert_eq!(AwsRegion::ApSoutheast2.identifier(), "ap-southeast-2");
287        }
288    }
289}