Skip to content

Commit 80432af

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

File tree

10 files changed

+334
-7
lines changed

10 files changed

+334
-7
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.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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.junit.jupiter.api.Test
20+
import org.junit.jupiter.api.TestInfo
21+
import software.amazon.awssdk.services.s3.S3Client
22+
import software.amazon.awssdk.services.s3.model.Tag
23+
24+
internal class BucketTaggingIT : S3TestBase() {
25+
private val s3Client: S3Client = createS3Client()
26+
27+
@Test
28+
@S3VerifiedTodo
29+
fun `GET BucketTagging returns empty tag set when no tags are set`(testInfo: TestInfo) {
30+
val bucketName = givenBucket(testInfo)
31+
32+
val tagSet = s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet()
33+
34+
assertThat(tagSet).isEmpty()
35+
}
36+
37+
@Test
38+
@S3VerifiedTodo
39+
fun `PUT and GET BucketTagging succeeds`(testInfo: TestInfo) {
40+
val bucketName = givenBucket(testInfo)
41+
val tag1 = tag("env", "prod")
42+
val tag2 = tag("team", "backend")
43+
44+
s3Client.putBucketTagging {
45+
it.bucket(bucketName)
46+
it.tagging { t -> t.tagSet(tag1, tag2) }
47+
}
48+
49+
val tagSet = s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet()
50+
51+
assertThat(tagSet).contains(tag1, tag2)
52+
}
53+
54+
@Test
55+
@S3VerifiedTodo
56+
fun `PUT and GET and DELETE BucketTagging succeeds`(testInfo: TestInfo) {
57+
val bucketName = givenBucket(testInfo)
58+
val tag1 = tag("env", "staging")
59+
val tag2 = tag("owner", "team-a")
60+
61+
s3Client.putBucketTagging {
62+
it.bucket(bucketName)
63+
it.tagging { t -> t.tagSet(tag1, tag2) }
64+
}
65+
66+
assertThat(s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet())
67+
.contains(tag1, tag2)
68+
69+
s3Client.deleteBucketTagging { it.bucket(bucketName) }
70+
71+
assertThat(s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet())
72+
.isEmpty()
73+
}
74+
75+
private fun tag(
76+
key: String,
77+
value: String,
78+
): Tag =
79+
Tag
80+
.builder()
81+
.key(key)
82+
.value(value)
83+
.build()
84+
}

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

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017-2025 Adobe.
2+
* Copyright 2017-2026 Adobe.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -26,6 +26,7 @@ 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.Tagging
2930
import com.adobe.testing.s3mock.dto.VersioningConfiguration
3031
import com.adobe.testing.s3mock.service.BucketService
3132
import com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_BUCKET_OBJECT_LOCK_ENABLED
@@ -45,11 +46,13 @@ import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LIFECYCLE
4546
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LIST_TYPE
4647
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LOCATION
4748
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_OBJECT_LOCK
49+
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_TAGGING
4850
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_UPLOADS
4951
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_VERSIONING
5052
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_VERSIONS
5153
import com.adobe.testing.s3mock.util.AwsHttpParameters.OBJECT_LOCK
5254
import com.adobe.testing.s3mock.util.AwsHttpParameters.START_AFTER
55+
import com.adobe.testing.s3mock.util.AwsHttpParameters.TAGGING
5356
import com.adobe.testing.s3mock.util.AwsHttpParameters.VERSIONING
5457
import com.adobe.testing.s3mock.util.AwsHttpParameters.VERSIONS
5558
import com.adobe.testing.s3mock.util.AwsHttpParameters.VERSION_ID_MARKER
@@ -119,6 +122,7 @@ class BucketController(private val bucketService: BucketService) {
119122
params = [
120123
NOT_OBJECT_LOCK,
121124
NOT_LIFECYCLE,
125+
NOT_TAGGING,
122126
NOT_VERSIONING
123127
]
124128
)
@@ -186,7 +190,8 @@ class BucketController(private val bucketService: BucketService) {
186190
// AWS SDK V1 pattern
187191
"/{bucketName:.+}/"
188192
], params = [
189-
NOT_LIFECYCLE
193+
NOT_LIFECYCLE,
194+
NOT_TAGGING
190195
]
191196
)
192197
@S3Verified(year = 2025)
@@ -365,6 +370,76 @@ class BucketController(private val bucketService: BucketService) {
365370
return ResponseEntity.noContent().build()
366371
}
367372

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

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017-2025 Adobe.
2+
* Copyright 2017-2026 Adobe.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -34,6 +34,9 @@ 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
38+
import com.adobe.testing.s3mock.dto.Tagging
39+
import com.adobe.testing.s3mock.dto.TagSet
3740
import com.adobe.testing.s3mock.dto.VersioningConfiguration
3841
import com.adobe.testing.s3mock.store.BucketMetadata
3942
import com.adobe.testing.s3mock.store.BucketStore
@@ -181,6 +184,22 @@ open class BucketService(
181184
return bucketMetadata.bucketLifecycleConfiguration ?: throw S3Exception.NO_SUCH_LIFECYCLE_CONFIGURATION
182185
}
183186

187+
fun getBucketTagging(bucketName: String): Tagging {
188+
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
189+
val tags = bucketMetadata.tagging ?: emptyList()
190+
return Tagging(TagSet(tags))
191+
}
192+
193+
fun setBucketTagging(bucketName: String, tagging: Tagging) {
194+
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
195+
bucketStore.storeBucketTagging(bucketMetadata, tagging.tagSet.tags)
196+
}
197+
198+
fun deleteBucketTagging(bucketName: String) {
199+
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
200+
bucketStore.storeBucketTagging(bucketMetadata, null)
201+
}
202+
184203
fun getS3Objects(bucketName: String, prefix: String?): List<S3Object> {
185204
val bucketMetadata = bucketStore.getBucketMetadata(bucketName)
186205
return bucketStore.lookupIdsInBucket(prefix, bucketName)

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017-2025 Adobe.
2+
* Copyright 2017-2026 Adobe.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -21,6 +21,7 @@ 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
2627
import com.fasterxml.jackson.annotation.JsonProperty
@@ -41,6 +42,7 @@ data class BucketMetadata(
4142
val bucketRegion: String,
4243
val bucketInfo: BucketInfo?,
4344
val locationInfo: LocationInfo?,
45+
val tagging: List<Tag>? = null,
4446
@param:JsonProperty("objects")
4547
private val _objects: MutableMap<String, UUID> = mutableMapOf()
4648
) {
@@ -69,6 +71,9 @@ data class BucketMetadata(
6971
fun withBucketLifecycleConfiguration(bucketLifecycleConfiguration: BucketLifecycleConfiguration?): BucketMetadata =
7072
this.copy(bucketLifecycleConfiguration = bucketLifecycleConfiguration)
7173

74+
fun withTagging(tagging: List<Tag>?): BucketMetadata =
75+
this.copy(tagging = tagging)
76+
7277
@get:JsonIgnore
7378
val isVersioningEnabled: Boolean
7479
get() = this.versioningConfiguration?.status == VersioningConfiguration.Status.ENABLED

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017-2025 Adobe.
2+
* Copyright 2017-2026 Adobe.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -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+
tagging: List<Tag>?
202+
) {
203+
synchronized(lockStore[metadata.name]!!) {
204+
writeToDisk(metadata.withTagging(tagging))
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)