Skip to content

Commit

Permalink
Implement If-(Un)modified-Since
Browse files Browse the repository at this point in the history
Fixes #829
  • Loading branch information
afranken committed Dec 15, 2024
1 parent 38c79d3 commit 0b72127
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,68 @@ internal class GetPutDeleteObjectV2IT : S3TestBase() {
}
}

@Test
@S3VerifiedTodo
fun testGetObject_successWithMatchingIfModified(testInfo: TestInfo) {
val now = Instant.now()
val (bucketName, _) = givenBucketAndObjectV2(testInfo, UPLOAD_FILE_NAME)

s3ClientV2.getObject(
GetObjectRequest.builder()
.bucket(bucketName)
.key(UPLOAD_FILE_NAME)
.ifModifiedSince(now)
.build()
).use {
assertThat(it.response().eTag()).isNotNull()
}
}

@Test
@S3VerifiedTodo
fun testGetObject_failureWithNonMatchingIfModified(testInfo: TestInfo) {
val (bucketName, _) = givenBucketAndObjectV2(testInfo, UPLOAD_FILE_NAME)

val now = Instant.now()
assertThatThrownBy {
s3ClientV2.getObject(
GetObjectRequest.builder()
.bucket(bucketName)
.key(UPLOAD_FILE_NAME)
.ifModifiedSince(now)
.build()
)
}.isInstanceOf(S3Exception::class.java)
.hasMessageContaining("Service: S3, Status Code: 412")
}

@Test
@S3VerifiedTodo
fun testGetObject_successWithMatchingIfUnmodified(testInfo: TestInfo) {

val uploadFile = File(UPLOAD_FILE_NAME)
val matchingEtag = FileInputStream(uploadFile).let {
"\"${DigestUtil.hexDigest(it)}\""
}

val (bucketName, putObjectResponse) = givenBucketAndObjectV2(testInfo, UPLOAD_FILE_NAME)
val eTag = putObjectResponse.eTag().also {
assertThat(it).isEqualTo(matchingEtag)
}

val now = Instant.now()

s3ClientV2.getObject(
GetObjectRequest.builder()
.bucket(bucketName)
.key(UPLOAD_FILE_NAME)
.ifUnmodifiedSince(now)
.build()
).use {
assertThat(it.response().eTag()).isEqualTo(eTag)
}
}

@Test
@S3VerifiedSuccess(year = 2024)
fun testGetObject_successWithMatchingEtag(testInfo: TestInfo) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.NOT_X_AMZ_COPY_SOURCE_RANGE;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_MATCH;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_NONE_MATCH;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_RANGE;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_STORAGE_CLASS;
import static com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LIFECYCLE;
Expand Down Expand Up @@ -55,6 +57,7 @@
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStream;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.apache.commons.io.FileUtils;
Expand Down Expand Up @@ -285,14 +288,18 @@ public ResponseEntity<CopyPartResult> uploadPartCopy(
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_MATCH, required = false) List<String> match,
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_NONE_MATCH,
required = false) List<String> noneMatch,
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE,
required = false) List<Instant> ifModifiedSince,
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE,
required = false) List<Instant> ifUnmodifiedSince,
@RequestParam String uploadId,
@RequestParam String partNumber,
@RequestHeader HttpHeaders httpHeaders) {
//needs modified-since handling, see API
bucketService.verifyBucketExists(bucketName);
multipartService.verifyPartNumberLimits(partNumber);
var s3ObjectMetadata = objectService.verifyObjectExists(copySource.bucket(), copySource.key());
objectService.verifyObjectMatchingForCopy(match, noneMatch, s3ObjectMetadata);
objectService.verifyObjectMatchingForCopy(match, noneMatch,
ifModifiedSince, ifUnmodifiedSince, s3ObjectMetadata);

var result = multipartService.copyPart(copySource.bucket(),
copySource.key(),
Expand Down
35 changes: 25 additions & 10 deletions server/src/main/java/com/adobe/testing/s3mock/ObjectController.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_ACL;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_MATCH;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_NONE_MATCH;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_DELETE_MARKER;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_METADATA_DIRECTIVE;
import static com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_OBJECT_ATTRIBUTES;
Expand Down Expand Up @@ -58,7 +60,9 @@
import static com.adobe.testing.s3mock.util.HeaderUtil.userMetadataHeadersFrom;
import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
import static org.springframework.http.HttpHeaders.IF_MATCH;
import static org.springframework.http.HttpHeaders.IF_MODIFIED_SINCE;
import static org.springframework.http.HttpHeaders.IF_NONE_MATCH;
import static org.springframework.http.HttpHeaders.IF_UNMODIFIED_SINCE;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.PARTIAL_CONTENT;
import static org.springframework.http.HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE;
Expand Down Expand Up @@ -90,6 +94,7 @@
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -183,15 +188,18 @@ public ResponseEntity<DeleteResult> deleteObjects(
public ResponseEntity<Void> headObject(@PathVariable String bucketName,
@PathVariable ObjectKey key,
@RequestHeader(value = IF_MATCH, required = false) List<String> match,
@RequestHeader(value = IF_NONE_MATCH, required = false) List<String> noneMatch) {
//TODO: needs modified-since handling, see API
@RequestHeader(value = IF_NONE_MATCH, required = false) List<String> noneMatch,
@RequestHeader(value = IF_MODIFIED_SINCE, required = false) List<Instant> ifModifiedSince,
@RequestHeader(value = IF_UNMODIFIED_SINCE, required = false) List<Instant> ifUnmodifiedSince
) {
bucketService.verifyBucketExists(bucketName);

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

if (s3ObjectMetadata != null) {
objectService.verifyObjectMatching(match, noneMatch, s3ObjectMetadata);
objectService.verifyObjectMatching(match, noneMatch,
ifModifiedSince, ifUnmodifiedSince, s3ObjectMetadata);
return ResponseEntity.ok()
.eTag(s3ObjectMetadata.etag())
.lastModified(s3ObjectMetadata.lastModified())
Expand Down Expand Up @@ -260,12 +268,14 @@ public ResponseEntity<StreamingResponseBody> getObject(@PathVariable String buck
@RequestHeader(value = RANGE, required = false) HttpRange range,
@RequestHeader(value = IF_MATCH, required = false) List<String> match,
@RequestHeader(value = IF_NONE_MATCH, required = false) List<String> noneMatch,
@RequestHeader(value = IF_MODIFIED_SINCE, required = false) List<Instant> ifModifiedSince,
@RequestHeader(value = IF_UNMODIFIED_SINCE, required = false) List<Instant> ifUnmodifiedSince,
@RequestParam Map<String, String> queryParams) {
//TODO: needs modified-since handling, see API
bucketService.verifyBucketExists(bucketName);

var s3ObjectMetadata = objectService.verifyObjectExists(bucketName, key.key());
objectService.verifyObjectMatching(match, noneMatch, s3ObjectMetadata);
objectService.verifyObjectMatching(match, noneMatch,
ifModifiedSince, ifUnmodifiedSince, s3ObjectMetadata);

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

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

S3ObjectMetadata s3ObjectMetadata = objectService.verifyObjectExists(bucketName, key.key());
objectService.verifyObjectMatching(match, noneMatch, s3ObjectMetadata);
objectService.verifyObjectMatching(match, noneMatch,
ifModifiedSince, ifUnmodifiedSince, s3ObjectMetadata);
//S3Mock stores the etag with the additional quotation marks needed in the headers. This
// response does not use eTag as a header, so it must not contain the quotation marks.
String etag = s3ObjectMetadata.etag().replace("\"", "");
Expand Down Expand Up @@ -688,13 +700,16 @@ public ResponseEntity<CopyObjectResult> copyObject(@PathVariable String bucketNa
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_MATCH, required = false) List<String> match,
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_NONE_MATCH,
required = false) List<String> noneMatch,
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE,
required = false) List<Instant> ifModifiedSince,
@RequestHeader(value = X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE,
required = false) List<Instant> ifUnmodifiedSince,
@RequestHeader(value = X_AMZ_STORAGE_CLASS, required = false) StorageClass storageClass,
@RequestHeader HttpHeaders httpHeaders) {
//TODO: needs modified-since handling, see API

bucketService.verifyBucketExists(bucketName);
var s3ObjectMetadata = objectService.verifyObjectExists(copySource.bucket(), copySource.key());
objectService.verifyObjectMatchingForCopy(match, noneMatch, s3ObjectMetadata);
objectService.verifyObjectMatchingForCopy(match, noneMatch,
ifModifiedSince, ifUnmodifiedSince, s3ObjectMetadata);

Map<String, String> userMetadata = Collections.emptyMap();
Map<String, String> storeHeaders = Collections.emptyMap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,10 @@ public void verifyMd5(InputStream inputStream, String contentMd5) {
* FOr copy use-cases, we need to return PRECONDITION_FAILED only.
*/
public void verifyObjectMatchingForCopy(List<String> match, List<String> noneMatch,
List<Instant> ifModifiedSince, List<Instant> ifUnmodifiedSince,
S3ObjectMetadata s3ObjectMetadata) {
try {
verifyObjectMatching(match, noneMatch, s3ObjectMetadata);
verifyObjectMatching(match, noneMatch, ifModifiedSince, ifUnmodifiedSince, s3ObjectMetadata);
} catch (S3Exception e) {
if (NOT_MODIFIED.equals(e)) {
throw PRECONDITION_FAILED;
Expand All @@ -298,18 +299,38 @@ public void verifyObjectMatchingForCopy(List<String> match, List<String> noneMat
}

public void verifyObjectMatching(List<String> match, List<String> noneMatch,
List<Instant> ifModifiedSince, List<Instant> ifUnmodifiedSince,
S3ObjectMetadata s3ObjectMetadata) {
if (s3ObjectMetadata != null) {
var etag = s3ObjectMetadata.etag();
if (match != null) {
var lastModified = Instant.ofEpochMilli(s3ObjectMetadata.lastModified());

var setModifiedSince = ifModifiedSince != null && !ifModifiedSince.isEmpty();
if (setModifiedSince) {
if (ifModifiedSince.get(0).isAfter(lastModified)) {
throw PRECONDITION_FAILED;
}
}

var setUnmodifiedSince = ifUnmodifiedSince != null && !ifUnmodifiedSince.isEmpty();
if (setUnmodifiedSince) {
if (ifUnmodifiedSince.get(0).isBefore(lastModified)) {
throw PRECONDITION_FAILED;
}
}

var setMatch = match != null && !match.isEmpty();
if (setMatch) {
if (match.contains(WILDCARD_ETAG)) {
//request cares only that the object exists
return;
} else if (!match.contains(etag)) {
throw PRECONDITION_FAILED;
}
}
if (noneMatch != null && (noneMatch.contains(WILDCARD_ETAG) || noneMatch.contains(etag))) {

var setNoneMatch = noneMatch != null && !noneMatch.isEmpty();
if (setNoneMatch && (noneMatch.contains(WILDCARD_ETAG) || noneMatch.contains(etag))) {
//request cares only that the object DOES NOT exist.
throw NOT_MODIFIED;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ internal class ObjectServiceTest : ServiceTestBase() {
val s3ObjectMetadata = s3ObjectMetadata(UUID.randomUUID(), key)
val etag = "\"someetag\""

iut.verifyObjectMatching(listOf(etag), null, s3ObjectMetadata)
iut.verifyObjectMatching(listOf(etag), null, null, null, s3ObjectMetadata)
}

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

iut.verifyObjectMatching(listOf(etag, ObjectService.WILDCARD_ETAG), null, s3ObjectMetadata)
iut.verifyObjectMatching(listOf(etag, ObjectService.WILDCARD_ETAG), null, null, null, s3ObjectMetadata)
}

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

assertThatThrownBy { iut.verifyObjectMatching(listOf(etag), null, s3ObjectMetadata) }
assertThatThrownBy { iut.verifyObjectMatching(listOf(etag), null, null, null, s3ObjectMetadata) }
.isEqualTo(S3Exception.PRECONDITION_FAILED)
}

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

iut.verifyObjectMatching(null, listOf(etag), s3ObjectMetadata)
iut.verifyObjectMatching(null, listOf(etag), null, null, s3ObjectMetadata)
}

@Test
fun testVerifyObjectMatching_noneMatchWildcard() {
val key = "key"
val s3ObjectMetadata = s3ObjectMetadata(UUID.randomUUID(), key)
val etag = "\"someetag\""

assertThatThrownBy {
iut.verifyObjectMatching(
null,
listOf(etag, ObjectService.WILDCARD_ETAG),
listOf(ObjectService.WILDCARD_ETAG),
null, null,
s3ObjectMetadata
)
}
Expand All @@ -216,7 +216,7 @@ internal class ObjectServiceTest : ServiceTestBase() {
val s3ObjectMetadata = s3ObjectMetadata(UUID.randomUUID(), key)
val etag = "\"someetag\""

assertThatThrownBy { iut.verifyObjectMatching(null, listOf(etag), s3ObjectMetadata) }
assertThatThrownBy { iut.verifyObjectMatching(null, listOf(etag), null, null, s3ObjectMetadata) }
.isEqualTo(S3Exception.NOT_MODIFIED)
}

Expand Down

0 comments on commit 0b72127

Please sign in to comment.