cobalt_aws/s3/
s3_object.rs

1// Standard library imports
2use std::str::FromStr;
3
4// External crates
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8#[derive(Debug, thiserror::Error)]
9#[allow(clippy::enum_variant_names)]
10pub enum S3ObjectError {
11    #[error("S3 URL must have a scheme of s3")]
12    UrlSchemeError,
13    #[error("S3 URL must have a host")]
14    UrlHostError,
15    #[error("S3 URL must have a path")]
16    UrlPathError,
17    #[error(transparent)]
18    UrlParseError(#[from] url::ParseError),
19}
20
21/// A bucket key pair for a S3Object, with conversion from S3 urls.
22#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
23pub struct S3Object {
24    /// The bucket the object is in.
25    pub bucket: String,
26    /// The key the in the bucket for the object.
27    pub key: String,
28}
29
30impl S3Object {
31    /// Create a new [S3Object] using anything which can be
32    /// treated as [&str].  Any leading `/` will be trimmed from
33    /// the key.  No validation is done against the bucket or key
34    /// to ensure they meet the AWS requirements.
35    pub fn new(bucket: impl AsRef<str>, key: impl AsRef<str>) -> Self {
36        S3Object {
37            bucket: bucket.as_ref().to_owned(),
38            key: key.as_ref().trim_start_matches('/').to_owned(),
39        }
40    }
41}
42
43/// Convert from an [Url] into a [S3Object]. The scheme
44/// must be `s3` and the `path` must not be empty.
45impl TryFrom<Url> for S3Object {
46    type Error = S3ObjectError;
47
48    fn try_from(value: Url) -> Result<Self, Self::Error> {
49        if value.scheme() != "s3" {
50            return Err(S3ObjectError::UrlSchemeError);
51        }
52        let bucket = value.host_str().ok_or(S3ObjectError::UrlHostError)?;
53        let key = value.path();
54
55        if key.is_empty() {
56            return Err(S3ObjectError::UrlPathError);
57        }
58        Ok(S3Object::new(bucket, key))
59    }
60}
61
62/// Convert from a [&str] into a [S3Object].
63/// The [&str] must be a valid `S3` [Url].
64impl TryFrom<&str> for S3Object {
65    type Error = S3ObjectError;
66
67    fn try_from(value: &str) -> Result<Self, Self::Error> {
68        value.parse::<Url>()?.try_into()
69    }
70}
71
72/// Convert from [String] into a [S3Object].
73/// The [String] must be a valid `S3` [Url].
74impl TryFrom<String> for S3Object {
75    type Error = S3ObjectError;
76
77    fn try_from(value: String) -> Result<Self, Self::Error> {
78        value.parse::<Url>()?.try_into()
79    }
80}
81
82/// Convert from [&str] into a [S3Object].
83/// The [&str] must be a valid `S3` [Url].
84impl FromStr for S3Object {
85    type Err = S3ObjectError;
86
87    fn from_str(value: &str) -> Result<Self, Self::Err> {
88        value.parse::<Url>()?.try_into()
89    }
90}
91
92/// Converts from a [S3Object] into a [Url].
93/// If the [S3Object] holds an invalid path or
94/// domain the conversion will fail.
95impl TryFrom<S3Object> for Url {
96    type Error = url::ParseError;
97    fn try_from(obj: S3Object) -> std::result::Result<Self, Self::Error> {
98        Url::parse(&format!("s3://{}/{}", obj.bucket, obj.key))
99    }
100}
101
102/// Converts from a [S3Object] into a [Url].
103/// If the [S3Object] holds an invalid path or
104/// domain the conversion will fail.
105impl TryFrom<&S3Object> for Url {
106    type Error = url::ParseError;
107    fn try_from(obj: &S3Object) -> std::result::Result<Self, Self::Error> {
108        Url::parse(&format!("s3://{}/{}", obj.bucket, obj.key))
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_s3_tryfrom() {
118        let bucket = "test-bucket.to_owned()";
119        let key = "test-key";
120        let url: url::Url = format!("s3://{bucket}/{key}")
121            .parse()
122            .expect("Expected successful URL parsing");
123        let obj: S3Object = url.try_into().expect("Expected successful URL conversion");
124        assert_eq!(bucket, obj.bucket);
125        assert_eq!(key, obj.key);
126    }
127
128    #[test]
129    fn test_s3_tryfrom_no_path() {
130        let url: url::Url = "s3://test-bucket"
131            .parse()
132            .expect("Expected successful URL parsing");
133        let result: Result<S3Object, S3ObjectError> = url.try_into();
134        assert!(result.is_err())
135    }
136
137    #[test]
138    fn test_s3_tryfrom_file_url() {
139        let url: url::Url = "file://path/to/file"
140            .parse()
141            .expect("Expected successful URL parsing");
142        let result: Result<S3Object, S3ObjectError> = url.try_into();
143        assert!(result.is_err())
144    }
145}