Skip to content

Commit c8663ab

Browse files
feat(s3): preserve Cache-Control header on PutObject, GetObject, HeadObject, and CopyObject
Add cacheControl as a dedicated field on S3Object (following the contentEncoding pattern) and thread it through putObject, storeObject, and copyObject in S3Service. Extract Cache-Control from request headers in S3Controller.putObject, return it in appendObjectHeaders for GetObject/HeadObject responses, and handle it in handleCopyObject with REPLACE directive support. Includes 6 integration tests covering put, get, head, copy-preserve, copy-replace, and cleanup. Co-Authored-By: Matej Snuderl <ematej.snuderl@gmail.com>
1 parent e83f6d6 commit c8663ab

4 files changed

Lines changed: 122 additions & 14 deletions

File tree

src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,10 +392,12 @@ public Response putObject(@PathParam("bucket") String bucket,
392392
byte[] data = decodeAwsChunked(body, contentEncoding, contentSha256);
393393
validateChecksumHeaders(httpHeaders, data);
394394
String persistedEncoding = toPersistedContentEncoding(contentEncoding);
395+
String cacheControl = httpHeaders.getHeaderString("Cache-Control");
395396
S3Object obj = s3Service.putObject(bucket, key, data, contentType, extractUserMetadata(httpHeaders),
396397
httpHeaders.getHeaderString("x-amz-storage-class"),
397398
persistedEncoding,
398-
lockMode, retainUntil, legalHold);
399+
lockMode, retainUntil, legalHold,
400+
cacheControl);
399401
var resp = Response.ok().header("ETag", obj.getETag());
400402
if (obj.getVersionId() != null) {
401403
resp.header("x-amz-version-id", obj.getVersionId());
@@ -1287,6 +1289,9 @@ private void appendObjectHeaders(Response.ResponseBuilder resp, S3Object obj) {
12871289
if (obj.getContentEncoding() != null) {
12881290
resp.header("Content-Encoding", obj.getContentEncoding());
12891291
}
1292+
if (obj.getCacheControl() != null) {
1293+
resp.header("Cache-Control", obj.getCacheControl());
1294+
}
12901295
if (obj.getMetadata() != null) {
12911296
for (Map.Entry<String, String> entry : obj.getMetadata().entrySet()) {
12921297
resp.header("x-amz-meta-" + entry.getKey(), entry.getValue());
@@ -1331,12 +1336,14 @@ private Response handleCopyObject(String copySource, String destBucket, String d
13311336
String sourceKey = decodedSource.substring(slashIndex + 1);
13321337

13331338
String copyContentEncoding = toPersistedContentEncoding(httpHeaders.getHeaderString("Content-Encoding"));
1339+
String copyCacheControl = httpHeaders.getHeaderString("Cache-Control");
13341340
S3Object copy = s3Service.copyObject(sourceBucket, sourceKey, destBucket, destKey,
13351341
httpHeaders.getHeaderString("x-amz-metadata-directive"),
13361342
extractUserMetadata(httpHeaders),
13371343
httpHeaders.getHeaderString("x-amz-storage-class"),
13381344
contentType,
1339-
copyContentEncoding);
1345+
copyContentEncoding,
1346+
copyCacheControl);
13401347
String xml = new XmlBuilder()
13411348
.raw("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
13421349
.start("CopyObjectResult", AwsNamespaces.S3)

src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -184,29 +184,30 @@ public List<Bucket> listBuckets() {
184184

185185
public S3Object putObject(String bucketName, String key, byte[] data,
186186
String contentType, Map<String, String> metadata) {
187-
return putObject(bucketName, key, data, contentType, metadata, null, null, null, null);
187+
return putObject(bucketName, key, data, contentType, metadata, null, null, null, null, null, null);
188188
}
189189

190190
public S3Object putObject(String bucketName, String key, byte[] data,
191191
String contentType, Map<String, String> metadata,
192192
String objectLockMode, Instant retainUntilDate, String legalHoldStatus) {
193-
return putObject(bucketName, key, data, contentType, metadata, null,
194-
objectLockMode, retainUntilDate, legalHoldStatus);
193+
return putObject(bucketName, key, data, contentType, metadata, null, null,
194+
objectLockMode, retainUntilDate, legalHoldStatus, null);
195195
}
196196

197197
public S3Object putObject(String bucketName, String key, byte[] data,
198198
String contentType, Map<String, String> metadata, String storageClass,
199199
String objectLockMode, Instant retainUntilDate, String legalHoldStatus) {
200200
return putObject(bucketName, key, data, contentType, metadata, storageClass, null,
201-
objectLockMode, retainUntilDate, legalHoldStatus);
201+
objectLockMode, retainUntilDate, legalHoldStatus, null);
202202
}
203203

204204
public S3Object putObject(String bucketName, String key, byte[] data,
205205
String contentType, Map<String, String> metadata, String storageClass,
206206
String contentEncoding,
207-
String objectLockMode, Instant retainUntilDate, String legalHoldStatus) {
207+
String objectLockMode, Instant retainUntilDate, String legalHoldStatus,
208+
String cacheControl) {
208209
S3Object object = storeObject(bucketName, key, data, contentType, metadata, storageClass, null, null,
209-
objectLockMode, retainUntilDate, legalHoldStatus, contentEncoding);
210+
objectLockMode, retainUntilDate, legalHoldStatus, contentEncoding, cacheControl);
210211
fireNotifications(bucketName, key, "ObjectCreated:Put", object);
211212
return object;
212213
}
@@ -217,22 +218,22 @@ public S3Object putObject(String bucketName, String key, byte[] data,
217218
private S3Object storeObject(String bucketName, String key, byte[] data,
218219
String contentType, Map<String, String> metadata) {
219220
return storeObject(bucketName, key, data, contentType, metadata, null, null, null,
220-
null, null, null, null);
221+
null, null, null, null, null);
221222
}
222223

223224
private S3Object storeObject(String bucketName, String key, byte[] data,
224225
String contentType, Map<String, String> metadata, String storageClass,
225226
S3Checksum checksum, List<Part> parts,
226227
String objectLockMode, Instant retainUntilDate, String legalHoldStatus) {
227228
return storeObject(bucketName, key, data, contentType, metadata, storageClass, checksum, parts,
228-
objectLockMode, retainUntilDate, legalHoldStatus, null);
229+
objectLockMode, retainUntilDate, legalHoldStatus, null, null);
229230
}
230231

231232
private S3Object storeObject(String bucketName, String key, byte[] data,
232233
String contentType, Map<String, String> metadata, String storageClass,
233234
S3Checksum checksum, List<Part> parts,
234235
String objectLockMode, Instant retainUntilDate, String legalHoldStatus,
235-
String contentEncoding) {
236+
String contentEncoding, String cacheControl) {
236237
Bucket bucket = bucketStore.get(bucketName)
237238
.orElseThrow(() -> new AwsException("NoSuchBucket",
238239
"The specified bucket does not exist.", 404));
@@ -245,6 +246,7 @@ private S3Object storeObject(String bucketName, String key, byte[] data,
245246
object.setChecksum(checksum != null ? copyChecksum(checksum) : buildChecksum(data, parts, false));
246247
object.setParts(copyParts(parts));
247248
object.setContentEncoding(contentEncoding);
249+
object.setCacheControl(cacheControl);
248250

249251
if (bucket.isVersioningEnabled()) {
250252
String versionId = UUID.randomUUID().toString();
@@ -597,13 +599,14 @@ public S3Object copyObject(String sourceBucket, String sourceKey,
597599
String metadataDirective, Map<String, String> replacementMetadata,
598600
String storageClass, String contentType) {
599601
return copyObject(sourceBucket, sourceKey, destBucket, destKey, metadataDirective,
600-
replacementMetadata, storageClass, contentType, null);
602+
replacementMetadata, storageClass, contentType, null, null);
601603
}
602604

603605
public S3Object copyObject(String sourceBucket, String sourceKey,
604606
String destBucket, String destKey,
605607
String metadataDirective, Map<String, String> replacementMetadata,
606-
String storageClass, String contentType, String contentEncoding) {
608+
String storageClass, String contentType, String contentEncoding,
609+
String cacheControl) {
607610
S3Object source = getObject(sourceBucket, sourceKey);
608611
ensureBucketExists(destBucket);
609612

@@ -616,9 +619,10 @@ public S3Object copyObject(String sourceBucket, String sourceKey,
616619
String effectiveContentType = replaceMetadata && contentType != null ? contentType : source.getContentType();
617620
String effectiveStorageClass = storageClass != null ? storageClass : source.getStorageClass();
618621
String effectiveContentEncoding = replaceMetadata && contentEncoding != null ? contentEncoding : source.getContentEncoding();
622+
String effectiveCacheControl = replaceMetadata && cacheControl != null ? cacheControl : source.getCacheControl();
619623
S3Object copy = storeObject(destBucket, destKey, source.getData(), effectiveContentType, metadata,
620624
effectiveStorageClass, source.getChecksum(), source.getParts(), null, null, null,
621-
effectiveContentEncoding);
625+
effectiveContentEncoding, effectiveCacheControl);
622626
copy.setETag(source.getETag());
623627
LOG.debugv("Copied object: {0}/{1} -> {2}/{3}", sourceBucket, sourceKey, destBucket, destKey);
624628
fireNotifications(destBucket, destKey, "ObjectCreated:Copy", copy);
@@ -1560,6 +1564,7 @@ private static S3Object copyObject(S3Object source) {
15601564
copy.setMetadata(new HashMap<>(source.getMetadata()));
15611565
copy.setContentType(source.getContentType());
15621566
copy.setContentEncoding(source.getContentEncoding());
1567+
copy.setCacheControl(source.getCacheControl());
15631568
copy.setSize(source.getSize());
15641569
copy.setLastModified(source.getLastModified());
15651570
copy.setETag(source.getETag());

src/main/java/io/github/hectorvent/floci/services/s3/model/S3Object.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public class S3Object {
2222
private Map<String, String> metadata;
2323
private String contentType;
2424
private String contentEncoding;
25+
private String cacheControl;
2526
private long size;
2627
private Instant lastModified;
2728
private String eTag;
@@ -81,6 +82,9 @@ public S3Object(String bucketName, String key, byte[] data, String contentType)
8182
public String getContentEncoding() { return contentEncoding; }
8283
public void setContentEncoding(String contentEncoding) { this.contentEncoding = contentEncoding; }
8384

85+
public String getCacheControl() { return cacheControl; }
86+
public void setCacheControl(String cacheControl) { this.cacheControl = cacheControl; }
87+
8488
public long getSize() { return size; }
8589
public void setSize(long size) { this.size = size; }
8690

src/test/java/io/github/hectorvent/floci/services/s3/S3IntegrationTest.java

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,98 @@ void cleanupContentEncodingBucket() {
898898
given().delete("/encoding-test-bucket");
899899
}
900900

901+
// --- Cache-Control header preservation ---
902+
903+
@Test
904+
@Order(89)
905+
void createCacheControlBucketAndPutObject() {
906+
given()
907+
.put("/cache-control-bucket")
908+
.then()
909+
.statusCode(200);
910+
911+
given()
912+
.contentType("text/plain")
913+
.header("Cache-Control", "public, max-age=31536000")
914+
.body("cached-content")
915+
.when()
916+
.put("/cache-control-bucket/cached.txt")
917+
.then()
918+
.statusCode(200)
919+
.header("ETag", notNullValue());
920+
}
921+
922+
@Test
923+
@Order(90)
924+
void getObjectReturnsCacheControl() {
925+
given()
926+
.when()
927+
.get("/cache-control-bucket/cached.txt")
928+
.then()
929+
.statusCode(200)
930+
.header("Cache-Control", equalTo("public, max-age=31536000"));
931+
}
932+
933+
@Test
934+
@Order(90)
935+
void headObjectReturnsCacheControl() {
936+
given()
937+
.when()
938+
.head("/cache-control-bucket/cached.txt")
939+
.then()
940+
.statusCode(200)
941+
.header("Cache-Control", equalTo("public, max-age=31536000"));
942+
}
943+
944+
@Test
945+
@Order(91)
946+
void copyObjectPreservesCacheControl() {
947+
given()
948+
.header("x-amz-copy-source", "/cache-control-bucket/cached.txt")
949+
.when()
950+
.put("/cache-control-bucket/cached-copy.txt")
951+
.then()
952+
.statusCode(200)
953+
.body(containsString("CopyObjectResult"));
954+
955+
given()
956+
.when()
957+
.head("/cache-control-bucket/cached-copy.txt")
958+
.then()
959+
.statusCode(200)
960+
.header("Cache-Control", equalTo("public, max-age=31536000"));
961+
}
962+
963+
@Test
964+
@Order(91)
965+
void copyObjectReplaceCacheControl() {
966+
given()
967+
.header("x-amz-copy-source", "/cache-control-bucket/cached.txt")
968+
.header("x-amz-metadata-directive", "REPLACE")
969+
.header("Cache-Control", "no-cache")
970+
.when()
971+
.put("/cache-control-bucket/cached-nocache.txt")
972+
.then()
973+
.statusCode(200)
974+
.body(containsString("CopyObjectResult"));
975+
976+
given()
977+
.when()
978+
.head("/cache-control-bucket/cached-nocache.txt")
979+
.then()
980+
.statusCode(200)
981+
.header("Cache-Control", equalTo("no-cache"));
982+
}
983+
984+
@Test
985+
@Order(92)
986+
void cleanupCacheControlBucket() {
987+
given().delete("/cache-control-bucket/cached.txt");
988+
given().delete("/cache-control-bucket/cached-copy.txt");
989+
given().delete("/cache-control-bucket/cached-nocache.txt");
990+
given().delete("/cache-control-bucket");
991+
}
992+
901993
// --- S3 Notification Configuration with Filter ---
902994

903995
@Test

0 commit comments

Comments
 (0)