Skip to content

Commit 64b84cd

Browse files
committed
API consistency: Object APIs / DTOs
Checking AWS APIs for changes and S3Mock for implementations and tests. Fixes #2340
1 parent 34de981 commit 64b84cd

27 files changed

+620
-216
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ Version 4.x is JDK17 LTS bytecode compatible, with Docker and JUnit / direct Jav
158158
* CreateMultipartUpload now accepts checksum headers and returns checksum and encryption headers
159159
* CompleteMultipartUpload now accepts checksum headers and returns checksum and encryption headers
160160
* Checksum validation on complete
161+
* DeleteObject now supports conditional requests
162+
* PutObject now supports conditional requests
161163
* Version updates (deliverable dependencies)
162164
* Bump spring-boot.version from 3.4.4 to 3.4.5
163165
* Bump testcontainers.version from 1.20.6 to 1.21.0

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import org.junit.jupiter.api.TestInfo
2323
import software.amazon.awssdk.checksums.DefaultChecksumAlgorithm
2424
import software.amazon.awssdk.core.sync.RequestBody
2525
import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm
26+
import software.amazon.awssdk.services.s3.model.ChecksumMode
2627
import java.io.File
2728
import java.io.FileInputStream
2829
import java.io.InputStream
@@ -67,6 +68,7 @@ internal class AwsChunkedEncodingIT : S3TestBase() {
6768

6869
s3Client.getObject {
6970
it.bucket(bucket)
71+
it.checksumMode(ChecksumMode.ENABLED)
7072
it.key(UPLOAD_FILE_NAME)
7173
}.also {
7274
assertThat(it.response().eTag()).isEqualTo(expectedEtag)

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

Lines changed: 234 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -901,7 +901,7 @@ internal class GetPutDeleteObjectIT : S3TestBase() {
901901

902902
@Test
903903
@S3VerifiedSuccess(year = 2025)
904-
fun testGetObject_successWithMatchingEtag(testInfo: TestInfo) {
904+
fun `GET object succeeds with matching etag`(testInfo: TestInfo) {
905905
val uploadFile = File(UPLOAD_FILE_NAME)
906906
val matchingEtag = FileInputStream(uploadFile).let {
907907
"\"${DigestUtil.hexDigest(it)}\""
@@ -955,6 +955,239 @@ internal class GetPutDeleteObjectIT : S3TestBase() {
955955
}
956956
}
957957

958+
@Test
959+
@S3VerifiedSuccess(year = 2025)
960+
fun `PUT object succeeds with matching etag`(testInfo: TestInfo) {
961+
val uploadFile = File(UPLOAD_FILE_NAME)
962+
val matchingEtag = FileInputStream(uploadFile).let {
963+
"\"${DigestUtil.hexDigest(it)}\""
964+
}
965+
966+
val (bucketName, putObjectResponse) = givenBucketAndObject(testInfo, UPLOAD_FILE_NAME)
967+
putObjectResponse.eTag().also {
968+
assertThat(it).isEqualTo(matchingEtag)
969+
}
970+
971+
s3Client.putObject ({
972+
it.bucket(bucketName)
973+
it.key(UPLOAD_FILE_NAME)
974+
it.ifMatch(matchingEtag)
975+
}, RequestBody.fromFile(uploadFile)
976+
)
977+
}
978+
979+
@Test
980+
@S3VerifiedFailure(year = 2025,
981+
reason = "S3 returns: A header you provided implies functionality that is not implemented.")
982+
fun `PUT object fails with non matching wildcard etag`(testInfo: TestInfo) {
983+
val uploadFile = File(UPLOAD_FILE_NAME)
984+
val expectedEtag = FileInputStream(uploadFile).let {
985+
"\"${DigestUtil.hexDigest(it)}\""
986+
}
987+
val nonMatchingEtag = "\"*\""
988+
989+
val (bucketName, putObjectResponse) = givenBucketAndObject(testInfo, UPLOAD_FILE_NAME)
990+
putObjectResponse.eTag().also {
991+
assertThat(it).isEqualTo(expectedEtag)
992+
}
993+
994+
assertThatThrownBy {
995+
s3Client.putObject(
996+
{
997+
it.bucket(bucketName)
998+
it.key(UPLOAD_FILE_NAME)
999+
it.ifNoneMatch(nonMatchingEtag)
1000+
}, RequestBody.fromFile(uploadFile)
1001+
)
1002+
}.isInstanceOf(S3Exception::class.java)
1003+
.hasMessageContaining("Service: S3, Status Code: 304")
1004+
}
1005+
1006+
@Test
1007+
@S3VerifiedSuccess(year = 2025)
1008+
fun `PUT object fails with non matching etag`(testInfo: TestInfo) {
1009+
val uploadFile = File(UPLOAD_FILE_NAME)
1010+
val expectedEtag = FileInputStream(uploadFile).let {
1011+
"\"${DigestUtil.hexDigest(it)}\""
1012+
}
1013+
val nonMatchingEtag = "\"$randomName\""
1014+
1015+
val (bucketName, putObjectResponse) = givenBucketAndObject(testInfo, UPLOAD_FILE_NAME)
1016+
val eTag = putObjectResponse.eTag().also {
1017+
assertThat(it).isEqualTo(expectedEtag)
1018+
}
1019+
1020+
assertThatThrownBy {
1021+
s3Client.putObject(
1022+
{
1023+
it.bucket(bucketName)
1024+
it.key(UPLOAD_FILE_NAME)
1025+
it.ifMatch(nonMatchingEtag)
1026+
}, RequestBody.fromFile(uploadFile)
1027+
)
1028+
}.isInstanceOf(S3Exception::class.java)
1029+
.hasMessageContaining("Service: S3, Status Code: 412")
1030+
}
1031+
1032+
@Test
1033+
@S3VerifiedFailure(year = 2025,
1034+
reason = "Supported only on directory buckets. S3 returns: A header you provided implies functionality that is not implemented.")
1035+
fun `DELETE object succeeds with matching etag`(testInfo: TestInfo) {
1036+
val uploadFile = File(UPLOAD_FILE_NAME)
1037+
val expectedEtag = FileInputStream(uploadFile).let {
1038+
"\"${DigestUtil.hexDigest(it)}\""
1039+
}
1040+
1041+
val (bucketName, putObjectResponse) = givenBucketAndObject(testInfo, UPLOAD_FILE_NAME)
1042+
val eTag = putObjectResponse.eTag().also {
1043+
assertThat(it).isEqualTo(expectedEtag)
1044+
}
1045+
1046+
s3Client.deleteObject {
1047+
it.bucket(bucketName)
1048+
it.key(UPLOAD_FILE_NAME)
1049+
it.ifMatch(expectedEtag)
1050+
}
1051+
}
1052+
1053+
@Test
1054+
@S3VerifiedFailure(year = 2025,
1055+
reason = "Supported only on directory buckets. S3 returns: A header you provided implies functionality that is not implemented.")
1056+
fun `DELETE object succeeds with matching wildcard etag`(testInfo: TestInfo) {
1057+
val uploadFile = File(UPLOAD_FILE_NAME)
1058+
val expectedEtag = FileInputStream(uploadFile).let {
1059+
"\"${DigestUtil.hexDigest(it)}\""
1060+
}
1061+
1062+
val matchingEtag = "\"*\""
1063+
1064+
val (bucketName, putObjectResponse) = givenBucketAndObject(testInfo, UPLOAD_FILE_NAME)
1065+
val eTag = putObjectResponse.eTag().also {
1066+
assertThat(it).isEqualTo(expectedEtag)
1067+
}
1068+
1069+
s3Client.deleteObject {
1070+
it.bucket(bucketName)
1071+
it.key(UPLOAD_FILE_NAME)
1072+
it.ifMatch(matchingEtag)
1073+
}
1074+
}
1075+
1076+
@Test
1077+
@S3VerifiedFailure(year = 2025,
1078+
reason = "Supported only on directory buckets. S3 returns: A header you provided implies functionality that is not implemented.")
1079+
fun `DELETE object succeeds with matching size`(testInfo: TestInfo) {
1080+
val uploadFile = File(UPLOAD_FILE_NAME)
1081+
val expectedEtag = FileInputStream(uploadFile).let {
1082+
"\"${DigestUtil.hexDigest(it)}\""
1083+
}
1084+
1085+
val (bucketName, putObjectResponse) = givenBucketAndObject(testInfo, UPLOAD_FILE_NAME)
1086+
val eTag = putObjectResponse.eTag().also {
1087+
assertThat(it).isEqualTo(expectedEtag)
1088+
}
1089+
1090+
s3Client.deleteObject {
1091+
it.bucket(bucketName)
1092+
it.key(UPLOAD_FILE_NAME)
1093+
it.ifMatchSize(uploadFile.length())
1094+
}
1095+
}
1096+
1097+
1098+
@Test
1099+
@S3VerifiedFailure(year = 2025,
1100+
reason = "Supported only on directory buckets. S3 returns: A header you provided implies functionality that is not implemented.")
1101+
fun `DELETE object succeeds with matching lastModifiedTime`(testInfo: TestInfo) {
1102+
val (bucketName, _) = givenBucketAndObject(testInfo, UPLOAD_FILE_NAME)
1103+
1104+
val lastModified = s3Client.headObject {
1105+
it.bucket(bucketName)
1106+
it.key(UPLOAD_FILE_NAME)
1107+
}.lastModified()
1108+
1109+
s3Client.deleteObject {
1110+
it.bucket(bucketName)
1111+
it.key(UPLOAD_FILE_NAME)
1112+
it.ifMatchLastModifiedTime(lastModified)
1113+
}
1114+
}
1115+
1116+
@Test
1117+
@S3VerifiedFailure(year = 2025,
1118+
reason = "Supported only on directory buckets. S3 returns: A header you provided implies functionality that is not implemented.")
1119+
fun `DELETE object fails with non matching etag`(testInfo: TestInfo) {
1120+
val uploadFile = File(UPLOAD_FILE_NAME)
1121+
val expectedEtag = FileInputStream(uploadFile).let {
1122+
"\"${DigestUtil.hexDigest(it)}\""
1123+
}
1124+
val nonMatchingEtag = "\"$randomName\""
1125+
1126+
val (bucketName, putObjectResponse) = givenBucketAndObject(testInfo, UPLOAD_FILE_NAME)
1127+
val eTag = putObjectResponse.eTag().also {
1128+
assertThat(it).isEqualTo(expectedEtag)
1129+
}
1130+
1131+
assertThatThrownBy {
1132+
s3Client.deleteObject {
1133+
it.bucket(bucketName)
1134+
it.key(UPLOAD_FILE_NAME)
1135+
it.ifMatch(nonMatchingEtag)
1136+
}
1137+
}.isInstanceOf(S3Exception::class.java)
1138+
.hasMessageContaining("Service: S3, Status Code: 412")
1139+
}
1140+
1141+
@Test
1142+
@S3VerifiedFailure(year = 2025,
1143+
reason = "Supported only on directory buckets. S3 returns: A header you provided implies functionality that is not implemented.")
1144+
fun `DELETE object fails with non matching size`(testInfo: TestInfo) {
1145+
val uploadFile = File(UPLOAD_FILE_NAME)
1146+
val expectedEtag = FileInputStream(uploadFile).let {
1147+
"\"${DigestUtil.hexDigest(it)}\""
1148+
}
1149+
val nonMatchingSize = 0L
1150+
1151+
val (bucketName, putObjectResponse) = givenBucketAndObject(testInfo, UPLOAD_FILE_NAME)
1152+
val eTag = putObjectResponse.eTag().also {
1153+
assertThat(it).isEqualTo(expectedEtag)
1154+
}
1155+
1156+
assertThatThrownBy {
1157+
s3Client.deleteObject {
1158+
it.bucket(bucketName)
1159+
it.key(UPLOAD_FILE_NAME)
1160+
it.ifMatchSize(nonMatchingSize)
1161+
}
1162+
}.isInstanceOf(S3Exception::class.java)
1163+
.hasMessageContaining("Service: S3, Status Code: 412")
1164+
}
1165+
1166+
@Test
1167+
@S3VerifiedFailure(year = 2025,
1168+
reason = "Supported only on directory buckets. S3 returns: A header you provided implies functionality that is not implemented.")
1169+
fun `DELETE object fails with non matching lastModifiedTime`(testInfo: TestInfo) {
1170+
val uploadFile = File(UPLOAD_FILE_NAME)
1171+
val expectedEtag = FileInputStream(uploadFile).let {
1172+
"\"${DigestUtil.hexDigest(it)}\""
1173+
}
1174+
val lastModifiedTime = Instant.now().minusSeconds(60)
1175+
1176+
val (bucketName, putObjectResponse) = givenBucketAndObject(testInfo, UPLOAD_FILE_NAME)
1177+
val eTag = putObjectResponse.eTag().also {
1178+
assertThat(it).isEqualTo(expectedEtag)
1179+
}
1180+
1181+
assertThatThrownBy {
1182+
s3Client.deleteObject {
1183+
it.bucket(bucketName)
1184+
it.key(UPLOAD_FILE_NAME)
1185+
it.ifMatchLastModifiedTime(lastModifiedTime)
1186+
}
1187+
}.isInstanceOf(S3Exception::class.java)
1188+
.hasMessageContaining("Service: S3, Status Code: 412")
1189+
}
1190+
9581191
@Test
9591192
@S3VerifiedSuccess(year = 2025)
9601193
fun testHeadObject_successWithNonMatchEtag(testInfo: TestInfo) {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2017-2025 Adobe.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.adobe.testing.s3mock;
18+
19+
import com.adobe.testing.s3mock.dto.ChecksumMode;
20+
import com.adobe.testing.s3mock.dto.ObjectOwnership;
21+
import com.adobe.testing.s3mock.util.AwsHttpHeaders;
22+
import org.springframework.core.convert.converter.Converter;
23+
import org.springframework.lang.NonNull;
24+
import org.springframework.lang.Nullable;
25+
26+
/**
27+
* Converts values of the {@link AwsHttpHeaders#X_AMZ_CHECKSUM_MODE} which is sent by the Amazon
28+
* client.
29+
* Example: x-amz-checksum-mode: ENABLED
30+
* <a href="https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html">API Reference</a>
31+
*/
32+
class ChecksumModeHeaderConverter implements Converter<String, ChecksumMode> {
33+
34+
@Override
35+
@Nullable
36+
public ChecksumMode convert(@NonNull String source) {
37+
return ChecksumMode.fromValue(source);
38+
}
39+
}

0 commit comments

Comments
 (0)