Skip to content

Amazon S3

Overview

The cloudstub-s3 module mocks Amazon Simple Storage Service. Its operation set is generated from the AWS S3 Smithy model; AWS SDK v2 drives it with the REST path protocol (each operation is matched by HTTP method and a path regex).

The bucket and object operations, and object/bucket tagging, are state-backed: an object written with PutObject is returned by a later GetObject, reflected in ListObjectsV2/ListObjects, and removed by DeleteObject; tags set with PutObjectTagging are returned by GetObjectTagging. State survives a restart when a persistent store directory is configured, and is visible through the REST API. The remaining sub-resource operations (ACLs, lifecycle, multipart, and similar) are registered from the model and return well-formed but stateless placeholder responses. See Supported operations for the full list.

Addressing

A production-faithful S3Client uses virtual-hosted-style addressing by default (Host: {bucket}.s3…, path /{key}), while CloudStub's stubs route on the path (/{bucket}/{key}). cloudstub-s3 bridges this transparently: it ships an AWS SDK v2 ExecutionInterceptor, auto-discovered from the classpath, that rewrites virtual-hosted-style requests aimed at a loopback CloudStub endpoint into path-style before they are sent. Because the module is a test-scope dependency, the interceptor acts only in the JVM under test and leaves a real S3 client untouched.

The result: a vanilla S3Client works against CloudStub with no pathStyleAccessEnabled and no other mock-specific configuration. Path-style remains supported alongside virtual-hosted, so non-Java tooling that addresses CloudStub path-style (for example curl against http://localhost:4566/{bucket}/{key}, or an AWS CLI configured for path addressing) works directly.

S3 must be a test dependency, not download-only

Because that interceptor is an AWS SDK component discovered from the application classpath, the JUnit extension's withModules("s3") cannot provision it: a downloaded module supplies server-side stubs only. Declare cloudstub-s3 as a test dependency instead (its stubs are then discovered automatically, so it does not go in withModules(...)), or enable pathStyleAccessEnabled on the S3Client and provision it with withModules like any other module.

Standalone usage

Start the standalone server with the S3 module enabled (it is auto-downloaded if not already in the plugin directory):

java -jar cloudstub-local/build/libs/cloudstub-local.jar --services=s3

Applications talk to it through the AWS SDK by pointing the client's endpoint at the mock port (http://localhost:4566); see the Test example for an S3Client setup and Standalone Mode for the full configuration. The walk-through below drives the core operations directly on the mock port with curl (path-style), showing the real round-trip:

$ curl -s -X PUT "http://localhost:4566/documents"                       # CreateBucket -> 200

$ curl -s -X PUT "http://localhost:4566/documents/greeting.txt" -d 'hello world'   # PutObject -> 200

$ curl -s "http://localhost:4566/documents/greeting.txt"                 # GetObject
hello world

$ curl -s "http://localhost:4566/documents?list-type=2"                  # ListObjectsV2
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>documents</Name><Prefix></Prefix><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated><Contents><Key>greeting.txt</Key><LastModified>2026-06-22T13:29:11.719735Z</LastModified><ETag>"5eb63bbbe01eeed093cb22bb8f5acdc3"</ETag><Size>11</Size><StorageClass>STANDARD</StorageClass></Contents><KeyCount>1</KeyCount></ListBucketResult>

$ curl -s -X DELETE "http://localhost:4566/documents/greeting.txt"       # DeleteObject -> 204

To inspect and drive object state from the terminal without speaking the S3 wire protocol, use the CLI (clb), or call the REST API on the API port (4567) directly. Parameters are passed as query-string values. Store an object and read it back:

$ clb s3 put-object --bucket documents --key greeting.txt --body "hello world"
{
  "key" : "greeting.txt",
  "etag" : "5eb63bbbe01eeed093cb22bb8f5acdc3",
  "bucket" : "documents",
  "status" : "uploaded"
}

$ clb s3 get-object --bucket documents --key greeting.txt
{
  "body" : "hello world",
  "key" : "greeting.txt",
  "bucket" : "documents"
}

$ clb s3 list-objects --bucket documents
{
  "objects" : [ "greeting.txt" ],
  "bucket" : "documents"
}
$ curl -s -X PUT "http://localhost:4567/api/s3/put-object?bucket=documents&key=greeting.txt&body=hello%20world"
{"key":"greeting.txt","etag":"5eb63bbbe01eeed093cb22bb8f5acdc3","bucket":"documents","status":"uploaded"}

$ curl -s "http://localhost:4567/api/s3/get-object?bucket=documents&key=greeting.txt"
{"body":"hello world","key":"greeting.txt","bucket":"documents"}

$ curl -s "http://localhost:4567/api/s3/list-objects?bucket=documents"
{"objects":["greeting.txt"],"bucket":"documents"}

The REST API and the SDK share the same state store, so an object your application writes through the SDK is returned by GET /api/s3/get-object, and vice versa. See REST API access for the full route set.

Test example

In embedded mode, add cloudstub-s3 (see Getting Started) and exercise the service end to end with CloudStubExtension. The client is a vanilla virtual-hosted-style S3Client, configured exactly as it would be in production; the only CloudStub touch-point is the aws.endpoint-url redirect. The test drives a small system under test (DocumentStore) and asserts on its behavior:

import io.cloudstub.junit.CloudStubExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.S3Object;

import java.net.URI;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(CloudStubExtension.class)
class DocumentStoreTest {

    /** The system under test: production code that uses a vanilla S3 client. */
    static class DocumentStore {
        private final S3Client s3;
        private final String bucket = "documents";

        DocumentStore(S3Client s3) {
            this.s3 = s3;
        }

        void init() {
            s3.createBucket(b -> b.bucket(bucket));
        }

        void put(String key, String body) {
            s3.putObject(b -> b.bucket(bucket).key(key), RequestBody.fromString(body));
        }

        String get(String key) {
            return s3.getObjectAsBytes(b -> b.bucket(bucket).key(key)).asUtf8String();
        }

        List<String> list() {
            return s3.listObjectsV2(b -> b.bucket(bucket)).contents().stream()
                .map(S3Object::key)
                .toList();
        }
    }

    @Test
    void storedObjectIsReadBackAndListed() {
        S3Client s3 = S3Client.builder()
            .endpointOverride(URI.create(System.getProperty("aws.endpoint-url")))
            .credentialsProvider(AnonymousCredentialsProvider.create())
            .region(Region.US_EAST_1)
            .build();

        DocumentStore store = new DocumentStore(s3);
        store.init();
        store.put("greeting.txt", "hello world");

        assertEquals("hello world", store.get("greeting.txt"));
        assertTrue(store.list().contains("greeting.txt"));
    }
}

The cloudstub-example Spring Boot application carries the same flow as a Spring-wired @SpringBootTest: an ObjectStore @Service over an S3Client bean, exercised by ObjectStoreIntegrationTest.

REST API access

The module exposes a REST API under /api/s3/…. These routes read and write the same state as the AWS-protocol stubs, so an object written with the SDK is returned by GET /api/s3/get-object, and vice versa. Parameters are passed as query-string values.

Route Parameters Description
GET /api/s3/list-buckets List buckets
GET /api/s3/list-objects bucket List objects in a bucket
PUT /api/s3/put-object bucket, key, body Upload an object
GET /api/s3/get-object bucket, key Download an object

Supported operations

State-backed operations return live data from the shared state store:

Operation Behavior
CreateBucket Records the bucket
DeleteBucket Removes the bucket
HeadBucket 200 if the bucket exists, 404 otherwise
ListBuckets Returns all created buckets
PutObject Stores the body; returns the ETag. Clears any tags on the key
GetObject Returns the stored body, content type, and ETag
HeadObject Returns object metadata (size, ETag) without the body
DeleteObject Removes the object
DeleteObjects Removes the listed objects in one request
ListObjects Lists object keys in the bucket (v1)
ListObjectsV2 Lists object keys in the bucket
PutObjectTagging Stores the object's tag set
GetObjectTagging Returns the object's tag set
DeleteObjectTagging Clears the object's tag set
PutBucketTagging Stores the bucket's tag set
GetBucketTagging Returns the bucket's tag set
DeleteBucketTagging Clears the bucket's tag set

The remaining operations (ACLs, CORS, encryption, lifecycle, versioning, replication, inventory, multipart upload, CopyObject, and the other bucket sub-resource configurations) are registered and return well-formed but stateless placeholder responses; they do not read or mutate state.

Limitations

  • Multipart upload lifecycle is not simulated; CreateMultipartUpload, CompleteMultipartUpload, and AbortMultipartUpload are placeholders.
  • Object versioning is not simulated; a key holds a single current value.
  • CopyObject is a placeholder and does not copy state.
  • Tags supplied at put time via the x-amz-tagging header are not stored; a plain PutObject clears the object's tags, matching real S3. Tag set with PutObjectTagging after the put.
  • Tag validation (the max-10-tags and key/value length limits real S3 enforces) is not applied.
  • Object metadata beyond content type is not stored.
  • ACLs, bucket policies, lifecycle, CORS, encryption, and the other sub-resource configurations are placeholders and are not enforced.

See also: Troubleshooting for common integration problems and workarounds.