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):
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, andAbortMultipartUploadare placeholders. - Object versioning is not simulated; a key holds a single current value.
CopyObjectis a placeholder and does not copy state.- Tags supplied at put time via the
x-amz-taggingheader are not stored; a plainPutObjectclears the object's tags, matching real S3. Tag set withPutObjectTaggingafter 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.