Skip to content

Commit 5b4bc47

Browse files
committed
Implement If-(Un)modified-Since
Fixes #829
1 parent 38c79d3 commit 5b4bc47

File tree

5 files changed

+137
-22
lines changed

5 files changed

+137
-22
lines changed

integration-tests/src/test/kotlin/com/adobe/testing/s3mock/its/GetPutDeleteObjectV2IT.kt

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,78 @@ internal class GetPutDeleteObjectV2IT : S3TestBase() {
623623
}
624624
}
625625

626+
@Test
627+
@S3VerifiedTodo
628+
fun testGetObject_successWithMatchingIfModified(testInfo: TestInfo) {
629+
val now = Instant.now()
630+
val (bucketName, _) = givenBucketAndObjectV2(testInfo, UPLOAD_FILE_NAME)
631+
632+
s3ClientV2.getObject(
633+
GetObjectRequest.builder()
634+
.bucket(bucketName)
635+
.key(UPLOAD_FILE_NAME)
636+
.ifModifiedSince(now)
637+
.build()
638+
).use {
639+
assertThat(it.response().eTag()).isNotNull()
640+
}
641+
}
642+
643+
@Test
644+
@S3VerifiedTodo
645+
fun testGetObject_failureWithNonMatchingIfModified(testInfo: TestInfo) {
646+
val (bucketName, _) = givenBucketAndObjectV2(testInfo, UPLOAD_FILE_NAME)
647+
val now = Instant.now()
648+
649+
assertThatThrownBy {
650+
s3ClientV2.getObject(
651+
GetObjectRequest.builder()
652+
.bucket(bucketName)
653+
.key(UPLOAD_FILE_NAME)
654+
.ifModifiedSince(now)
655+
.build()
656+
)
657+
}.isInstanceOf(S3Exception::class.java)
658+
.hasMessageContaining("Service: S3, Status Code: 412")
659+
}
660+
661+
@Test
662+
@S3VerifiedTodo
663+
fun testGetObject_successWithMatchingIfUnmodified(testInfo: TestInfo) {
664+
val (bucketName, _) = givenBucketAndObjectV2(testInfo, UPLOAD_FILE_NAME)
665+
val now = Instant.now()
666+
667+
s3ClientV2.getObject(
668+
GetObjectRequest.builder()
669+
.bucket(bucketName)
670+
.key(UPLOAD_FILE_NAME)
671+
.ifUnmodifiedSince(now)
672+
.build()
673+
).use {
674+
assertThat(it.response().eTag()).isNotNull()
675+
}
676+
}
677+
678+
679+
@Test
680+
@S3VerifiedTodo
681+
fun testGetObject_failureWithNonMatchingIfUnmodified(testInfo: TestInfo) {
682+
val now = Instant.now()
683+
val (bucketName, _) = givenBucketAndObjectV2(testInfo, UPLOAD_FILE_NAME)
684+
685+
assertThatThrownBy {
686+
s3ClientV2.getObject(
687+
GetObjectRequest.builder()
688+
.bucket(bucketName)
689+
.key(UPLOAD_FILE_NAME)
690+
.ifUnmodifiedSince(now)
691+
.build()
692+
)
693+
}.isInstanceOf(S3Exception::class.java)
694+
.hasMessageContaining("Service: S3, Status Code: 412")
695+
}
696+
697+
626698
@Test
627699
@S3VerifiedSuccess(year = 2024)
628700
fun testGetObject_successWithMatchingEtag(testInfo: TestInfo) {

server/src/main/java/com/adobe/testing/s3mock/MultipartController.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.NOT_X_AMZ_COPY_SOURCE_RANGE;
2323
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE;
2424
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_MATCH;
25+
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE;
2526
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_NONE_MATCH;
27+
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE;
2628
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_RANGE;
2729
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_STORAGE_CLASS;
2830
import static com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LIFECYCLE;
@@ -55,6 +57,7 @@
5557
import jakarta.servlet.http.HttpServletRequest;
5658
import java.io.IOException;
5759
import java.io.InputStream;
60+
import java.time.Instant;
5861
import java.util.List;
5962
import java.util.Map;
6063
import org.apache.commons.io.FileUtils;
@@ -285,14 +288,18 @@ public ResponseEntity<CopyPartResult> uploadPartCopy(
285288
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_MATCH, required = false) List<String> match,
286289
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_NONE_MATCH,
287290
required = false) List<String> noneMatch,
291+
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE,
292+
required = false) List<Instant> ifModifiedSince,
293+
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE,
294+
required = false) List<Instant> ifUnmodifiedSince,
288295
@RequestParam String uploadId,
289296
@RequestParam String partNumber,
290297
@RequestHeader HttpHeaders httpHeaders) {
291-
//needs modified-since handling, see API
292298
bucketService.verifyBucketExists(bucketName);
293299
multipartService.verifyPartNumberLimits(partNumber);
294300
var s3ObjectMetadata = objectService.verifyObjectExists(copySource.bucket(), copySource.key());
295-
objectService.verifyObjectMatchingForCopy(match, noneMatch, s3ObjectMetadata);
301+
objectService.verifyObjectMatchingForCopy(match, noneMatch,
302+
ifModifiedSince, ifUnmodifiedSince, s3ObjectMetadata);
296303

297304
var result = multipartService.copyPart(copySource.bucket(),
298305
copySource.key(),

server/src/main/java/com/adobe/testing/s3mock/ObjectController.java

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_ACL;
2626
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE;
2727
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_MATCH;
28+
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE;
2829
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_NONE_MATCH;
30+
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE;
2931
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_DELETE_MARKER;
3032
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_METADATA_DIRECTIVE;
3133
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_OBJECT_ATTRIBUTES;
@@ -58,7 +60,9 @@
5860
import static com.adobe.testing.s3mock.util.HeaderUtil.userMetadataHeadersFrom;
5961
import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
6062
import static org.springframework.http.HttpHeaders.IF_MATCH;
63+
import static org.springframework.http.HttpHeaders.IF_MODIFIED_SINCE;
6164
import static org.springframework.http.HttpHeaders.IF_NONE_MATCH;
65+
import static org.springframework.http.HttpHeaders.IF_UNMODIFIED_SINCE;
6266
import static org.springframework.http.HttpStatus.NOT_FOUND;
6367
import static org.springframework.http.HttpStatus.PARTIAL_CONTENT;
6468
import static org.springframework.http.HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE;
@@ -90,6 +94,7 @@
9094
import java.io.OutputStream;
9195
import java.nio.file.Files;
9296
import java.nio.file.Path;
97+
import java.time.Instant;
9398
import java.util.Collections;
9499
import java.util.List;
95100
import java.util.Map;
@@ -183,15 +188,18 @@ public ResponseEntity<DeleteResult> deleteObjects(
183188
public ResponseEntity<Void> headObject(@PathVariable String bucketName,
184189
@PathVariable ObjectKey key,
185190
@RequestHeader(value = IF_MATCH, required = false) List<String> match,
186-
@RequestHeader(value = IF_NONE_MATCH, required = false) List<String> noneMatch) {
187-
//TODO: needs modified-since handling, see API
191+
@RequestHeader(value = IF_NONE_MATCH, required = false) List<String> noneMatch,
192+
@RequestHeader(value = IF_MODIFIED_SINCE, required = false) List<Instant> ifModifiedSince,
193+
@RequestHeader(value = IF_UNMODIFIED_SINCE, required = false) List<Instant> ifUnmodifiedSince
194+
) {
188195
bucketService.verifyBucketExists(bucketName);
189196

190197
var s3ObjectMetadata = objectService.verifyObjectExists(bucketName, key.key());
191198
//return version id
192199

193200
if (s3ObjectMetadata != null) {
194-
objectService.verifyObjectMatching(match, noneMatch, s3ObjectMetadata);
201+
objectService.verifyObjectMatching(match, noneMatch,
202+
ifModifiedSince, ifUnmodifiedSince, s3ObjectMetadata);
195203
return ResponseEntity.ok()
196204
.eTag(s3ObjectMetadata.etag())
197205
.lastModified(s3ObjectMetadata.lastModified())
@@ -260,12 +268,14 @@ public ResponseEntity<StreamingResponseBody> getObject(@PathVariable String buck
260268
@RequestHeader(value = RANGE, required = false) HttpRange range,
261269
@RequestHeader(value = IF_MATCH, required = false) List<String> match,
262270
@RequestHeader(value = IF_NONE_MATCH, required = false) List<String> noneMatch,
271+
@RequestHeader(value = IF_MODIFIED_SINCE, required = false) List<Instant> ifModifiedSince,
272+
@RequestHeader(value = IF_UNMODIFIED_SINCE, required = false) List<Instant> ifUnmodifiedSince,
263273
@RequestParam Map<String, String> queryParams) {
264-
//TODO: needs modified-since handling, see API
265274
bucketService.verifyBucketExists(bucketName);
266275

267276
var s3ObjectMetadata = objectService.verifyObjectExists(bucketName, key.key());
268-
objectService.verifyObjectMatching(match, noneMatch, s3ObjectMetadata);
277+
objectService.verifyObjectMatching(match, noneMatch,
278+
ifModifiedSince, ifUnmodifiedSince, s3ObjectMetadata);
269279

270280
if (range != null) {
271281
return getObjectWithRange(range, s3ObjectMetadata);
@@ -535,14 +545,16 @@ public ResponseEntity<GetObjectAttributesOutput> getObjectAttributes(
535545
@PathVariable ObjectKey key,
536546
@RequestHeader(value = IF_MATCH, required = false) List<String> match,
537547
@RequestHeader(value = IF_NONE_MATCH, required = false) List<String> noneMatch,
548+
@RequestHeader(value = IF_MODIFIED_SINCE, required = false) List<Instant> ifModifiedSince,
549+
@RequestHeader(value = IF_UNMODIFIED_SINCE, required = false) List<Instant> ifUnmodifiedSince,
538550
@RequestHeader(value = X_AMZ_OBJECT_ATTRIBUTES) List<String> objectAttributes) {
539-
//TODO: needs modified-since handling, see API
540551
bucketService.verifyBucketExists(bucketName);
541552

542553
//this is for either an object request, or a parts request.
543554

544555
S3ObjectMetadata s3ObjectMetadata = objectService.verifyObjectExists(bucketName, key.key());
545-
objectService.verifyObjectMatching(match, noneMatch, s3ObjectMetadata);
556+
objectService.verifyObjectMatching(match, noneMatch,
557+
ifModifiedSince, ifUnmodifiedSince, s3ObjectMetadata);
546558
//S3Mock stores the etag with the additional quotation marks needed in the headers. This
547559
// response does not use eTag as a header, so it must not contain the quotation marks.
548560
String etag = s3ObjectMetadata.etag().replace("\"", "");
@@ -688,13 +700,16 @@ public ResponseEntity<CopyObjectResult> copyObject(@PathVariable String bucketNa
688700
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_MATCH, required = false) List<String> match,
689701
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_NONE_MATCH,
690702
required = false) List<String> noneMatch,
703+
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE,
704+
required = false) List<Instant> ifModifiedSince,
705+
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE,
706+
required = false) List<Instant> ifUnmodifiedSince,
691707
@RequestHeader(value = X_AMZ_STORAGE_CLASS, required = false) StorageClass storageClass,
692708
@RequestHeader HttpHeaders httpHeaders) {
693-
//TODO: needs modified-since handling, see API
694-
695709
bucketService.verifyBucketExists(bucketName);
696710
var s3ObjectMetadata = objectService.verifyObjectExists(copySource.bucket(), copySource.key());
697-
objectService.verifyObjectMatchingForCopy(match, noneMatch, s3ObjectMetadata);
711+
objectService.verifyObjectMatchingForCopy(match, noneMatch,
712+
ifModifiedSince, ifUnmodifiedSince, s3ObjectMetadata);
698713

699714
Map<String, String> userMetadata = Collections.emptyMap();
700715
Map<String, String> storeHeaders = Collections.emptyMap();

server/src/main/java/com/adobe/testing/s3mock/service/ObjectService.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,9 +285,10 @@ public void verifyMd5(InputStream inputStream, String contentMd5) {
285285
* FOr copy use-cases, we need to return PRECONDITION_FAILED only.
286286
*/
287287
public void verifyObjectMatchingForCopy(List<String> match, List<String> noneMatch,
288+
List<Instant> ifModifiedSince, List<Instant> ifUnmodifiedSince,
288289
S3ObjectMetadata s3ObjectMetadata) {
289290
try {
290-
verifyObjectMatching(match, noneMatch, s3ObjectMetadata);
291+
verifyObjectMatching(match, noneMatch, ifModifiedSince, ifUnmodifiedSince, s3ObjectMetadata);
291292
} catch (S3Exception e) {
292293
if (NOT_MODIFIED.equals(e)) {
293294
throw PRECONDITION_FAILED;
@@ -298,18 +299,38 @@ public void verifyObjectMatchingForCopy(List<String> match, List<String> noneMat
298299
}
299300

300301
public void verifyObjectMatching(List<String> match, List<String> noneMatch,
302+
List<Instant> ifModifiedSince, List<Instant> ifUnmodifiedSince,
301303
S3ObjectMetadata s3ObjectMetadata) {
302304
if (s3ObjectMetadata != null) {
303305
var etag = s3ObjectMetadata.etag();
304-
if (match != null) {
306+
var lastModified = Instant.ofEpochMilli(s3ObjectMetadata.lastModified());
307+
308+
var setModifiedSince = ifModifiedSince != null && !ifModifiedSince.isEmpty();
309+
if (setModifiedSince) {
310+
if (ifModifiedSince.get(0).isAfter(lastModified)) {
311+
throw PRECONDITION_FAILED;
312+
}
313+
}
314+
315+
var setUnmodifiedSince = ifUnmodifiedSince != null && !ifUnmodifiedSince.isEmpty();
316+
if (setUnmodifiedSince) {
317+
if (ifUnmodifiedSince.get(0).isBefore(lastModified)) {
318+
throw PRECONDITION_FAILED;
319+
}
320+
}
321+
322+
var setMatch = match != null && !match.isEmpty();
323+
if (setMatch) {
305324
if (match.contains(WILDCARD_ETAG)) {
306325
//request cares only that the object exists
307326
return;
308327
} else if (!match.contains(etag)) {
309328
throw PRECONDITION_FAILED;
310329
}
311330
}
312-
if (noneMatch != null && (noneMatch.contains(WILDCARD_ETAG) || noneMatch.contains(etag))) {
331+
332+
var setNoneMatch = noneMatch != null && !noneMatch.isEmpty();
333+
if (setNoneMatch && (noneMatch.contains(WILDCARD_ETAG) || noneMatch.contains(etag))) {
313334
//request cares only that the object DOES NOT exist.
314335
throw NOT_MODIFIED;
315336
}

server/src/test/kotlin/com/adobe/testing/s3mock/service/ObjectServiceTest.kt

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ internal class ObjectServiceTest : ServiceTestBase() {
163163
val s3ObjectMetadata = s3ObjectMetadata(UUID.randomUUID(), key)
164164
val etag = "\"someetag\""
165165

166-
iut.verifyObjectMatching(listOf(etag), null, s3ObjectMetadata)
166+
iut.verifyObjectMatching(listOf(etag), null, null, null, s3ObjectMetadata)
167167
}
168168

169169
@Test
@@ -172,7 +172,7 @@ internal class ObjectServiceTest : ServiceTestBase() {
172172
val s3ObjectMetadata = s3ObjectMetadata(UUID.randomUUID(), key)
173173
val etag = "\"nonematch\""
174174

175-
iut.verifyObjectMatching(listOf(etag, ObjectService.WILDCARD_ETAG), null, s3ObjectMetadata)
175+
iut.verifyObjectMatching(listOf(etag, ObjectService.WILDCARD_ETAG), null, null, null, s3ObjectMetadata)
176176
}
177177

178178
@Test
@@ -181,7 +181,7 @@ internal class ObjectServiceTest : ServiceTestBase() {
181181
val s3ObjectMetadata = s3ObjectMetadata(UUID.randomUUID(), key)
182182
val etag = "\"nonematch\""
183183

184-
assertThatThrownBy { iut.verifyObjectMatching(listOf(etag), null, s3ObjectMetadata) }
184+
assertThatThrownBy { iut.verifyObjectMatching(listOf(etag), null, null, null, s3ObjectMetadata) }
185185
.isEqualTo(S3Exception.PRECONDITION_FAILED)
186186
}
187187

@@ -191,19 +191,19 @@ internal class ObjectServiceTest : ServiceTestBase() {
191191
val s3ObjectMetadata = s3ObjectMetadata(UUID.randomUUID(), key)
192192
val etag = "\"nonematch\""
193193

194-
iut.verifyObjectMatching(null, listOf(etag), s3ObjectMetadata)
194+
iut.verifyObjectMatching(null, listOf(etag), null, null, s3ObjectMetadata)
195195
}
196196

197197
@Test
198198
fun testVerifyObjectMatching_noneMatchWildcard() {
199199
val key = "key"
200200
val s3ObjectMetadata = s3ObjectMetadata(UUID.randomUUID(), key)
201-
val etag = "\"someetag\""
202201

203202
assertThatThrownBy {
204203
iut.verifyObjectMatching(
205204
null,
206-
listOf(etag, ObjectService.WILDCARD_ETAG),
205+
listOf(ObjectService.WILDCARD_ETAG),
206+
null, null,
207207
s3ObjectMetadata
208208
)
209209
}
@@ -216,7 +216,7 @@ internal class ObjectServiceTest : ServiceTestBase() {
216216
val s3ObjectMetadata = s3ObjectMetadata(UUID.randomUUID(), key)
217217
val etag = "\"someetag\""
218218

219-
assertThatThrownBy { iut.verifyObjectMatching(null, listOf(etag), s3ObjectMetadata) }
219+
assertThatThrownBy { iut.verifyObjectMatching(null, listOf(etag), null, null, s3ObjectMetadata) }
220220
.isEqualTo(S3Exception.NOT_MODIFIED)
221221
}
222222

0 commit comments

Comments
 (0)