Skip to content

Commit 10cbebc

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 17841d2 commit 10cbebc

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
@@ -391,10 +391,12 @@ public Response putObject(@PathParam("bucket") String bucket,
391391
byte[] data = decodeAwsChunked(body, contentEncoding, contentSha256);
392392
validateChecksumHeaders(httpHeaders, data);
393393
String persistedEncoding = toPersistedContentEncoding(contentEncoding);
394+
String cacheControl = httpHeaders.getHeaderString("Cache-Control");
394395
S3Object obj = s3Service.putObject(bucket, key, data, contentType, extractUserMetadata(httpHeaders),
395396
httpHeaders.getHeaderString("x-amz-storage-class"),
396397
persistedEncoding,
397-
lockMode, retainUntil, legalHold);
398+
lockMode, retainUntil, legalHold,
399+
cacheControl);
398400
var resp = Response.ok().header("ETag", obj.getETag());
399401
if (obj.getVersionId() != null) {
400402
resp.header("x-amz-version-id", obj.getVersionId());
@@ -1266,6 +1268,9 @@ private void appendObjectHeaders(Response.ResponseBuilder resp, S3Object obj) {
12661268
if (obj.getContentEncoding() != null) {
12671269
resp.header("Content-Encoding", obj.getContentEncoding());
12681270
}
1271+
if (obj.getCacheControl() != null) {
1272+
resp.header("Cache-Control", obj.getCacheControl());
1273+
}
12691274
if (obj.getMetadata() != null) {
12701275
for (Map.Entry<String, String> entry : obj.getMetadata().entrySet()) {
12711276
resp.header("x-amz-meta-" + entry.getKey(), entry.getValue());
@@ -1310,12 +1315,14 @@ private Response handleCopyObject(String copySource, String destBucket, String d
13101315
String sourceKey = decodedSource.substring(slashIndex + 1);
13111316

13121317
String copyContentEncoding = toPersistedContentEncoding(httpHeaders.getHeaderString("Content-Encoding"));
1318+
String copyCacheControl = httpHeaders.getHeaderString("Cache-Control");
13131319
S3Object copy = s3Service.copyObject(sourceBucket, sourceKey, destBucket, destKey,
13141320
httpHeaders.getHeaderString("x-amz-metadata-directive"),
13151321
extractUserMetadata(httpHeaders),
13161322
httpHeaders.getHeaderString("x-amz-storage-class"),
13171323
contentType,
1318-
copyContentEncoding);
1324+
copyContentEncoding,
1325+
copyCacheControl);
13191326
String xml = new XmlBuilder()
13201327
.raw("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
13211328
.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
@@ -138,29 +138,30 @@ public List<Bucket> listBuckets() {
138138

139139
public S3Object putObject(String bucketName, String key, byte[] data,
140140
String contentType, Map<String, String> metadata) {
141-
return putObject(bucketName, key, data, contentType, metadata, null, null, null, null);
141+
return putObject(bucketName, key, data, contentType, metadata, null, null, null, null, null, null);
142142
}
143143

144144
public S3Object putObject(String bucketName, String key, byte[] data,
145145
String contentType, Map<String, String> metadata,
146146
String objectLockMode, Instant retainUntilDate, String legalHoldStatus) {
147-
return putObject(bucketName, key, data, contentType, metadata, null,
148-
objectLockMode, retainUntilDate, legalHoldStatus);
147+
return putObject(bucketName, key, data, contentType, metadata, null, null,
148+
objectLockMode, retainUntilDate, legalHoldStatus, null);
149149
}
150150

151151
public S3Object putObject(String bucketName, String key, byte[] data,
152152
String contentType, Map<String, String> metadata, String storageClass,
153153
String objectLockMode, Instant retainUntilDate, String legalHoldStatus) {
154154
return putObject(bucketName, key, data, contentType, metadata, storageClass, null,
155-
objectLockMode, retainUntilDate, legalHoldStatus);
155+
objectLockMode, retainUntilDate, legalHoldStatus, null);
156156
}
157157

158158
public S3Object putObject(String bucketName, String key, byte[] data,
159159
String contentType, Map<String, String> metadata, String storageClass,
160160
String contentEncoding,
161-
String objectLockMode, Instant retainUntilDate, String legalHoldStatus) {
161+
String objectLockMode, Instant retainUntilDate, String legalHoldStatus,
162+
String cacheControl) {
162163
S3Object object = storeObject(bucketName, key, data, contentType, metadata, storageClass, null, null,
163-
objectLockMode, retainUntilDate, legalHoldStatus, contentEncoding);
164+
objectLockMode, retainUntilDate, legalHoldStatus, contentEncoding, cacheControl);
164165
fireNotifications(bucketName, key, "ObjectCreated:Put", object);
165166
return object;
166167
}
@@ -171,22 +172,22 @@ public S3Object putObject(String bucketName, String key, byte[] data,
171172
private S3Object storeObject(String bucketName, String key, byte[] data,
172173
String contentType, Map<String, String> metadata) {
173174
return storeObject(bucketName, key, data, contentType, metadata, null, null, null,
174-
null, null, null, null);
175+
null, null, null, null, null);
175176
}
176177

177178
private S3Object storeObject(String bucketName, String key, byte[] data,
178179
String contentType, Map<String, String> metadata, String storageClass,
179180
S3Checksum checksum, List<Part> parts,
180181
String objectLockMode, Instant retainUntilDate, String legalHoldStatus) {
181182
return storeObject(bucketName, key, data, contentType, metadata, storageClass, checksum, parts,
182-
objectLockMode, retainUntilDate, legalHoldStatus, null);
183+
objectLockMode, retainUntilDate, legalHoldStatus, null, null);
183184
}
184185

185186
private S3Object storeObject(String bucketName, String key, byte[] data,
186187
String contentType, Map<String, String> metadata, String storageClass,
187188
S3Checksum checksum, List<Part> parts,
188189
String objectLockMode, Instant retainUntilDate, String legalHoldStatus,
189-
String contentEncoding) {
190+
String contentEncoding, String cacheControl) {
190191
Bucket bucket = bucketStore.get(bucketName)
191192
.orElseThrow(() -> new AwsException("NoSuchBucket",
192193
"The specified bucket does not exist.", 404));
@@ -199,6 +200,7 @@ private S3Object storeObject(String bucketName, String key, byte[] data,
199200
object.setChecksum(checksum != null ? copyChecksum(checksum) : buildChecksum(data, parts, false));
200201
object.setParts(copyParts(parts));
201202
object.setContentEncoding(contentEncoding);
203+
object.setCacheControl(cacheControl);
202204

203205
if (bucket.isVersioningEnabled()) {
204206
String versionId = UUID.randomUUID().toString();
@@ -551,13 +553,14 @@ public S3Object copyObject(String sourceBucket, String sourceKey,
551553
String metadataDirective, Map<String, String> replacementMetadata,
552554
String storageClass, String contentType) {
553555
return copyObject(sourceBucket, sourceKey, destBucket, destKey, metadataDirective,
554-
replacementMetadata, storageClass, contentType, null);
556+
replacementMetadata, storageClass, contentType, null, null);
555557
}
556558

557559
public S3Object copyObject(String sourceBucket, String sourceKey,
558560
String destBucket, String destKey,
559561
String metadataDirective, Map<String, String> replacementMetadata,
560-
String storageClass, String contentType, String contentEncoding) {
562+
String storageClass, String contentType, String contentEncoding,
563+
String cacheControl) {
561564
S3Object source = getObject(sourceBucket, sourceKey);
562565
ensureBucketExists(destBucket);
563566

@@ -570,9 +573,10 @@ public S3Object copyObject(String sourceBucket, String sourceKey,
570573
String effectiveContentType = replaceMetadata && contentType != null ? contentType : source.getContentType();
571574
String effectiveStorageClass = storageClass != null ? storageClass : source.getStorageClass();
572575
String effectiveContentEncoding = replaceMetadata && contentEncoding != null ? contentEncoding : source.getContentEncoding();
576+
String effectiveCacheControl = replaceMetadata && cacheControl != null ? cacheControl : source.getCacheControl();
573577
S3Object copy = storeObject(destBucket, destKey, source.getData(), effectiveContentType, metadata,
574578
effectiveStorageClass, source.getChecksum(), source.getParts(), null, null, null,
575-
effectiveContentEncoding);
579+
effectiveContentEncoding, effectiveCacheControl);
576580
copy.setETag(source.getETag());
577581
LOG.debugv("Copied object: {0}/{1} -> {2}/{3}", sourceBucket, sourceKey, destBucket, destKey);
578582
fireNotifications(destBucket, destKey, "ObjectCreated:Copy", copy);
@@ -1415,6 +1419,7 @@ private static S3Object copyObject(S3Object source) {
14151419
copy.setMetadata(new HashMap<>(source.getMetadata()));
14161420
copy.setContentType(source.getContentType());
14171421
copy.setContentEncoding(source.getContentEncoding());
1422+
copy.setCacheControl(source.getCacheControl());
14181423
copy.setSize(source.getSize());
14191424
copy.setLastModified(source.getLastModified());
14201425
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)