cts_common/
region.rs

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