diff --git a/Cargo.toml b/Cargo.toml index b1be495..6162123 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ redis = [] scylladb = [] selenium = [] rustfs = [] +s3mock = ["http_wait"] solr = [] surrealdb = [] trufflesuite_ganachecli = [] diff --git a/src/lib.rs b/src/lib.rs index 1cc582a..3fdafc6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -181,6 +181,10 @@ pub mod rqlite; #[cfg_attr(docsrs, doc(cfg(feature = "rustfs")))] /// **RustFS** (S3-compatible distributed storage) testcontainer pub mod rustfs; +#[cfg(feature = "s3mock")] +#[cfg_attr(docsrs, doc(cfg(feature = "s3mock")))] +/// **S3Mock** (S3-compatible object storage mock) testcontainer +pub mod s3mock; #[cfg(feature = "scylladb")] #[cfg_attr(docsrs, doc(cfg(feature = "scylladb")))] /// **scylladb** (distributed NoSQL wide-column data store) testcontainer diff --git a/src/s3mock/mod.rs b/src/s3mock/mod.rs new file mode 100644 index 0000000..a155b20 --- /dev/null +++ b/src/s3mock/mod.rs @@ -0,0 +1,138 @@ +use testcontainers::{ + core::{wait::HttpWaitStrategy, ContainerPort, WaitFor}, + Image, +}; + +const NAME: &str = "adobe/s3mock"; +const TAG: &str = "5.1.0"; + +/// Port that [`S3Mock`] uses internally for its HTTP S3 API. +pub const S3MOCK_PORT: ContainerPort = ContainerPort::Tcp(9090); + +/// Module to work with [`S3Mock`] inside of tests. +/// +/// Starts an instance of S3Mock based on the official [`S3Mock docker image`]. +/// +/// S3Mock is an S3-compatible object storage mock intended for integration tests. +/// The container exposes port `9090` for the S3 API by default. +/// +/// # Example +/// ``` +/// use testcontainers_modules::{ +/// s3mock::{S3Mock, S3MOCK_PORT}, +/// testcontainers::runners::AsyncRunner, +/// }; +/// +/// # #[tokio::main] +/// # async fn main() -> Result<(), Box> { +/// let s3mock_instance = S3Mock::default().start().await?; +/// let host = s3mock_instance.get_host().await?; +/// let port = s3mock_instance.get_host_port_ipv4(S3MOCK_PORT).await?; +/// +/// // Use the S3-compatible API at http://{host}:{port} +/// # Ok(()) +/// # } +/// ``` +/// +/// [`S3Mock docker image`]: https://hub.docker.com/r/adobe/s3mock +#[derive(Debug, Default, Clone)] +pub struct S3Mock { + _priv: (), +} + +impl Image for S3Mock { + fn name(&self) -> &str { + NAME + } + + fn tag(&self) -> &str { + TAG + } + + fn ready_conditions(&self) -> Vec { + vec![WaitFor::http( + HttpWaitStrategy::new("/favicon.ico") + .with_port(S3MOCK_PORT) + .with_expected_status_code(200_u16), + )] + } + + fn expose_ports(&self) -> &[ContainerPort] { + &[S3MOCK_PORT] + } +} + +#[cfg(test)] +mod tests { + use aws_config::{meta::region::RegionProviderChain, BehaviorVersion}; + use aws_sdk_s3::{config::Credentials, primitives::ByteStream, Client}; + use testcontainers::runners::AsyncRunner; + + use crate::s3mock::{S3Mock, S3MOCK_PORT}; + + #[tokio::test] + async fn supports_bucket_and_object_lifecycle() -> Result<(), Box> { + let node = S3Mock::default().start().await?; + let host_port = node.get_host_port_ipv4(S3MOCK_PORT).await?; + let client = build_s3_client(host_port).await; + + let bucket = "test-bucket"; + let key = "test-object"; + let content = b"s3mock content"; + + client.create_bucket().bucket(bucket).send().await?; + client + .put_object() + .bucket(bucket) + .key(key) + .body(ByteStream::from_static(content)) + .send() + .await?; + + let object = client + .get_object() + .bucket(bucket) + .key(key) + .send() + .await? + .body + .collect() + .await? + .into_bytes(); + assert_eq!(object.as_ref(), content); + + let objects = client.list_objects_v2().bucket(bucket).send().await?; + assert_eq!(objects.contents().len(), 1); + assert_eq!(objects.contents()[0].key(), Some(key)); + + client + .delete_object() + .bucket(bucket) + .key(key) + .send() + .await?; + let objects = client.list_objects_v2().bucket(bucket).send().await?; + assert!(objects.contents().is_empty()); + + client.delete_bucket().bucket(bucket).send().await?; + + Ok(()) + } + + async fn build_s3_client(host_port: u16) -> Client { + let endpoint_uri = format!("http://127.0.0.1:{host_port}"); + let region_provider = RegionProviderChain::default_provider().or_else("us-east-1"); + let credentials = Credentials::new("test", "test", None, None, "test"); + let shared_config = aws_config::defaults(BehaviorVersion::latest()) + .region(region_provider) + .endpoint_url(endpoint_uri) + .credentials_provider(credentials) + .load() + .await; + let s3_config = aws_sdk_s3::config::Builder::from(&shared_config) + .force_path_style(true) + .build(); + + Client::from_conf(s3_config) + } +}