Repository: floci-io/floci (2026-04-01T16:20:10Z)

Issue:
[FEAT] S3 ListParts operation for multipart uploads
## Service

S3

## API Action / Feature

ListParts — `GET /{Bucket}/{Key}?uploadId={UploadId}`

## AWS Documentation

- https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListParts.html

## Why is this needed?

ListParts is essential for resumable multipart uploads. AWS SDKs call ListParts to discover which parts have already been uploaded before resuming an interrupted upload. Without it:

- The AWS SDK `TransferManager` cannot resume multipart uploads
- Any application using resumable uploads will fail
- `aws s3api list-parts` command doesn't work

The S3Service already tracks parts via `MultipartUpload.getParts()`, so the data is available — it just needs an endpoint in the controller.

## Expected behavior

```bash
# Create bucket and initiate multipart upload
aws s3api create-bucket --bucket my-bucket --endpoint-url http://localhost:4566
UPLOAD_ID=$(aws s3api create-multipart-upload --bucket my-bucket --key large-file.bin --endpoint-url http://localhost:4566 --query UploadId --output text)

# Upload a part
aws s3api upload-part --bucket my-bucket --key large-file.bin --upload-id $UPLOAD_ID --part-number 1 --body part1.bin --endpoint-url http://localhost:4566

# List parts should return the uploaded part
aws s3api list-parts --bucket my-bucket --key large-file.bin --upload-id $UPLOAD_ID --endpoint-url http://localhost:4566
# Expected: JSON with Parts array containing part 1 with ETag, Size, PartNumber
```

Expected response format:
```xml
<ListPartsResult>
  <Bucket>my-bucket</Bucket>
  <Key>large-file.bin</Key>
  <UploadId>...</UploadId>
  <Part>
    <PartNumber>1</PartNumber>
    <ETag>"..."</ETag>
    <Size>...</Size>
    <LastModified>...</LastModified>
  </Part>
</ListPartsResult>
```

## Actual behavior

The request matches the S3 controller's `GET /{bucket}/{key:.+}` route and is treated as a regular GetObject, failing because the key doesn't exist yet (multipart upload is in progress).

## Are you willing to contribute a PR?

Yes



Discussion:

Patch:
diff --git a/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java b/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java
index 3d4d67fa..d4781d62 100644
--- a/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java
+++ b/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java
@@ -368,6 +368,9 @@ public Response putObject(@PathParam("bucket") String bucket,
     public Response getObject(@PathParam("bucket") String bucket,
                               @PathParam("key") String key,
                               @QueryParam("versionId") String versionId,
+                              @QueryParam("uploadId") String uploadId,
+                              @QueryParam("max-parts") Integer maxPartsQuery,
+                              @QueryParam("part-number-marker") String partNumberMarkerQuery,
                               @HeaderParam("x-amz-object-attributes") String objectAttributesHeader,
                               @HeaderParam("x-amz-max-parts") Integer maxParts,
                               @HeaderParam("x-amz-part-number-marker") Integer partNumberMarker,
@@ -379,6 +382,9 @@ public Response getObject(@PathParam("bucket") String bucket,
                               @Context UriInfo uriInfo,
                               @Context HttpHeaders httpHeaders) {
         try {
+            if (uploadId != null) {
+                return handleListParts(bucket, key, uploadId, maxPartsQuery, partNumberMarkerQuery);
+            }
             if (hasQueryParam(uriInfo, "tagging")) {
                 return handleGetObjectTagging(bucket, key);
             }
@@ -679,6 +685,63 @@ private Response handleDeleteObjects(String bucket, byte[] body) {
         return Response.ok(builder.build()).type(MediaType.APPLICATION_XML).build();
     }
 
+    private Response handleListParts(String bucket, String key, String uploadId,
+                                      Integer maxPartsParam, String partNumberMarkerParam) {
+        MultipartUpload upload = s3Service.listParts(bucket, key, uploadId);
+        int maxPartsLimit = (maxPartsParam != null && maxPartsParam > 0) ? maxPartsParam : 1000;
+        int markerValue = 0;
+        if (partNumberMarkerParam != null && !partNumberMarkerParam.isBlank()) {
+            try {
+                markerValue = Integer.parseInt(partNumberMarkerParam.trim());
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        final int marker = markerValue;
+
+        List<Part> sortedParts = upload.getParts().entrySet().stream()
+                .sorted(Map.Entry.comparingByKey())
+                .filter(e -> e.getKey() > marker)
+                .limit(maxPartsLimit + 1L)
+                .map(Map.Entry::getValue)
+                .toList();
+
+        boolean truncated = sortedParts.size() > maxPartsLimit;
+        List<Part> page = truncated ? sortedParts.subList(0, maxPartsLimit) : sortedParts;
+        String nextMarker = truncated ? String.valueOf(page.getLast().getPartNumber()) : null;
+
+        XmlBuilder xml = new XmlBuilder()
+                .raw("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
+                .start("ListPartsResult", AwsNamespaces.S3)
+                .elem("Bucket", bucket)
+                .elem("Key", key)
+                .elem("UploadId", uploadId)
+                .elem("PartNumberMarker", String.valueOf(marker))
+                .elem("MaxParts", maxPartsLimit)
+                .elem("IsTruncated", truncated);
+        if (truncated) {
+            xml.elem("NextPartNumberMarker", nextMarker);
+        }
+        for (Part part : page) {
+            xml.start("Part")
+               .elem("PartNumber", part.getPartNumber())
+               .elem("LastModified", ISO_FORMAT.format(part.getLastModified()))
+               .elem("ETag", part.getETag())
+               .elem("Size", part.getSize())
+               .end("Part");
+        }
+        xml.start("Initiator")
+           .elem("ID", "owner")
+           .elem("DisplayName", "owner")
+           .end("Initiator")
+           .start("Owner")
+           .elem("ID", "owner")
+           .elem("DisplayName", "owner")
+           .end("Owner")
+           .elem("StorageClass", upload.getStorageClass());
+        xml.end("ListPartsResult");
+        return Response.ok(xml.build()).type(MediaType.APPLICATION_XML).build();
+    }
+
     private Response handleListMultipartUploads(String bucket) {
         List<MultipartUpload> uploads = s3Service.listMultipartUploads(bucket);
         XmlBuilder xml = new XmlBuilder()
diff --git a/src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java b/src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java
index fa4381fd..e79e9d86 100644
--- a/src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java
+++ b/src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java
@@ -908,6 +908,15 @@ public List<MultipartUpload> listMultipartUploads(String bucket) {
                 .toList();
     }
 
+    public MultipartUpload listParts(String bucket, String key, String uploadId) {
+        MultipartUpload upload = multipartUploads.get(uploadId);
+        if (upload == null || !upload.getBucket().equals(bucket) || !upload.getKey().equals(key)) {
+            throw new AwsException("NoSuchUpload",
+                    "The specified multipart upload does not exist.", 404);
+        }
+        return upload;
+    }
+
     // --- Notification Configuration ---
 
     public void putBucketNotificationConfiguration(String bucketName, NotificationConfiguration config) {


Test patch:
diff --git a/src/test/java/io/github/hectorvent/floci/services/s3/S3MultipartIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/s3/S3MultipartIntegrationTest.java
index 4c376a40..5840cb37 100644
--- a/src/test/java/io/github/hectorvent/floci/services/s3/S3MultipartIntegrationTest.java
+++ b/src/test/java/io/github/hectorvent/floci/services/s3/S3MultipartIntegrationTest.java
@@ -69,6 +69,23 @@ void uploadPart2() {
 
     @Test
     @Order(5)
+    void listParts() {
+        given()
+        .when()
+            .get("/" + BUCKET + "/" + KEY + "?uploadId=" + uploadId)
+        .then()
+            .statusCode(200)
+            .body(containsString("<ListPartsResult"))
+            .body(containsString("<Bucket>" + BUCKET + "</Bucket>"))
+            .body(containsString("<Key>" + KEY + "</Key>"))
+            .body(containsString("<UploadId>" + uploadId + "</UploadId>"))
+            .body(containsString("<PartNumber>1</PartNumber>"))
+            .body(containsString("<PartNumber>2</PartNumber>"))
+            .body(containsString("<IsTruncated>false</IsTruncated>"));
+    }
+
+    @Test
+    @Order(6)
     void listMultipartUploads() {
         given()
         .when()
@@ -80,7 +97,7 @@ void listMultipartUploads() {
     }
 
     @Test
-    @Order(6)
+    @Order(8)
     void completeMultipartUpload() {
         String completeXml = """
                 <CompleteMultipartUpload>
@@ -101,7 +118,7 @@ void completeMultipartUpload() {
     }
 
     @Test
-    @Order(7)
+    @Order(9)
     void getCompletedObject() {
         given()
         .when()
@@ -114,7 +131,7 @@ void getCompletedObject() {
     }
 
     @Test
-    @Order(8)
+    @Order(10)
     void getMultipartObjectAttributes() {
         given()
             .header("x-amz-object-attributes", "ObjectParts,Checksum,StorageClass")
@@ -131,7 +148,7 @@ void getMultipartObjectAttributes() {
     }
 
     @Test
-    @Order(9)
+    @Order(11)
     void multipartUploadNoLongerListed() {
         given()
         .when()
@@ -142,7 +159,7 @@ void multipartUploadNoLongerListed() {
     }
 
     @Test
-    @Order(10)
+    @Order(12)
     void abortMultipartUpload() {
         // Initiate new upload
         String newUploadId = given()
@@ -177,7 +194,7 @@ void abortMultipartUpload() {
     }
 
     @Test
-    @Order(11)
+    @Order(13)
     void uploadPartCopy() {
         // Put a source object
         given()
@@ -240,7 +257,7 @@ void uploadPartCopy() {
     }
 
     @Test
-    @Order(12)
+    @Order(14)
     void cleanUp() {
         given().when().delete("/" + BUCKET + "/" + KEY).then().statusCode(204);
         given().when().delete("/" + BUCKET + "/source-for-copy.bin").then().statusCode(204);