You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ozone.apache.org by si...@apache.org on 2026/10/14 06:35:15 UTC

(ozone) branch master updated: HDDS-16739. S3 ListObjectVersions should return the null version of each object (#11426)

This is an automated email from the ASF dual-hosted git repository.

smengcl pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/master by this push:
     new e038f57986c HDDS-16739. S3 ListObjectVersions should return the null version of each object (#11426)
e038f57986c is described below

commit e038f57986c6087c83e9fe29fe35292e333a6f18
Author: Siyao Meng <50...@users.noreply.github.com>
AuthorDate: Tue Oct 13 23:35:08 2026 -0700

    HDDS-16739. S3 ListObjectVersions should return the null version of each object (#11426)
    
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 .../ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java   |  38 ++++++
 .../hadoop/ozone/s3/commontypes/KeyMetadata.java   |  23 ++++
 .../hadoop/ozone/s3/endpoint/BucketEndpoint.java   |  50 +++++--
 .../BucketOperationNotImplementedHandler.java      |   7 +-
 .../hadoop/ozone/s3/endpoint/EndpointBase.java     |  16 +++
 .../ozone/s3/endpoint/ListVersionsResult.java      | 139 ++++++++++++++++++++
 .../ozone/s3/endpoint/MultiDeleteResponse.java     |  12 ++
 .../ObjectOperationNotImplementedHandler.java      |  13 +-
 .../ozone/s3/endpoint/ObjectTaggingHandler.java    |   2 +-
 .../org/apache/hadoop/ozone/s3/util/S3Consts.java  |   6 +-
 .../hadoop/ozone/s3/endpoint/TestBucketList.java   | 144 +++++++++++++++++++++
 .../s3/endpoint/TestBucketNotImplemented.java      |   9 +-
 .../ozone/s3/endpoint/TestObjectMultiDelete.java   |  44 +++++++
 .../s3/endpoint/TestObjectNotImplemented.java      |  22 +++-
 .../ozone/s3/endpoint/TestObjectTaggingDelete.java |  12 +-
 .../ozone/s3/endpoint/TestObjectTaggingPut.java    |  20 ++-
 16 files changed, 532 insertions(+), 25 deletions(-)

diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java
index 6e88f707672..4e4d5334d98 100644
--- a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java
+++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java
@@ -143,6 +143,9 @@
 import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
 import software.amazon.awssdk.services.s3.model.DeleteObjectTaggingRequest;
 import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest;
+import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse;
+import software.amazon.awssdk.services.s3.model.DeletedObject;
+import software.amazon.awssdk.services.s3.model.EncodingType;
 import software.amazon.awssdk.services.s3.model.ExpirationStatus;
 import software.amazon.awssdk.services.s3.model.GetBucketAclRequest;
 import software.amazon.awssdk.services.s3.model.GetBucketLifecycleConfigurationResponse;
@@ -167,6 +170,7 @@
 import software.amazon.awssdk.services.s3.model.ListDirectoryBucketsResponse;
 import software.amazon.awssdk.services.s3.model.ListMultipartUploadsRequest;
 import software.amazon.awssdk.services.s3.model.ListMultipartUploadsResponse;
+import software.amazon.awssdk.services.s3.model.ListObjectVersionsResponse;
 import software.amazon.awssdk.services.s3.model.ListObjectsRequest;
 import software.amazon.awssdk.services.s3.model.ListObjectsResponse;
 import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
@@ -177,6 +181,7 @@
 import software.amazon.awssdk.services.s3.model.ObjectAttributes;
 import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
 import software.amazon.awssdk.services.s3.model.ObjectLockLegalHoldStatus;
+import software.amazon.awssdk.services.s3.model.ObjectVersion;
 import software.amazon.awssdk.services.s3.model.PutBucketAclRequest;
 import software.amazon.awssdk.services.s3.model.PutBucketTaggingRequest;
 import software.amazon.awssdk.services.s3.model.PutObjectRequest;
@@ -967,6 +972,39 @@ public void testDeleteObjectsWithVersionIdNotImplemented() {
     assertDoesNotThrow(() -> s3Client.headObject(b -> b.bucket(bucketName).key(keyName)));
   }
 
+  @Test
+  public void testDeleteObjectsListedByListObjectVersions() {
+    final String bucketName = getBucketName();
+    final List<String> keyNames = Arrays.asList("dir/key 3", "key+1", "key2");
+    s3Client.createBucket(b -> b.bucket(bucketName));
+    keyNames.forEach(k -> s3Client.putObject(b -> b.bucket(bucketName).key(k), RequestBody.fromString("bar")));
+
+    final List<ObjectVersion> versions = s3Client.listObjectVersionsPaginator(
+            b -> b.bucket(bucketName).maxKeys(2).encodingType(EncodingType.URL))
+        .versions().stream().collect(Collectors.toList());
+
+    // versioning is not implemented, each object only has the null version
+    assertThat(versions).extracting(ObjectVersion::key).containsExactlyElementsOf(keyNames);
+    assertThat(versions).allSatisfy(v -> {
+      assertThat(v.versionId()).isEqualTo("null");
+      assertThat(v.isLatest()).isTrue();
+      assertThat(v.size()).isEqualTo(3L);
+    });
+
+    final DeleteObjectsResponse deleted = s3Client.deleteObjects(b -> b.bucket(bucketName).delete(d -> d.objects(
+        versions.stream()
+            .map(v -> ObjectIdentifier.builder().key(v.key()).versionId(v.versionId()).build())
+            .collect(Collectors.toList()))));
+
+    assertThat(deleted.errors()).isEmpty();
+    assertThat(deleted.deleted()).extracting(DeletedObject::key).containsExactlyInAnyOrderElementsOf(keyNames);
+    assertThat(deleted.deleted()).extracting(DeletedObject::versionId).containsOnly("null");
+    final ListObjectVersionsResponse remaining = s3Client.listObjectVersions(b -> b.bucket(bucketName));
+    assertThat(remaining.versions()).isEmpty();
+    assertThat(remaining.deleteMarkers()).isEmpty();
+    assertThat(remaining.isTruncated()).isFalse();
+  }
+
   @Test
   public void testPutObjectLegalHoldNotImplemented() {
     final String bucketName = getBucketName();
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/commontypes/KeyMetadata.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/commontypes/KeyMetadata.java
index 1ebf28a9bc4..d0cff56f31d 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/commontypes/KeyMetadata.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/commontypes/KeyMetadata.java
@@ -51,6 +51,13 @@ public class KeyMetadata {
   @XmlElement(name = "StorageClass")
   private String storageClass;
 
+  // VersionId and IsLatest are only set in ListObjectVersions responses
+  @XmlElement(name = "VersionId")
+  private String versionId;
+
+  @XmlElement(name = "IsLatest")
+  private Boolean isLatest;
+
   public EncodingTypeObject getKey() {
     return key;
   }
@@ -98,4 +105,20 @@ public String getStorageClass() {
   public void setStorageClass(String storageClass) {
     this.storageClass = storageClass;
   }
+
+  public String getVersionId() {
+    return versionId;
+  }
+
+  public void setVersionId(String versionId) {
+    this.versionId = versionId;
+  }
+
+  public Boolean getIsLatest() {
+    return isLatest;
+  }
+
+  public void setIsLatest(Boolean latest) {
+    this.isLatest = latest;
+  }
 }
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java
index 9c14629bb02..db9d6a0cb30 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketEndpoint.java
@@ -105,14 +105,14 @@ public Response get(
 
   @Override
   Response handleGetRequest(S3RequestContext context, String bucketName) throws IOException, OS3Exception {
-    final String continueToken = queryParams().get(QueryParams.CONTINUATION_TOKEN);
+    final String continueToken = isListObjectVersions() ? null : queryParams().get(QueryParams.CONTINUATION_TOKEN);
     final String delimiter = queryParams().containsKey(QueryParams.DELIMITER) ?
         queryParams().get(QueryParams.DELIMITER) : null;
     final String encodingType = queryParams().get(QueryParams.ENCODING_TYPE);
-    final String marker = queryParams().get(QueryParams.MARKER);
+    final String marker = getListMarker();
     int maxKeys = queryParams().getInt(QueryParams.MAX_KEYS, 1000);
     String prefix = queryParams().get(QueryParams.PREFIX, "");
-    String startAfter = queryParams().get(QueryParams.START_AFTER);
+    String startAfter = isListObjectVersions() ? null : queryParams().get(QueryParams.START_AFTER);
     boolean includeOwner = shouldIncludeOwnerInListResponse();
     Iterator<? extends OzoneKey> ozoneKeyIterator = null;
     // AWS S3 treats an empty continuation-token as no token: list from the
@@ -247,7 +247,34 @@ Response handleGetRequest(S3RequestContext context, String bucketName) throws IO
     context.getPerf().appendCount(keyCount);
     context.getPerf().appendOpLatencyNanos(opLatencyNs);
     response.setKeyCount(keyCount);
-    return Response.ok(response).build();
+    return Response.ok(toListResult(response)).build();
+  }
+
+  private boolean isListObjectVersions() {
+    return queryParams().get(QueryParams.VERSIONS) != null;
+  }
+
+  /** ListObjectVersions lists the same keys as ListObjects (V1), but paginates with key-marker instead of marker. */
+  private String getListMarker() throws OS3Exception {
+    if (!isListObjectVersions()) {
+      return queryParams().get(QueryParams.MARKER);
+    }
+    final String keyMarker = queryParams().get(QueryParams.KEY_MARKER);
+    final String versionIdMarker = queryParams().get(QueryParams.VERSION_ID_MARKER);
+    if (StringUtils.isNotEmpty(versionIdMarker)) {
+      if (StringUtils.isEmpty(keyMarker)) {
+        throw newError(S3ErrorTable.INVALID_ARGUMENT, "A version-id marker cannot be specified without a key marker");
+      }
+      if (isUnsupportedVersionId(versionIdMarker)) {
+        throw newError(S3ErrorTable.NOT_IMPLEMENTED, versionIdMarker);
+      }
+    }
+    return keyMarker;
+  }
+
+  private Object toListResult(ListObjectResponse response) {
+    return isListObjectVersions()
+        ? ListVersionsResult.of(response, queryParams().get(QueryParams.VERSION_ID_MARKER)) : response;
   }
 
   private int validateMaxKeys(int maxKeys) throws OS3Exception {
@@ -347,8 +374,9 @@ public MultiDeleteResponse multiDelete(
       throw newError(S3ErrorTable.MALFORMED_XML, bucketName);
     }
 
-    // Deleting a specific version is not implemented; ignoring VersionId would delete the current object instead.
-    if (request.getObjects() != null && request.getObjects().stream().anyMatch(o -> o.getVersionId() != null)) {
+    // Only the null version can be deleted; ignoring any other VersionId would delete the current object instead.
+    if (request.getObjects() != null
+        && request.getObjects().stream().anyMatch(o -> isUnsupportedVersionId(o.getVersionId()))) {
       throw newError(S3ErrorTable.NOT_IMPLEMENTED, bucketName);
     }
 
@@ -380,11 +408,15 @@ public MultiDeleteResponse multiDelete(
               ResultCodes.KEY_NOT_FOUND.name().equals(error.getCode());
           if (deleted) {
             if (!request.isQuiet()) {
-              result.addDeleted(new DeletedObject(d.getKey()));
+              DeletedObject deletedObject = new DeletedObject(d.getKey());
+              deletedObject.setVersionId(d.getVersionId());
+              result.addDeleted(deletedObject);
             }
           } else {
             failedDeletes.add(d.getKey());
-            result.addError(new Error(d.getKey(), error.getCode(), error.getMessage()));
+            Error deleteError = new Error(d.getKey(), error.getCode(), error.getMessage());
+            deleteError.setVersionId(d.getVersionId());
+            result.addError(deleteError);
           }
         }
         getMetrics().updateDeleteKeySuccessStats(startNanos);
@@ -493,6 +525,6 @@ private void handleRuntimeException(RuntimeException ex, S3RequestContext contex
   private boolean shouldIncludeOwnerInListResponse() {
     int listType = queryParams().getInt(QueryParams.LIST_TYPE, 1);
     boolean fetchOwner = queryParams().getBoolean(QueryParams.FETCH_OWNER, false);
-    return listType != 2 || fetchOwner;
+    return isListObjectVersions() || listType != 2 || fetchOwner;
   }
 }
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketOperationNotImplementedHandler.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketOperationNotImplementedHandler.java
index 0ca3cd5b413..bf97576daed 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketOperationNotImplementedHandler.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/BucketOperationNotImplementedHandler.java
@@ -18,6 +18,7 @@
 package org.apache.hadoop.ozone.s3.endpoint;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import java.io.InputStream;
 import java.util.Set;
 import javax.ws.rs.core.Response;
@@ -47,9 +48,13 @@ class BucketOperationNotImplementedHandler extends BucketOperationHandler {
       QueryParams.PUBLIC_ACCESS_BLOCK, QueryParams.REPLICATION, QueryParams.REQUEST_PAYMENT, QueryParams.SESSION,
       QueryParams.VERSIONING, QueryParams.VERSIONS, QueryParams.WEBSITE);
 
+  // ListObjectVersions is implemented by BucketEndpoint
+  private static final Set<String> GET_SUBRESOURCES =
+      Sets.difference(SUBRESOURCES, ImmutableSet.of(QueryParams.VERSIONS)).immutableCopy();
+
   @Override
   Response handleGetRequest(S3RequestContext context, String bucketName) {
-    return rejectNotImplemented(context, SUBRESOURCES, bucketName);
+    return rejectNotImplemented(context, GET_SUBRESOURCES, bucketName);
   }
 
   @Override
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java
index 74a90f1fc6e..3b0eb8f96cf 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java
@@ -43,6 +43,7 @@
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.newError;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.AWS_TAG_PREFIX;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.CUSTOM_METADATA_HEADER_PREFIX;
+import static org.apache.hadoop.ozone.s3.util.S3Consts.NULL_VERSION_ID;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.RESERVED_USER_METADATA_KEY_PREFIX;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CONFIG_HEADER;
@@ -792,6 +793,21 @@ protected void validateObjectKeyUri(String keyPath) throws OS3Exception {
     }
   }
 
+  /**
+   * Versioning is not implemented, so each object only has the null version, i.e. the current object.
+   *
+   * @return true if {@code versionId} refers to any other version
+   * @throws OS3Exception InvalidArgument if {@code versionId} is empty, same as AWS S3
+   */
+  static boolean isUnsupportedVersionId(@Nullable String versionId) {
+    if (versionId != null && versionId.isEmpty()) {
+      OS3Exception ex = newError(INVALID_ARGUMENT, versionId);
+      ex.setErrorMessage("Version id cannot be the empty string");
+      throw ex;
+    }
+    return versionId != null && !NULL_VERSION_ID.equals(versionId);
+  }
+
   /**
    * Rejects the request with {@code NotImplemented} if it has any of the {@code subresources}.
    *
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ListVersionsResult.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ListVersionsResult.java
new file mode 100644
index 00000000000..309f4b7303c
--- /dev/null
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ListVersionsResult.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3.endpoint;
+
+import static org.apache.hadoop.ozone.s3.util.S3Consts.NULL_VERSION_ID;
+
+import java.util.List;
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+import org.apache.hadoop.ozone.s3.commontypes.CommonPrefix;
+import org.apache.hadoop.ozone.s3.commontypes.EncodingTypeObject;
+import org.apache.hadoop.ozone.s3.commontypes.KeyMetadata;
+import org.apache.hadoop.ozone.s3.commontypes.ObjectKeyNameAdapter;
+import org.apache.hadoop.ozone.s3.util.S3Consts;
+
+/**
+ * Response of ListObjectVersions.
+ * <p>
+ * Versioning is not implemented, so each object is listed with its only version, the null version.
+ * This is the same as what AWS S3 returns for a bucket that never had versioning enabled.
+ */
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement(name = "ListVersionsResult", namespace = S3Consts.S3_XML_NAMESPACE)
+public class ListVersionsResult {
+
+  @XmlElement(name = "Name")
+  private String name;
+
+  @XmlJavaTypeAdapter(ObjectKeyNameAdapter.class)
+  @XmlElement(name = "Prefix")
+  private EncodingTypeObject prefix;
+
+  @XmlJavaTypeAdapter(ObjectKeyNameAdapter.class)
+  @XmlElement(name = "KeyMarker")
+  private EncodingTypeObject keyMarker;
+
+  @XmlElement(name = "VersionIdMarker")
+  private String versionIdMarker;
+
+  @XmlJavaTypeAdapter(ObjectKeyNameAdapter.class)
+  @XmlElement(name = "NextKeyMarker")
+  private EncodingTypeObject nextKeyMarker;
+
+  @XmlElement(name = "NextVersionIdMarker")
+  private String nextVersionIdMarker;
+
+  @XmlElement(name = "MaxKeys")
+  private int maxKeys;
+
+  @XmlJavaTypeAdapter(ObjectKeyNameAdapter.class)
+  @XmlElement(name = "Delimiter")
+  private EncodingTypeObject delimiter;
+
+  @XmlElement(name = "EncodingType")
+  private String encodingType;
+
+  @XmlElement(name = "IsTruncated")
+  private boolean isTruncated;
+
+  @XmlElement(name = "Version")
+  private List<KeyMetadata> versions;
+
+  @XmlElement(name = "CommonPrefixes")
+  private List<CommonPrefix> commonPrefixes;
+
+  /**
+   * @param listing result of listing the bucket with {@code key-marker} as the marker
+   * @param versionIdMarker {@code version-id-marker} of the request
+   */
+  static ListVersionsResult of(ListObjectResponse listing, String versionIdMarker) {
+    final String encodingType = listing.getEncodingType();
+    final ListVersionsResult result = new ListVersionsResult();
+    result.name = listing.getName();
+    result.prefix = listing.getPrefix();
+    result.keyMarker = EncodingTypeObject.createNullable(listing.getMarker(), encodingType);
+    result.versionIdMarker = versionIdMarker == null ? "" : versionIdMarker;
+    result.maxKeys = listing.getMaxKeys();
+    result.delimiter = listing.getDelimiter();
+    result.encodingType = encodingType;
+    result.isTruncated = listing.isTruncated();
+    if (listing.isTruncated()) {
+      result.nextKeyMarker = EncodingTypeObject.createNullable(listing.getNextMarker(), encodingType);
+      result.nextVersionIdMarker = NULL_VERSION_ID;
+    }
+    result.versions = listing.getContents();
+    for (KeyMetadata version : result.versions) {
+      version.setVersionId(NULL_VERSION_ID);
+      version.setIsLatest(true);
+    }
+    result.commonPrefixes = listing.getCommonPrefixes();
+    return result;
+  }
+
+  public EncodingTypeObject getKeyMarker() {
+    return keyMarker;
+  }
+
+  public String getVersionIdMarker() {
+    return versionIdMarker;
+  }
+
+  public EncodingTypeObject getNextKeyMarker() {
+    return nextKeyMarker;
+  }
+
+  public String getNextVersionIdMarker() {
+    return nextVersionIdMarker;
+  }
+
+  public boolean isTruncated() {
+    return isTruncated;
+  }
+
+  public List<KeyMetadata> getVersions() {
+    return versions;
+  }
+
+  public List<CommonPrefix> getCommonPrefixes() {
+    return commonPrefixes;
+  }
+}
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/MultiDeleteResponse.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/MultiDeleteResponse.java
index 222a5623934..f2d2622838a 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/MultiDeleteResponse.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/MultiDeleteResponse.java
@@ -74,6 +74,7 @@ public static class DeletedObject {
     @XmlElement(name = "Key")
     private String key;
 
+    @XmlElement(name = "VersionId")
     private String versionId;
 
     public DeletedObject() {
@@ -116,6 +117,9 @@ public static class Error {
     @XmlElement(name = "Message")
     private String message;
 
+    @XmlElement(name = "VersionId")
+    private String versionId;
+
     public Error() {
     }
 
@@ -148,5 +152,13 @@ public String getMessage() {
     public void setMessage(String message) {
       this.message = message;
     }
+
+    public String getVersionId() {
+      return versionId;
+    }
+
+    public void setVersionId(String versionId) {
+      this.versionId = versionId;
+    }
   }
 }
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectOperationNotImplementedHandler.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectOperationNotImplementedHandler.java
index afce9aca85f..2302c48897a 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectOperationNotImplementedHandler.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectOperationNotImplementedHandler.java
@@ -21,6 +21,7 @@
 import java.io.InputStream;
 import java.util.Set;
 import javax.ws.rs.core.Response;
+import org.apache.hadoop.ozone.audit.S3GAction;
 import org.apache.hadoop.ozone.s3.endpoint.ObjectEndpoint.ObjectRequestContext;
 import org.apache.hadoop.ozone.s3.util.S3Consts.QueryParams;
 
@@ -42,12 +43,6 @@ class ObjectOperationNotImplementedHandler extends ObjectOperationHandler {
       QueryParams.ANNOTATION, QueryParams.ENCRYPTION, QueryParams.LEGAL_HOLD, QueryParams.RENAME_OBJECT,
       QueryParams.RETENTION, QueryParams.UPLOADS);
 
-  // DeleteObject with versionId targets a specific version; ignoring it would delete the current object instead.
-  private static final Set<String> DELETE_SUBRESOURCES = ImmutableSet.<String>builder()
-      .addAll(SUBRESOURCES)
-      .add(QueryParams.VERSION_ID)
-      .build();
-
   @Override
   Response handleGetRequest(ObjectRequestContext context, String keyName) {
     return rejectNotImplemented(context, SUBRESOURCES, keyName);
@@ -60,6 +55,10 @@ Response handlePutRequest(ObjectRequestContext context, String keyName, InputStr
 
   @Override
   Response handleDeleteRequest(ObjectRequestContext context, String keyName) {
-    return rejectNotImplemented(context, DELETE_SUBRESOURCES, keyName);
+    // DeleteObject with versionId targets a specific version; ignoring it would delete the current object instead.
+    if (isUnsupportedVersionId(queryParams().get(QueryParams.VERSION_ID))) {
+      return rejectNotImplemented(context, S3GAction.NOT_IMPLEMENTED, keyName);
+    }
+    return rejectNotImplemented(context, SUBRESOURCES, keyName);
   }
 }
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectTaggingHandler.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectTaggingHandler.java
index 5c04df9a90d..8f5ed82558b 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectTaggingHandler.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectTaggingHandler.java
@@ -119,7 +119,7 @@ Response handleGetRequest(ObjectRequestContext context, String keyName)
 
   // Tagging a specific version is not implemented; ignoring versionId would modify the tags of the current object.
   private void rejectVersionId(String keyName) {
-    if (queryParams().get(S3Consts.QueryParams.VERSION_ID) != null) {
+    if (isUnsupportedVersionId(queryParams().get(S3Consts.QueryParams.VERSION_ID))) {
       throw S3ErrorTable.newError(S3ErrorTable.NOT_IMPLEMENTED, keyName);
     }
   }
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java
index 0cb61678d68..c3300d37dc3 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java
@@ -114,6 +114,9 @@ public final class S3Consts {
   /** AWS S3 maximum number of keys per DeleteObjects request. */
   public static final int S3_DELETE_OBJECTS_MAX_KEYS = 1000;
 
+  /** Version ID of an object in a bucket that never had versioning enabled. */
+  public static final String NULL_VERSION_ID = "null";
+
   // Bucket owner condition headers
   // See https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-owner-condition.html
   public static final String EXPECTED_BUCKET_OWNER_HEADER = "x-amz-expected-bucket-owner";
@@ -192,6 +195,8 @@ public static final class QueryParams {
     public static final String LIFECYCLE = "lifecycle";
     public static final String ATTRIBUTES = "attributes";
     public static final String VERSION_ID = "versionId";
+    public static final String VERSION_ID_MARKER = "version-id-marker";
+    public static final String VERSIONS = "versions";
 
     // Subresources of S3 APIs that are not implemented. They are only recognized so that the
     // request can be rejected with NotImplemented instead of falling through to another operation.
@@ -224,7 +229,6 @@ public static final class QueryParams {
     public static final String RETENTION = "retention";
     public static final String SESSION = "session";
     public static final String VERSIONING = "versioning";
-    public static final String VERSIONS = "versions";
     public static final String WEBSITE = "website";
 
     private QueryParams() {
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestBucketList.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestBucketList.java
index e6a69f95111..dd872757500 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestBucketList.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestBucketList.java
@@ -19,7 +19,10 @@
 
 import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_LIST_MAX_KEYS_LIMIT;
 import static org.apache.hadoop.ozone.s3.endpoint.EndpointBuilder.newBucketEndpointBuilder;
+import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertErrorResponse;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.ENCODING_TYPE;
+import static org.apache.hadoop.ozone.s3.util.S3Consts.NULL_VERSION_ID;
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -687,6 +690,147 @@ public void continuationTokenXmlElementName() throws Exception {
         "response must not use the non-AWS <continueToken> element");
   }
 
+  @Test
+  public void listObjectVersionsReturnsNullVersionOfEachKey() throws OS3Exception, IOException {
+    setup("dir1/file2", "file+1", "file3");
+
+    bucketEndpoint.queryParamsForTest().set(QueryParams.VERSIONS, "");
+    bucketEndpoint.queryParamsForTest().set(QueryParams.DELIMITER, "/");
+    bucketEndpoint.queryParamsForTest().set(QueryParams.ENCODING_TYPE, ENCODING_TYPE);
+    ListVersionsResult result = (ListVersionsResult) bucketEndpoint.get(BUCKET_NAME).getEntity();
+
+    assertThat(result.isTruncated()).isFalse();
+    assertThat(result.getNextKeyMarker()).isNull();
+    assertThat(result.getNextVersionIdMarker()).isNull();
+    assertThat(result.getCommonPrefixes()).extracting(p -> p.getPrefix().getName()).containsExactly("dir1/");
+    assertThat(result.getVersions()).extracting(v -> v.getKey().getName()).containsExactly("file+1", "file3");
+    assertThat(result.getVersions()).allSatisfy(v -> {
+      assertThat(v.getVersionId()).isEqualTo(NULL_VERSION_ID);
+      assertThat(v.getIsLatest()).isTrue();
+      assertThat(v.getOwner()).isNotNull();
+    });
+
+    StringWriter writer = new StringWriter();
+    JAXB.marshal(result, writer);
+    // LastModified and the owner depend on the time and the user running the test
+    assertThat(writer.toString().replaceAll("<(LastModified|DisplayName|ID)>[^<]*</\\1>", "<$1/>")).isEqualTo("""
+        <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+        <ListVersionsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+            <Name>b1</Name>
+            <Prefix></Prefix>
+            <KeyMarker></KeyMarker>
+            <VersionIdMarker></VersionIdMarker>
+            <MaxKeys>1000</MaxKeys>
+            <Delimiter>/</Delimiter>
+            <EncodingType>url</EncodingType>
+            <IsTruncated>false</IsTruncated>
+            <Version>
+                <Key>file%2B1</Key>
+                <Owner>
+                    <DisplayName/>
+                    <ID/>
+                </Owner>
+                <LastModified/>
+                <Size>0</Size>
+                <StorageClass>REDUCED_REDUNDANCY</StorageClass>
+                <VersionId>null</VersionId>
+                <IsLatest>true</IsLatest>
+            </Version>
+            <Version>
+                <Key>file3</Key>
+                <Owner>
+                    <DisplayName/>
+                    <ID/>
+                </Owner>
+                <LastModified/>
+                <Size>0</Size>
+                <StorageClass>REDUCED_REDUNDANCY</StorageClass>
+                <VersionId>null</VersionId>
+                <IsLatest>true</IsLatest>
+            </Version>
+            <CommonPrefixes>
+                <Prefix>dir1/</Prefix>
+            </CommonPrefixes>
+        </ListVersionsResult>
+        """);
+  }
+
+  @Test
+  public void listObjectVersionsPaginatesWithKeyMarker() throws OS3Exception, IOException {
+    setup("file+1", "file2", "file3");
+
+    bucketEndpoint.queryParamsForTest().set(QueryParams.VERSIONS, "");
+    bucketEndpoint.queryParamsForTest().set(QueryParams.ENCODING_TYPE, ENCODING_TYPE);
+    bucketEndpoint.queryParamsForTest().setInt(QueryParams.MAX_KEYS, 1);
+    // marker is the pagination parameter of ListObjects, it must be ignored
+    bucketEndpoint.queryParamsForTest().set(QueryParams.MARKER, "file3");
+    ListVersionsResult first = (ListVersionsResult) bucketEndpoint.get(BUCKET_NAME).getEntity();
+
+    assertThat(first.isTruncated()).isTrue();
+    assertThat(first.getVersions()).extracting(v -> v.getKey().getName()).containsExactly("file+1");
+    assertThat(first.getNextKeyMarker().getName()).isEqualTo("file+1");
+    assertThat(first.getNextVersionIdMarker()).isEqualTo(NULL_VERSION_ID);
+    StringWriter writer = new StringWriter();
+    JAXB.marshal(first, writer);
+    assertThat(writer.toString())
+        .contains("<NextKeyMarker>file%2B1</NextKeyMarker>", "<NextVersionIdMarker>null</NextVersionIdMarker>");
+
+    bucketEndpoint.queryParamsForTest().unset(QueryParams.MAX_KEYS);
+    bucketEndpoint.queryParamsForTest().set(QueryParams.KEY_MARKER, first.getNextKeyMarker().getName());
+    bucketEndpoint.queryParamsForTest().set(QueryParams.VERSION_ID_MARKER, first.getNextVersionIdMarker());
+    ListVersionsResult second = (ListVersionsResult) bucketEndpoint.get(BUCKET_NAME).getEntity();
+
+    assertThat(second.isTruncated()).isFalse();
+    assertThat(second.getKeyMarker().getName()).isEqualTo("file+1");
+    assertThat(second.getVersionIdMarker()).isEqualTo(NULL_VERSION_ID);
+    assertThat(second.getVersions()).extracting(v -> v.getKey().getName()).containsExactly("file2", "file3");
+  }
+
+  @Test
+  public void listObjectVersionsRejectsVersionIdMarkerWithoutKeyMarker() throws IOException {
+    setup("file1");
+
+    bucketEndpoint.queryParamsForTest().set(QueryParams.VERSIONS, "");
+    bucketEndpoint.queryParamsForTest().set(QueryParams.VERSION_ID_MARKER, NULL_VERSION_ID);
+
+    assertErrorResponse(S3ErrorTable.INVALID_ARGUMENT, () -> bucketEndpoint.get(BUCKET_NAME));
+  }
+
+  @Test
+  public void listObjectVersionsRejectsNonNullVersionIdMarker() throws IOException {
+    setup("file1", "file2");
+
+    bucketEndpoint.queryParamsForTest().set(QueryParams.VERSIONS, "");
+    bucketEndpoint.queryParamsForTest().set(QueryParams.KEY_MARKER, "file1");
+    bucketEndpoint.queryParamsForTest().set(QueryParams.VERSION_ID_MARKER, "nonexistent");
+
+    assertErrorResponse(S3ErrorTable.NOT_IMPLEMENTED, () -> bucketEndpoint.get(BUCKET_NAME));
+  }
+
+  @Test
+  public void listObjectVersionsIgnoresListObjectsV2Params() throws OS3Exception, IOException {
+    setup("file1", "file2");
+
+    bucketEndpoint.queryParamsForTest().set(QueryParams.VERSIONS, "");
+    bucketEndpoint.queryParamsForTest().setInt(QueryParams.LIST_TYPE, 2);
+    bucketEndpoint.queryParamsForTest().set(QueryParams.START_AFTER, "file1");
+    bucketEndpoint.queryParamsForTest().set(QueryParams.CONTINUATION_TOKEN, "invalid");
+    ListVersionsResult result = (ListVersionsResult) bucketEndpoint.get(BUCKET_NAME).getEntity();
+
+    assertThat(result.getVersions()).extracting(v -> v.getKey().getName()).containsExactly("file1", "file2");
+    assertThat(result.getVersions()).allSatisfy(v -> assertThat(v.getOwner()).isNotNull());
+  }
+
+  @Test
+  public void listObjectsDoesNotReturnVersionId() throws Exception {
+    setup("file1");
+
+    StringWriter writer = new StringWriter();
+    JAXB.marshal(bucketEndpoint.get(BUCKET_NAME).getEntity(), writer);
+
+    assertThat(writer.toString()).contains("<ListBucketResult ").doesNotContain("VersionId", "IsLatest");
+  }
+
   @Test
   public void listObjectOwnerOmittedForListV2ByDefault() throws OS3Exception, IOException {
     setup("key1", "key2");
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestBucketNotImplemented.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestBucketNotImplemented.java
index c6c9b29a2e6..0a6d772c818 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestBucketNotImplemented.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestBucketNotImplemented.java
@@ -60,12 +60,15 @@ private static Stream<String> subresources() {
         QueryParams.METADATA_TABLE, QueryParams.METRICS, QueryParams.NOTIFICATION, QueryParams.OBJECT_LOCK,
         QueryParams.OWNERSHIP_CONTROLS, QueryParams.POLICY, QueryParams.POLICY_STATUS, QueryParams.PUBLIC_ACCESS_BLOCK,
         QueryParams.REPLICATION, QueryParams.REQUEST_PAYMENT, QueryParams.SESSION, QueryParams.VERSIONING,
-        QueryParams.VERSIONS, QueryParams.WEBSITE);
+        QueryParams.WEBSITE);
   }
 
-  /** Same as {@link #subresources()}, plus uploads, for which only GET (ListMultipartUploads) is implemented. */
+  /**
+   * Same as {@link #subresources()}, plus uploads and versions, for which only GET (ListMultipartUploads and
+   * ListObjectVersions) is implemented.
+   */
   private static Stream<String> putSubresources() {
-    return Stream.concat(subresources(), Stream.of(QueryParams.UPLOADS));
+    return Stream.concat(subresources(), Stream.of(QueryParams.UPLOADS, QueryParams.VERSIONS));
   }
 
   /** Same as {@link #putSubresources()}, plus acl, for which only GET and PUT are implemented. */
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectMultiDelete.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectMultiDelete.java
index a9a2a1d278d..ee0dc3a68e5 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectMultiDelete.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectMultiDelete.java
@@ -19,9 +19,13 @@
 
 import static java.util.Arrays.asList;
 import static java.util.Collections.singleton;
+import static java.util.Collections.singletonMap;
 import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertErrorResponse;
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.MALFORMED_XML;
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.NOT_IMPLEMENTED;
+import static org.apache.hadoop.ozone.s3.util.S3Consts.NULL_VERSION_ID;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.anyBoolean;
@@ -216,6 +220,46 @@ public void multiDeleteWithVersionIdIsNotImplemented() throws Exception {
     assertEquals(Sets.newHashSet("key1", "key2", "key3"), keysAtTheEnd);
   }
 
+  @Test
+  public void multiDeleteWithNullVersionIdDeletesCurrentObject() throws Exception {
+    final OzoneClient client = new OzoneClientStub();
+    final OzoneBucket bucket = initTestData(client);
+    final BucketEndpoint rest = EndpointBuilder.newBucketEndpointBuilder().setClient(client).build();
+
+    final DeleteObject nullVersion = new DeleteObject("key2");
+    nullVersion.setVersionId(NULL_VERSION_ID);
+    final MultiDeleteRequest mdr = new MultiDeleteRequest();
+    mdr.getObjects().add(new DeleteObject("key1"));
+    mdr.getObjects().add(nullVersion);
+
+    final MultiDeleteResponse response = rest.multiDelete("b1", "", mdr);
+
+    assertThat(response.getErrors()).isEmpty();
+    // VersionId is only returned for the keys it was requested for
+    assertThat(response.getDeletedObjects())
+        .extracting(DeletedObject::getKey, DeletedObject::getVersionId)
+        .containsExactly(tuple("key1", null), tuple("key2", NULL_VERSION_ID));
+    assertThat(bucket.listKeys("")).toIterable().extracting(OzoneKey::getName).containsExactly("key3");
+  }
+
+  @Test
+  public void multiDeleteReturnsVersionIdOfFailedKey() throws Exception {
+    OzoneBucket bucket = mock(OzoneBucket.class);
+    when(bucket.deleteKeys(any(), anyBoolean()))
+        .thenReturn(singletonMap("key1", new ErrorInfo("ACCESS_DENIED", "ACL check failed")));
+    final DeleteObject nullVersion = new DeleteObject("key1");
+    nullVersion.setVersionId(NULL_VERSION_ID);
+    final MultiDeleteRequest mdr = new MultiDeleteRequest();
+    mdr.getObjects().add(nullVersion);
+
+    final MultiDeleteResponse response = newEndpointFor(bucket, new HashMap<>()).multiDelete("b1", "", mdr);
+
+    assertThat(response.getDeletedObjects()).isEmpty();
+    assertThat(response.getErrors())
+        .extracting(MultiDeleteResponse.Error::getKey, MultiDeleteResponse.Error::getVersionId)
+        .containsExactly(tuple("key1", NULL_VERSION_ID));
+  }
+
   private MultiDeleteRequest threeKeyRequest() {
     MultiDeleteRequest mdr = new MultiDeleteRequest();
     mdr.getObjects().add(new DeleteObject("key1"));
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectNotImplemented.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectNotImplemented.java
index f4270c7a699..1ab3fc8fdbc 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectNotImplemented.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectNotImplemented.java
@@ -17,13 +17,17 @@
 
 package org.apache.hadoop.ozone.s3.endpoint;
 
+import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
 import static org.apache.hadoop.ozone.client.OzoneClientTestUtils.assertKeyContent;
 import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertErrorResponse;
+import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertStatus;
 import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertSucceeds;
 import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.delete;
 import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.get;
 import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.put;
+import static org.apache.hadoop.ozone.s3.util.S3Consts.NULL_VERSION_ID;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256;
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -104,10 +108,26 @@ public void deleteIsNotImplementedAndDoesNotDeleteObject(String subresource) thr
   }
 
   @Test
-  public void deleteWithVersionIdIsNotImplementedAndDoesNotDeleteObject() throws IOException {
+  public void deleteWithNonNullVersionIdIsNotImplementedAndDoesNotDeleteObject() throws IOException {
     objectEndpoint.queryParamsForTest().set(QueryParams.VERSION_ID, "nonexistent");
 
     assertErrorResponse(S3ErrorTable.NOT_IMPLEMENTED, () -> delete(objectEndpoint, BUCKET_NAME, KEY_NAME));
     assertKeyContent(bucket, KEY_NAME, CONTENT);
   }
+
+  @Test
+  public void deleteWithEmptyVersionIdIsInvalidArgumentAndDoesNotDeleteObject() throws IOException {
+    objectEndpoint.queryParamsForTest().set(QueryParams.VERSION_ID, "");
+
+    assertErrorResponse(S3ErrorTable.INVALID_ARGUMENT, () -> delete(objectEndpoint, BUCKET_NAME, KEY_NAME));
+    assertKeyContent(bucket, KEY_NAME, CONTENT);
+  }
+
+  @Test
+  public void deleteWithNullVersionIdDeletesObject() throws IOException {
+    objectEndpoint.queryParamsForTest().set(QueryParams.VERSION_ID, NULL_VERSION_ID);
+
+    assertStatus(HTTP_NO_CONTENT, () -> delete(objectEndpoint, BUCKET_NAME, KEY_NAME));
+    assertThat(bucket.listKeys("")).isExhausted();
+  }
 }
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectTaggingDelete.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectTaggingDelete.java
index a20b03569b1..952bc12388b 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectTaggingDelete.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectTaggingDelete.java
@@ -25,8 +25,10 @@
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.NOT_IMPLEMENTED;
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.NO_SUCH_BUCKET;
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.NO_SUCH_KEY;
+import static org.apache.hadoop.ozone.s3.util.S3Consts.NULL_VERSION_ID;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_HEADER;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256;
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -106,7 +108,7 @@ public void testDeleteTagging() throws IOException, OS3Exception {
   }
 
   @Test
-  public void testDeleteTaggingWithVersionIdIsNotImplemented() throws IOException {
+  public void testDeleteTaggingWithNonNullVersionIdIsNotImplemented() throws IOException {
     rest.queryParamsForTest().set(QueryParams.VERSION_ID, "nonexistent");
 
     assertErrorResponse(NOT_IMPLEMENTED, () -> deleteTagging(rest, BUCKET_NAME, KEY_WITH_TAG));
@@ -115,6 +117,14 @@ public void testDeleteTaggingWithVersionIdIsNotImplemented() throws IOException
         client.getObjectStore().getS3Bucket(BUCKET_NAME).getKey(KEY_WITH_TAG).getTags());
   }
 
+  @Test
+  public void testDeleteTaggingWithNullVersionId() throws IOException, OS3Exception {
+    rest.queryParamsForTest().set(QueryParams.VERSION_ID, NULL_VERSION_ID);
+
+    assertThat(deleteTagging(rest, BUCKET_NAME, KEY_WITH_TAG).getStatus()).isEqualTo(HTTP_NO_CONTENT);
+    assertThat(client.getObjectStore().getS3Bucket(BUCKET_NAME).getKey(KEY_WITH_TAG).getTags()).isEmpty();
+  }
+
   @Test
   public void testDeleteTaggingNoKeyFound() {
     assertErrorResponse(NO_SUCH_KEY, () -> deleteTagging(rest, BUCKET_NAME, "nonexistent"));
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectTaggingPut.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectTaggingPut.java
index f5777a6e9fc..beb46da4e5a 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectTaggingPut.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectTaggingPut.java
@@ -22,6 +22,7 @@
 import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.createObjectEndpoint;
 import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.put;
 import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.putTagging;
+import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.INVALID_ARGUMENT;
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.MALFORMED_XML;
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.NOT_IMPLEMENTED;
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.NO_SUCH_BUCKET;
@@ -117,13 +118,30 @@ public void testPutInvalidObjectTagging() {
   }
 
   @Test
-  public void testPutObjectTaggingWithVersionIdIsNotImplemented() throws Exception {
+  public void testPutObjectTaggingWithNonNullVersionIdIsNotImplemented() throws Exception {
     objectEndpoint.queryParamsForTest().set(S3Consts.QueryParams.VERSION_ID, "nonexistent");
 
     assertErrorResponse(NOT_IMPLEMENTED, () -> putTagging(objectEndpoint, BUCKET_NAME, KEY_NAME, twoTags()));
     assertThat(clientStub.getObjectStore().getS3Bucket(BUCKET_NAME).getKey(KEY_NAME).getTags()).isEmpty();
   }
 
+  @Test
+  public void testPutObjectTaggingWithEmptyVersionIdIsInvalidArgument() throws Exception {
+    objectEndpoint.queryParamsForTest().set(S3Consts.QueryParams.VERSION_ID, "");
+
+    assertErrorResponse(INVALID_ARGUMENT, () -> putTagging(objectEndpoint, BUCKET_NAME, KEY_NAME, twoTags()));
+    assertThat(clientStub.getObjectStore().getS3Bucket(BUCKET_NAME).getKey(KEY_NAME).getTags()).isEmpty();
+  }
+
+  @Test
+  public void testPutObjectTaggingWithNullVersionId() throws Exception {
+    objectEndpoint.queryParamsForTest().set(S3Consts.QueryParams.VERSION_ID, S3Consts.NULL_VERSION_ID);
+
+    assertSucceeds(() -> putTagging(objectEndpoint, BUCKET_NAME, KEY_NAME, twoTags()));
+    assertThat(clientStub.getObjectStore().getS3Bucket(BUCKET_NAME).getKey(KEY_NAME).getTags())
+        .containsExactlyEntriesOf(TAGS);
+  }
+
   @Test
   public void testPutObjectTaggingNoKeyFound() {
     assertErrorResponse(NO_SUCH_KEY, () -> putTagging(objectEndpoint, BUCKET_NAME, "nonexistent", twoTags()));


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@ozone.apache.org
For additional commands, e-mail: commits-help@ozone.apache.org