Skip to content

Commit 02f5527

Browse files
authored
Support object tagging when creating multipart uploads (#88)
1 parent 624c987 commit 02f5527

File tree

13 files changed

+104
-38
lines changed

13 files changed

+104
-38
lines changed

local-s3-core/src/main/java/com/robothy/s3/core/model/answers/GetObjectAns.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.io.InputStream;
44
import java.util.Map;
5+
import java.util.Optional;
56
import lombok.Builder;
67
import lombok.Getter;
78

@@ -29,4 +30,5 @@ public class GetObjectAns {
2930

3031
private Map<String, String> userMetadata;
3132

33+
private int taggingCount;
3234
}

local-s3-core/src/main/java/com/robothy/s3/core/model/internal/UploadMetadata.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
44
import com.robothy.s3.core.converters.deserializer.UploadPartMetadataMapConverter;
55
import java.util.NavigableMap;
6+
import java.util.Optional;
67
import java.util.concurrent.ConcurrentSkipListMap;
78
import lombok.AllArgsConstructor;
89
import lombok.Builder;
@@ -19,8 +20,14 @@ public class UploadMetadata {
1920

2021
private String contentType;
2122

23+
private String[][] tagging;
24+
2225
@JsonDeserialize(converter = UploadPartMetadataMapConverter.class)
2326
@Builder.Default
2427
private NavigableMap<Integer, UploadPartMetadata> parts = new ConcurrentSkipListMap<>();
2528

29+
30+
public Optional<String[][]> getTagging() {
31+
return Optional.ofNullable(tagging);
32+
}
2633
}

local-s3-core/src/main/java/com/robothy/s3/core/model/request/CreateMultipartUploadOptions.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.robothy.s3.core.model.request;
22

3+
import java.util.Optional;
34
import lombok.Builder;
45
import lombok.Getter;
56

@@ -9,4 +10,9 @@ public class CreateMultipartUploadOptions {
910

1011
private String contentType;
1112

13+
private String[][] tagging;
14+
15+
public Optional<String[][]> getTagging() {
16+
return Optional.ofNullable(tagging);
17+
}
1218
}

local-s3-core/src/main/java/com/robothy/s3/core/service/CompleteMultipartUploadService.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ default CompleteMultipartUploadAns completeMultipartUpload(String bucket, String
7171
.size(size)
7272
.content(in)
7373
.contentType(uploadMetadata.getContentType())
74+
.tagging(uploadMetadata.getTagging().orElse(null))
7475
.build();
7576

7677
putObjectAns = putObject(bucket, key, putObjectOptions);

local-s3-core/src/main/java/com/robothy/s3/core/service/CreateMultipartUploadService.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ default String createMultipartUpload(String bucket, String key, CreateMultipartU
3232
uploads.get(key).put(uploadId, UploadMetadata.builder()
3333
.contentType(options.getContentType())
3434
.createDate(System.currentTimeMillis())
35+
.tagging(options.getTagging().orElse(null))
3536
.build());
3637
return uploadId;
3738
}

local-s3-core/src/main/java/com/robothy/s3/core/service/GetObjectService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ static GetObjectAns getObjectFromUnVersionedBucket(BucketMetadata bucketMetadata
4848
.content(metadataOnly ? null : storage.getInputStream(latestObject.getFileId()))
4949
.etag(latestObject.getEtag())
5050
.userMetadata(latestObject.getUserMetadata())
51+
.taggingCount(latestObject.getTagging().map(tagging -> tagging.length).orElse(0))
5152
.build();
5253
}
5354

@@ -108,6 +109,7 @@ static GetObjectAns getObject(BucketMetadata bucketMetadata, Storage storage,
108109
.size(versionedObjectMetadata.getSize())
109110
.content(metadataOnly ? null : storage.getInputStream(versionedObjectMetadata.getFileId()))
110111
.etag(versionedObjectMetadata.getEtag())
112+
.taggingCount(versionedObjectMetadata.getTagging().map(tagging -> tagging.length).orElse(0))
111113
.userMetadata(versionedObjectMetadata.getUserMetadata())
112114
.build();
113115
}

local-s3-interationtest/src/test/java/com/robothy/s3/test/MultipartUploadIntegrationTest.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@
2929
import java.net.URL;
3030
import java.util.List;
3131
import org.junit.jupiter.api.Test;
32+
import software.amazon.awssdk.core.ResponseInputStream;
33+
import software.amazon.awssdk.core.sync.RequestBody;
34+
import software.amazon.awssdk.services.s3.S3Client;
35+
import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadResponse;
36+
import software.amazon.awssdk.services.s3.model.CompletedPart;
37+
import software.amazon.awssdk.services.s3.model.CreateBucketResponse;
38+
import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse;
39+
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
40+
import software.amazon.awssdk.services.s3.model.Tag;
41+
import software.amazon.awssdk.services.s3.model.Tagging;
42+
import software.amazon.awssdk.services.s3.model.UploadPartResponse;
3243

3344
public class MultipartUploadIntegrationTest {
3445

@@ -227,4 +238,34 @@ void testListParts(AmazonS3 s3) {
227238
s3.listParts(new ListPartsRequest(bucketName, "a.txt", initiateMultipartUploadResult.getUploadId())));
228239
}
229240

241+
@Test
242+
@LocalS3
243+
void testCreateMultipartUploadsWithTagging(S3Client s3) throws Exception {
244+
s3.createBucket(builder -> builder.bucket("my-bucket"));
245+
Tag tag1 = Tag.builder().key("k1").value("v1").build();
246+
Tag tag2 = Tag.builder().key("k2").value("v2").build();
247+
CreateMultipartUploadResponse multipartUpload = s3.createMultipartUpload(builder -> builder.bucket("my-bucket").key("a.txt")
248+
.tagging(Tagging.builder().tagSet(tag1, tag2).build()));
249+
UploadPartResponse part1 = s3.uploadPart(b -> b.bucket("my-bucket")
250+
.uploadId(multipartUpload.uploadId()).key("a.txt").partNumber(1), RequestBody.fromString("Hello"));
251+
UploadPartResponse part2 = s3.uploadPart(b -> b.bucket("my-bucket").uploadId(multipartUpload.uploadId()).key("a.txt").partNumber(2),
252+
RequestBody.fromString("World"));
253+
254+
CompletedPart completedPart1 = CompletedPart.builder().partNumber(1).eTag(part1.eTag()).build();
255+
CompletedPart completedPart2 = CompletedPart.builder().partNumber(2).eTag(part2.eTag()).build();
256+
s3.completeMultipartUpload(b -> b.bucket("my-bucket").key("a.txt").multipartUpload(upload -> upload.parts(
257+
completedPart1, completedPart2)).uploadId(multipartUpload.uploadId()));
258+
259+
ResponseInputStream<GetObjectResponse> completedObject =
260+
s3.getObject(b -> b.bucket("my-bucket").key("a.txt"));
261+
assertEquals("HelloWorld", new String(completedObject.readAllBytes()));
262+
Integer tagCount = completedObject.response().tagCount();
263+
assertEquals(2, tagCount);
264+
265+
List<Tag> tags = s3.getObjectTagging(b -> b.bucket("my-bucket").key("a.txt")).tagSet();
266+
assertEquals(2, tags.size());
267+
assertTrue(tags.contains(tag1));
268+
assertTrue(tags.contains(tag2));
269+
}
270+
230271
}

local-s3-rest/src/main/java/com/robothy/s3/rest/constants/AmzHeaderNames.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ public class AmzHeaderNames {
1919

2020
public static final String X_AMZ_TAGGING = "x-amz-tagging";
2121

22+
public static final String X_AMZ_TAGGING_COUNT = "x-amz-tagging-count";
23+
2224
/**
2325
* Specifies the source object for the copy operation.
2426
*/

local-s3-rest/src/main/java/com/robothy/s3/rest/handler/CreateMultipartUploadController.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.robothy.s3.rest.assertions.RequestAssertions;
1111
import com.robothy.s3.rest.model.response.InitiateMultipartUploadResult;
1212
import com.robothy.s3.rest.service.ServiceFactory;
13+
import com.robothy.s3.rest.utils.RequestUtils;
1314
import com.robothy.s3.rest.utils.ResponseUtils;
1415
import io.netty.handler.codec.http.HttpResponseStatus;
1516

@@ -33,6 +34,7 @@ public void handle(HttpRequest request, HttpResponse response) throws Exception
3334
String key = RequestAssertions.assertObjectKeyProvided(request);
3435
String contentType = request.parameter("content-type").orElse("octet/stream");
3536
String uploadId = uploadService.createMultipartUpload(bucket, key, CreateMultipartUploadOptions.builder()
37+
.tagging(RequestUtils.extractTagging(request).orElse(null))
3638
.contentType(contentType).build());
3739
InitiateMultipartUploadResult result = InitiateMultipartUploadResult.builder()
3840
.bucket(bucket)

local-s3-rest/src/main/java/com/robothy/s3/rest/handler/GetObjectController.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public void handle(HttpRequest request, HttpResponse response) throws Exception
4747
ResponseUtils.addETag(response, getObjectAns.getEtag());
4848
response.status(HttpResponseStatus.OK)
4949
.write(content)
50+
.putHeader(AmzHeaderNames.X_AMZ_TAGGING_COUNT, getObjectAns.getTaggingCount())
5051
.putHeader(HttpHeaderNames.CONTENT_TYPE.toString(), getObjectAns.getContentType())
5152
.putHeader(HttpHeaderNames.CONTENT_LENGTH.toString(), getObjectAns.getSize());
5253
getObjectAns.getUserMetadata().forEach((k, v) -> response.putHeader(AmzHeaderNames.X_AMZ_META_PREFIX + k, v));

local-s3-rest/src/main/java/com/robothy/s3/rest/handler/PutObjectController.java

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import com.robothy.netty.http.HttpRequest;
44
import com.robothy.netty.http.HttpRequestHandler;
55
import com.robothy.netty.http.HttpResponse;
6-
import com.robothy.s3.core.exception.LocalS3InvalidArgumentException;
76
import com.robothy.s3.core.model.answers.PutObjectAns;
87
import com.robothy.s3.core.model.request.PutObjectOptions;
98
import com.robothy.s3.core.service.ObjectService;
@@ -18,8 +17,6 @@
1817
import java.util.HashMap;
1918
import java.util.Map;
2019
import java.util.Objects;
21-
import java.util.Optional;
22-
import org.apache.commons.lang3.StringUtils;
2320

2421
/**
2522
* Handle <a href="https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html">PutObject<a/>.
@@ -43,7 +40,7 @@ public void handle(HttpRequest request, HttpResponse response) throws Exception
4340
.contentType(request.header(HttpHeaderNames.CONTENT_TYPE).orElse(null))
4441
.size(decodedBody.getDecodedContentLength())
4542
.content(decodedBody.getDecodedBody())
46-
.tagging(extractTagging(request))
43+
.tagging(RequestUtils.extractTagging(request).orElse(null))
4744
.userMetadata(extractUserMetadata(request))
4845
.build();
4946

@@ -61,29 +58,6 @@ public void handle(HttpRequest request, HttpResponse response) throws Exception
6158
ResponseUtils.addAmzRequestId(response);
6259
}
6360

64-
// parse tagging from x-amz-tagging header in the put object request.
65-
String[][] extractTagging(HttpRequest request) {
66-
Optional<String> taggingOpt = request.header(AmzHeaderNames.X_AMZ_TAGGING);
67-
String tagging;
68-
if (taggingOpt.isEmpty() || StringUtils.isBlank(tagging = taggingOpt.get())) {
69-
return null;
70-
}
71-
72-
String[] tags = tagging.split("&");
73-
String[][] tagSet = new String[tags.length][2];
74-
for (int i = 0; i < tags.length; i++) {
75-
String[] kv = tags[i].split("=");
76-
if (kv.length != 2) {
77-
throw new LocalS3InvalidArgumentException(AmzHeaderNames.X_AMZ_TAGGING, "Invalid tagging format.");
78-
}
79-
80-
tagSet[i][0] = kv[0];
81-
tagSet[i][1] = kv[1];
82-
}
83-
84-
return tagSet;
85-
}
86-
8761
Map<String, String> extractUserMetadata(HttpRequest request) {
8862
Map<String, String> userMetadata = new HashMap<>();
8963
request.getHeaders()

local-s3-rest/src/main/java/com/robothy/s3/rest/utils/RequestUtils.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package com.robothy.s3.rest.utils;
22

33
import com.robothy.netty.http.HttpRequest;
4+
import com.robothy.s3.core.exception.LocalS3InvalidArgumentException;
45
import com.robothy.s3.rest.constants.AmzHeaderNames;
56
import com.robothy.s3.rest.constants.AmzHeaderValues;
67
import com.robothy.s3.rest.model.request.DecodedAmzRequestBody;
78
import io.netty.buffer.ByteBufInputStream;
89
import io.netty.handler.codec.http.HttpHeaderNames;
910
import java.io.InputStream;
1011
import java.util.Optional;
12+
import org.apache.commons.lang3.StringUtils;
1113

1214
/**
1315
* HTTP Request related utils.
@@ -56,4 +58,32 @@ public static Optional<String> getETag(HttpRequest request) {
5658
return request.header(HttpHeaderNames.ETAG.toString());
5759
}
5860

61+
/**
62+
* Extract tagging from the HTTP header.
63+
*
64+
* @param request HTTP request.
65+
* @return tagging.
66+
*/
67+
public static Optional<String[][]> extractTagging(HttpRequest request) {
68+
Optional<String> taggingOpt = request.header(AmzHeaderNames.X_AMZ_TAGGING);
69+
String tagging;
70+
if (taggingOpt.isEmpty() || StringUtils.isBlank(tagging = taggingOpt.get())) {
71+
return Optional.empty();
72+
}
73+
74+
String[] tags = tagging.split("&");
75+
String[][] tagSet = new String[tags.length][2];
76+
for (int i = 0; i < tags.length; i++) {
77+
String[] kv = tags[i].split("=");
78+
if (kv.length != 2) {
79+
throw new LocalS3InvalidArgumentException(AmzHeaderNames.X_AMZ_TAGGING, "Invalid tagging format.");
80+
}
81+
82+
tagSet[i][0] = kv[0];
83+
tagSet[i][1] = kv[1];
84+
}
85+
86+
return Optional.of(tagSet);
87+
}
88+
5989
}
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,23 @@
1-
package com.robothy.s3.rest.handler;
1+
package com.robothy.s3.rest.utils;
22

3-
import static org.junit.jupiter.api.Assertions.assertEquals;
4-
import static org.junit.jupiter.api.Assertions.assertThrows;
3+
import static org.junit.jupiter.api.Assertions.*;
54
import static org.mockito.Mockito.mock;
65
import static org.mockito.Mockito.when;
76
import com.robothy.netty.http.HttpRequest;
87
import com.robothy.s3.core.exception.LocalS3InvalidArgumentException;
98
import com.robothy.s3.rest.constants.AmzHeaderNames;
10-
import com.robothy.s3.rest.service.ServiceFactory;
119
import java.util.Optional;
1210
import org.junit.jupiter.api.Test;
1311

14-
class PutObjectControllerTest {
12+
class RequestUtilsTest {
1513

1614
@Test
1715
void testExtractTagging() {
1816

1917
HttpRequest request = mock(HttpRequest.class);
20-
ServiceFactory serviceFactory = mock(ServiceFactory.class);
21-
PutObjectController controller = new PutObjectController(serviceFactory);
22-
2318
when(request.header(AmzHeaderNames.X_AMZ_TAGGING)).thenReturn(Optional.of("key1=value1&key2=value2"));
24-
String[][] tagArray = controller.extractTagging(request);
19+
String[][] tagArray = RequestUtils.extractTagging(request).orElse(null);
20+
assertNotNull(tagArray);
2521
assertEquals(2, tagArray.length);
2622
assertEquals("key1", tagArray[0][0]);
2723
assertEquals("value1", tagArray[0][1]);
@@ -30,12 +26,13 @@ void testExtractTagging() {
3026

3127

3228
when(request.header(AmzHeaderNames.X_AMZ_TAGGING)).thenReturn(Optional.of("key1=value1"));
33-
tagArray = controller.extractTagging(request);
29+
tagArray = RequestUtils.extractTagging(request).orElse(null);
30+
assertNotNull(tagArray);
3431
assertEquals(1, tagArray.length);
3532
assertEquals("key1", tagArray[0][0]);
3633
assertEquals("value1", tagArray[0][1]);
3734

3835
when(request.header(AmzHeaderNames.X_AMZ_TAGGING)).thenReturn(Optional.of("invalid"));
39-
assertThrows(LocalS3InvalidArgumentException.class, () -> controller.extractTagging(request));
36+
assertThrows(LocalS3InvalidArgumentException.class, () -> RequestUtils.extractTagging(request));
4037
}
4138
}

0 commit comments

Comments
 (0)