Skip to main content

cts_common/
region.rs

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