Skip to content

Commit cfad94d

Browse files
Copilotafranken
andcommitted
feat: implement bucket tagging operations (GetBucketTagging, PutBucketTagging, DeleteBucketTagging)
Co-authored-by: afranken <763000+afranken@users.noreply.github.com>
1 parent 9b684ae commit cfad94d

File tree

10 files changed

+346
-8
lines changed

10 files changed

+346
-8
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ Version 5.x is JDK17 LTS bytecode compatible, with Docker and JUnit / direct Jav
145145
## 5.0.0
146146

147147
* Features and fixes
148+
* Implement bucket tagging operations: GetBucketTagging, PutBucketTagging, DeleteBucketTagging.
148149
* Breaking change (file system): Remove "DisplayName" from Owner. (fixes #2738)
149150
* AWS APIs stopped returning "DisplayName" in November 2025.
150151
* This is unfortunately a breaking change for clients starting S3Mock on existing file systems.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ See the [complete operations table](https://docs.aws.amazon.com/AmazonS3/latest/
118118
| [DeleteBucketOwnershipControls](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketOwnershipControls.html) | :x: | |
119119
| [DeleteBucketPolicy](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketPolicy.html) | :x: | |
120120
| [DeleteBucketReplication](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketReplication.html) | :x: | |
121-
| [DeleteBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketTagging.html) | :x: | |
121+
| [DeleteBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketTagging.html) | :white_check_mark: | |
122122
| [DeleteBucketWebsite](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketWebsite.html) | :x: | |
123123
| [DeleteObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html) | :white_check_mark: | |
124124
| [DeleteObjects](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) | :white_check_mark: | |
@@ -143,7 +143,7 @@ See the [complete operations table](https://docs.aws.amazon.com/AmazonS3/latest/
143143
| [GetBucketPolicyStatus](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketPolicyStatus.html) | :x: | |
144144
| [GetBucketReplication](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketReplication.html) | :x: | |
145145
| [GetBucketRequestPayment](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketRequestPayment.html) | :x: | |
146-
| [GetBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketTagging.html) | :x: | |
146+
| [GetBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketTagging.html) | :white_check_mark: | |
147147
| [GetBucketVersioning](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketVersioning.html) | :white_check_mark: | |
148148
| [GetBucketWebsite](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketWebsite.html) | :x: | |
149149
| [GetObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html) | :white_check_mark: | |
@@ -185,7 +185,7 @@ See the [complete operations table](https://docs.aws.amazon.com/AmazonS3/latest/
185185
| [PutBucketPolicy](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketPolicy.html) | :x: | |
186186
| [PutBucketReplication](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketReplication.html) | :x: | |
187187
| [PutBucketRequestPayment](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketRequestPayment.html) | :x: | |
188-
| [PutBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html) | :x: | |
188+
| [PutBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html) | :white_check_mark: | |
189189
| [PutBucketVersioning](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketVersioning.html) | :white_check_mark: | |
190190
| [PutBucketWebsite](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketWebsite.html) | :x: | |
191191
| [PutObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html) | :white_check_mark: | |
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright 2017-2026 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+
package com.adobe.testing.s3mock.its
17+
18+
import org.assertj.core.api.Assertions.assertThat
19+
import org.assertj.core.api.Assertions.assertThatThrownBy
20+
import org.assertj.core.api.InstanceOfAssertFactories
21+
import org.junit.jupiter.api.Test
22+
import org.junit.jupiter.api.TestInfo
23+
import software.amazon.awssdk.awscore.exception.AwsErrorDetails
24+
import software.amazon.awssdk.awscore.exception.AwsServiceException
25+
import software.amazon.awssdk.services.s3.S3Client
26+
import software.amazon.awssdk.services.s3.model.Tag
27+
import software.amazon.awssdk.services.s3.model.Tagging
28+
29+
internal class BucketTaggingIT : S3TestBase() {
30+
private val s3Client: S3Client = createS3Client()
31+
32+
@Test
33+
@S3VerifiedSuccess(year = 2026)
34+
fun `get bucket tagging returns error if not set`(testInfo: TestInfo) {
35+
val bucketName = givenBucket(testInfo)
36+
37+
assertThatThrownBy {
38+
s3Client.getBucketTagging { it.bucket(bucketName) }
39+
}.isInstanceOf(AwsServiceException::class.java)
40+
.hasMessageContaining("Service: S3, Status Code: 404")
41+
.asInstanceOf(InstanceOfAssertFactories.type(AwsServiceException::class.java))
42+
.extracting(AwsServiceException::awsErrorDetails)
43+
.extracting(AwsErrorDetails::errorCode)
44+
.isEqualTo("NoSuchTagSet")
45+
}
46+
47+
@Test
48+
@S3VerifiedSuccess(year = 2026)
49+
fun `put and get bucket tagging succeeds`(testInfo: TestInfo) {
50+
val bucketName = givenBucket(testInfo)
51+
val tag1 = tag("env" to "production")
52+
val tag2 = tag("team" to "platform")
53+
54+
s3Client.putBucketTagging {
55+
it.bucket(bucketName)
56+
it.tagging(Tagging.builder().tagSet(tag1, tag2).build())
57+
}
58+
59+
assertThat(
60+
s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet()
61+
).contains(tag1, tag2)
62+
}
63+
64+
@Test
65+
@S3VerifiedSuccess(year = 2026)
66+
fun `put get and delete bucket tagging succeeds`(testInfo: TestInfo) {
67+
val bucketName = givenBucket(testInfo)
68+
val tag1 = tag("env" to "staging")
69+
val tag2 = tag("owner" to "team-a")
70+
71+
s3Client.putBucketTagging {
72+
it.bucket(bucketName)
73+
it.tagging(Tagging.builder().tagSet(tag1, tag2).build())
74+
}
75+
76+
assertThat(
77+
s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet()
78+
).contains(tag1, tag2)
79+
80+
s3Client.deleteBucketTagging { it.bucket(bucketName) }
81+
82+
assertThatThrownBy {
83+
s3Client.getBucketTagging { it.bucket(bucketName) }
84+
}.isInstanceOf(AwsServiceException::class.java)
85+
.hasMessageContaining("Service: S3, Status Code: 404")
86+
.asInstanceOf(InstanceOfAssertFactories.type(AwsServiceException::class.java))
87+
.extracting(AwsServiceException::awsErrorDetails)
88+
.extracting(AwsErrorDetails::errorCode)
89+
.isEqualTo("NoSuchTagSet")
90+
}
91+
92+
@Test
93+
@S3VerifiedSuccess(year = 2026)
94+
fun `put bucket tagging replaces existing tags`(testInfo: TestInfo) {
95+
val bucketName = givenBucket(testInfo)
96+
val tag1 = tag("env" to "dev")
97+
98+
s3Client.putBucketTagging {
99+
it.bucket(bucketName)
100+
it.tagging(Tagging.builder().tagSet(tag1).build())
101+
}
102+
103+
val tag2 = tag("env" to "prod")
104+
s3Client.putBucketTagging {
105+
it.bucket(bucketName)
106+
it.tagging(Tagging.builder().tagSet(tag2).build())
107+
}
108+
109+
val result = s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet()
110+
assertThat(result).containsExactly(tag2)
111+
assertThat(result).doesNotContain(tag1)
112+
}
113+
114+
private fun tag(pair: Pair<String, String>): Tag =
115+
Tag.builder().key(pair.first).value(pair.second).build()
116+
}

server/src/main/kotlin/com/adobe/testing/s3mock/S3Exception.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ class S3Exception
9393
HttpStatus.NOT_FOUND.value(), "NoSuchLifecycleConfiguration",
9494
"The lifecycle configuration does not exist."
9595
)
96+
val NO_SUCH_TAG_SET: S3Exception = S3Exception(
97+
HttpStatus.NOT_FOUND.value(), "NoSuchTagSet",
98+
"There is no tag set associated with the bucket."
99+
)
96100
val NO_SUCH_KEY: S3Exception =
97101
S3Exception(HttpStatus.NOT_FOUND.value(), "NoSuchKey", "The specified key does not exist.")
98102
val NO_SUCH_VERSION: S3Exception = S3Exception(

server/src/main/kotlin/com/adobe/testing/s3mock/controller/BucketController.kt

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ import com.adobe.testing.s3mock.dto.LocationConstraint
2626
import com.adobe.testing.s3mock.dto.ObjectLockConfiguration
2727
import com.adobe.testing.s3mock.dto.ObjectOwnership
2828
import com.adobe.testing.s3mock.dto.Region
29+
import com.adobe.testing.s3mock.dto.Tag
30+
import com.adobe.testing.s3mock.dto.TagSet
31+
import com.adobe.testing.s3mock.dto.Tagging
2932
import com.adobe.testing.s3mock.dto.VersioningConfiguration
3033
import com.adobe.testing.s3mock.service.BucketService
3134
import com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_BUCKET_OBJECT_LOCK_ENABLED
@@ -45,11 +48,13 @@ import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LIFECYCLE
4548
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LIST_TYPE
4649
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LOCATION
4750
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_OBJECT_LOCK
51+
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_TAGGING
4852
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_UPLOADS
4953
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_VERSIONING
5054
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_VERSIONS
5155
import com.adobe.testing.s3mock.util.AwsHttpParameters.OBJECT_LOCK
5256
import com.adobe.testing.s3mock.util.AwsHttpParameters.START_AFTER
57+
import com.adobe.testing.s3mock.util.AwsHttpParameters.TAGGING
5358
import com.adobe.testing.s3mock.util.AwsHttpParameters.VERSIONING
5459
import com.adobe.testing.s3mock.util.AwsHttpParameters.VERSIONS
5560
import com.adobe.testing.s3mock.util.AwsHttpParameters.VERSION_ID_MARKER
@@ -119,7 +124,8 @@ class BucketController(private val bucketService: BucketService) {
119124
params = [
120125
NOT_OBJECT_LOCK,
121126
NOT_LIFECYCLE,
122-
NOT_VERSIONING
127+
NOT_VERSIONING,
128+
NOT_TAGGING
123129
]
124130
)
125131
@S3Verified(year = 2025)
@@ -186,7 +192,8 @@ class BucketController(private val bucketService: BucketService) {
186192
// AWS SDK V1 pattern
187193
"/{bucketName:.+}/"
188194
], params = [
189-
NOT_LIFECYCLE
195+
NOT_LIFECYCLE,
196+
NOT_TAGGING
190197
]
191198
)
192199
@S3Verified(year = 2025)
@@ -365,6 +372,76 @@ class BucketController(private val bucketService: BucketService) {
365372
return ResponseEntity.noContent().build()
366373
}
367374

375+
/**
376+
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketTagging.html).
377+
*/
378+
@GetMapping(
379+
value = [
380+
// AWS SDK V2 pattern
381+
"/{bucketName:.+}",
382+
// AWS SDK V1 pattern
383+
"/{bucketName:.+}/"
384+
],
385+
params = [
386+
TAGGING,
387+
NOT_LIST_TYPE
388+
],
389+
produces = [
390+
MediaType.APPLICATION_XML_VALUE
391+
]
392+
)
393+
@S3Verified(year = 2026)
394+
fun getBucketTagging(@PathVariable bucketName: String): ResponseEntity<Tagging> {
395+
bucketService.verifyBucketExists(bucketName)
396+
val tags = bucketService.getBucketTagging(bucketName)
397+
return ResponseEntity.ok(Tagging(TagSet(tags)))
398+
}
399+
400+
/**
401+
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html).
402+
*/
403+
@PutMapping(
404+
value = [
405+
// AWS SDK V2 pattern
406+
"/{bucketName:.+}",
407+
// AWS SDK V1 pattern
408+
"/{bucketName:.+}/"
409+
],
410+
params = [
411+
TAGGING
412+
]
413+
)
414+
@S3Verified(year = 2026)
415+
fun putBucketTagging(
416+
@PathVariable bucketName: String,
417+
@RequestBody tagging: Tagging
418+
): ResponseEntity<Void> {
419+
bucketService.verifyBucketExists(bucketName)
420+
bucketService.setBucketTagging(bucketName, tagging.tagSet.tags)
421+
return ResponseEntity.ok().build()
422+
}
423+
424+
/**
425+
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketTagging.html).
426+
*/
427+
@DeleteMapping(
428+
value = [
429+
// AWS SDK V2 pattern
430+
"/{bucketName:.+}",
431+
// AWS SDK V1 pattern
432+
"/{bucketName:.+}/"
433+
],
434+
params = [
435+
TAGGING
436+
]
437+
)
438+
@S3Verified(year = 2026)
439+
fun deleteBucketTagging(@PathVariable bucketName: String): ResponseEntity<Void> {
440+
bucketService.verifyBucketExists(bucketName)
441+
bucketService.deleteBucketTagging(bucketName)
442+
return ResponseEntity.noContent().build()
443+
}
444+
368445
/**
369446
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketLocation.html).
370447
*/
@@ -406,7 +483,8 @@ class BucketController(private val bucketService: BucketService) {
406483
NOT_LIFECYCLE,
407484
NOT_LOCATION,
408485
NOT_VERSIONS,
409-
NOT_VERSIONING
486+
NOT_VERSIONING,
487+
NOT_TAGGING
410488
],
411489
produces = [
412490
MediaType.APPLICATION_XML_VALUE

server/src/main/kotlin/com/adobe/testing/s3mock/service/BucketService.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import com.adobe.testing.s3mock.dto.Owner
3434
import com.adobe.testing.s3mock.dto.Prefix
3535
import com.adobe.testing.s3mock.dto.Region
3636
import com.adobe.testing.s3mock.dto.S3Object
37+
import com.adobe.testing.s3mock.dto.Tag
3738
import com.adobe.testing.s3mock.dto.VersioningConfiguration
3839
import com.adobe.testing.s3mock.store.BucketMetadata
3940
import com.adobe.testing.s3mock.store.BucketStore
@@ -181,6 +182,20 @@ open class BucketService(
181182
return bucketMetadata.bucketLifecycleConfiguration ?: throw S3Exception.NO_SUCH_LIFECYCLE_CONFIGURATION
182183
}
183184

185+
fun setBucketTagging(bucketName: String, tags: List<Tag>?) {
186+
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
187+
bucketStore.storeBucketTagging(bucketMetadata, tags)
188+
}
189+
190+
fun deleteBucketTagging(bucketName: String) {
191+
setBucketTagging(bucketName, null)
192+
}
193+
194+
fun getBucketTagging(bucketName: String): List<Tag> {
195+
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
196+
return bucketMetadata.tags?.takeIf { it.isNotEmpty() } ?: throw S3Exception.NO_SUCH_TAG_SET
197+
}
198+
184199
fun getS3Objects(bucketName: String, prefix: String?): List<S3Object> {
185200
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
186201
return bucketStore.lookupIdsInBucket(prefix, bucketName)

server/src/main/kotlin/com/adobe/testing/s3mock/store/BucketMetadata.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import com.adobe.testing.s3mock.dto.BucketLifecycleConfiguration
2121
import com.adobe.testing.s3mock.dto.LocationInfo
2222
import com.adobe.testing.s3mock.dto.ObjectLockConfiguration
2323
import com.adobe.testing.s3mock.dto.ObjectOwnership
24+
import com.adobe.testing.s3mock.dto.Tag
2425
import com.adobe.testing.s3mock.dto.VersioningConfiguration
2526
import com.fasterxml.jackson.annotation.JsonIgnore
27+
import com.fasterxml.jackson.annotation.JsonInclude
2628
import com.fasterxml.jackson.annotation.JsonProperty
2729
import java.nio.file.Path
2830
import java.util.UUID
@@ -41,6 +43,8 @@ data class BucketMetadata(
4143
val bucketRegion: String,
4244
val bucketInfo: BucketInfo?,
4345
val locationInfo: LocationInfo?,
46+
@JsonInclude(JsonInclude.Include.NON_NULL)
47+
val tags: List<Tag>? = null,
4448
@param:JsonProperty("objects")
4549
private val _objects: MutableMap<String, UUID> = mutableMapOf()
4650
) {
@@ -69,6 +73,9 @@ data class BucketMetadata(
6973
fun withBucketLifecycleConfiguration(bucketLifecycleConfiguration: BucketLifecycleConfiguration?): BucketMetadata =
7074
this.copy(bucketLifecycleConfiguration = bucketLifecycleConfiguration)
7175

76+
fun withTags(tags: List<Tag>?): BucketMetadata =
77+
this.copy(tags = tags)
78+
7279
@get:JsonIgnore
7380
val isVersioningEnabled: Boolean
7481
get() = this.versioningConfiguration?.status == VersioningConfiguration.Status.ENABLED

server/src/main/kotlin/com/adobe/testing/s3mock/store/BucketStore.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.adobe.testing.s3mock.dto.LocationInfo
2222
import com.adobe.testing.s3mock.dto.ObjectLockConfiguration
2323
import com.adobe.testing.s3mock.dto.ObjectLockEnabled.ENABLED
2424
import com.adobe.testing.s3mock.dto.ObjectOwnership
25+
import com.adobe.testing.s3mock.dto.Tag
2526
import com.adobe.testing.s3mock.dto.VersioningConfiguration
2627
import org.slf4j.Logger
2728
import org.slf4j.LoggerFactory
@@ -195,6 +196,15 @@ open class BucketStore(
195196
}
196197
}
197198

199+
fun storeBucketTagging(
200+
metadata: BucketMetadata,
201+
tags: List<Tag>?
202+
) {
203+
synchronized(lockStore[metadata.name]!!) {
204+
writeToDisk(metadata.withTags(tags))
205+
}
206+
}
207+
198208
fun isBucketEmpty(bucketName: String): Boolean {
199209
check(doesBucketExist(bucketName)) { "Requested Bucket does not exist: $bucketName" }
200210
return getBucketMetadata(bucketName).objects.isEmpty()

0 commit comments

Comments
 (0)