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#[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 #[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 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 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
232impl 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}