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

Gargi-jais11 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 1db063b63ce HDDS-16072. Implement GetObjectAttributes: ETag, 
ObjectSize, StorageClass, and ObjectParts count (#10930).
1db063b63ce is described below

commit 1db063b63ceed0d2946970752d3d6eea529db1aa
Author: Gargi Jaiswal <[email protected]>
AuthorDate: Wed Aug 12 11:25:30 2026 +0530

    HDDS-16072. Implement GetObjectAttributes: ETag, ObjectSize, StorageClass, 
and ObjectParts count (#10930).
---
 .../ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java   |  90 +++++++++
 .../org/apache/hadoop/ozone/audit/S3GAction.java   |   3 +-
 .../hadoop/ozone/s3/endpoint/EndpointBase.java     |  19 ++
 .../s3/endpoint/GetObjectAttributesResponse.java   | 206 +++++++++++++++++++++
 .../ozone/s3/endpoint/ObjectAttributesHandler.java | 173 +++++++++++++++++
 .../hadoop/ozone/s3/endpoint/ObjectEndpoint.java   |  21 +--
 .../hadoop/ozone/s3/metrics/S3GatewayMetrics.java  |  22 +++
 .../org/apache/hadoop/ozone/s3/util/S3Consts.java  |   4 +
 .../ozone/s3/endpoint/EndpointTestUtils.java       |  14 ++
 .../ozone/s3/endpoint/TestObjectAttributesGet.java | 176 ++++++++++++++++++
 10 files changed, 709 insertions(+), 19 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 ac54d20aa49..ec80b9c4de0 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
@@ -137,6 +137,8 @@
 import 
software.amazon.awssdk.services.s3.model.GetBucketLifecycleConfigurationResponse;
 import software.amazon.awssdk.services.s3.model.GetBucketTaggingRequest;
 import software.amazon.awssdk.services.s3.model.GetBucketTaggingResponse;
+import software.amazon.awssdk.services.s3.model.GetObjectAttributesRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectAttributesResponse;
 import software.amazon.awssdk.services.s3.model.GetObjectRequest;
 import software.amazon.awssdk.services.s3.model.GetObjectResponse;
 import software.amazon.awssdk.services.s3.model.GetObjectTaggingRequest;
@@ -160,6 +162,7 @@
 import software.amazon.awssdk.services.s3.model.ListPartsRequest;
 import software.amazon.awssdk.services.s3.model.MetadataDirective;
 import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
+import software.amazon.awssdk.services.s3.model.ObjectAttributes;
 import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
 import software.amazon.awssdk.services.s3.model.PutBucketAclRequest;
 import software.amazon.awssdk.services.s3.model.PutBucketTaggingRequest;
@@ -1712,6 +1715,93 @@ public void testHeadObjectReturnsTaggingCount() {
     assertEquals(tags.size(), head.tagCount().intValue());
   }
 
+  @Test
+  public void testGetObjectAttributes() {
+    final String bucketName = getBucketName();
+    final String keyName = getKeyName();
+    final String content = "get-object-attributes-content";
+    s3Client.createBucket(b -> b.bucket(bucketName));
+
+    PutObjectResponse putObjectResponse = s3Client.putObject(b -> 
b.bucket(bucketName).key(keyName),
+        RequestBody.fromString(content));
+
+    GetObjectAttributesResponse attributesResponse = 
s3Client.getObjectAttributes(
+        GetObjectAttributesRequest.builder()
+            .bucket(bucketName)
+            .key(keyName)
+            .objectAttributes(ObjectAttributes.E_TAG, 
ObjectAttributes.OBJECT_SIZE,
+                ObjectAttributes.STORAGE_CLASS)
+            .build());
+
+    assertNotNull(attributesResponse.lastModified());
+    assertEquals(
+        putObjectResponse.eTag().replace("\"", ""),
+        attributesResponse.eTag());
+    assertEquals((long) content.length(), attributesResponse.objectSize());
+    assertEquals("STANDARD", attributesResponse.storageClassAsString());
+    assertNull(attributesResponse.objectParts());
+  }
+
+  @Test
+  public void testGetObjectAttributesMultipartObjectParts(@TempDir Path 
tempDir) throws Exception {
+    final String bucketName = getBucketName();
+    final String keyName = getKeyName();
+
+    s3Client.createBucket(b -> b.bucket(bucketName));
+
+    File multipartUploadFile = 
Files.createFile(tempDir.resolve("get-object-attributes-mpu.txt")).toFile();
+    createFile(multipartUploadFile, (int) (15 * MB));
+    multipartUpload(bucketName, keyName, multipartUploadFile, (int) (5 * MB), 
new HashMap<>(),
+        Collections.emptyList());
+
+    GetObjectAttributesResponse attributesResponse = 
s3Client.getObjectAttributes(
+        GetObjectAttributesRequest.builder()
+            .bucket(bucketName)
+            .key(keyName)
+            .objectAttributes(ObjectAttributes.OBJECT_PARTS, 
ObjectAttributes.E_TAG,
+                ObjectAttributes.OBJECT_SIZE)
+            .build());
+
+    assertNotNull(attributesResponse.eTag());
+    assertTrue(attributesResponse.eTag().contains("-"));
+    assertEquals(multipartUploadFile.length(), 
attributesResponse.objectSize());
+    assertNotNull(attributesResponse.objectParts());
+    assertEquals(3, attributesResponse.objectParts().totalPartsCount());
+    assertFalse(attributesResponse.objectParts().isTruncated());
+  }
+
+  @Test
+  public void testGetObjectAttributesNoSuchKey() {
+    final String bucketName = getBucketName();
+    final String keyName = getKeyName();
+    s3Client.createBucket(b -> b.bucket(bucketName));
+
+    assertThrows(NoSuchKeyException.class, () -> s3Client.getObjectAttributes(
+        GetObjectAttributesRequest.builder()
+            .bucket(bucketName)
+            .key(keyName)
+            .objectAttributes(ObjectAttributes.E_TAG)
+            .build()));
+  }
+
+  @Test
+  public void testGetObjectAttributesChecksumOmitted() {
+    final String bucketName = getBucketName();
+    final String keyName = getKeyName();
+    s3Client.createBucket(b -> b.bucket(bucketName));
+    s3Client.putObject(b -> b.bucket(bucketName).key(keyName), 
RequestBody.fromString("checksum-test"));
+
+    GetObjectAttributesResponse attributesResponse = 
s3Client.getObjectAttributes(
+        GetObjectAttributesRequest.builder()
+            .bucket(bucketName)
+            .key(keyName)
+            .objectAttributes(ObjectAttributes.CHECKSUM, 
ObjectAttributes.E_TAG)
+            .build());
+
+    assertNotNull(attributesResponse.eTag());
+    assertNull(attributesResponse.checksum());
+  }
+
   @Test
   public void testResumableDownloadWithEtagMismatch() throws Exception {
     // Arrange
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java
index 11efd4e9064..16f5ceb1e4b 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/audit/S3GAction.java
@@ -62,7 +62,8 @@ public enum S3GAction implements AuditAction {
   GET_BUCKET_TAGGING,
   PUT_BUCKET_TAGGING,
   DELETE_BUCKET_TAGGING,
-  PUT_OBJECT_ACL;
+  PUT_OBJECT_ACL,
+  GET_OBJECT_ATTRIBUTES;
 
   @Override
   public String getAction() {
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 124fed461ff..854dcf80e27 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
@@ -28,6 +28,8 @@
 import static org.apache.hadoop.ozone.OzoneConsts.KB;
 import static 
org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_CLIENT_BUFFER_SIZE_DEFAULT;
 import static 
org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_CLIENT_BUFFER_SIZE_KEY;
+import static 
org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED;
+import static 
org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED_DEFAULT;
 import static 
org.apache.hadoop.ozone.s3.exception.S3ErrorTable.BUCKET_ALREADY_EXISTS;
 import static 
org.apache.hadoop.ozone.s3.exception.S3ErrorTable.BUCKET_ALREADY_OWNED_BY_YOU;
 import static 
org.apache.hadoop.ozone.s3.exception.S3ErrorTable.INVALID_ARGUMENT;
@@ -810,6 +812,23 @@ protected static CheckedRunnable<IOException> 
validateContentLength(
     };
   }
 
+  /**
+   * Rejects FSO directory keys when the request path omits the trailing slash.
+   *
+   * <p>Necessary for directories in buckets with FSO layout. Intended for 
apps which use Hadoop S3A
+   * (e.g. Trino through the Hive connector).
+   */
+  protected void validateFileKey(String keyPath, OzoneKey key) throws 
OMException {
+    boolean isFsoDirCreationEnabled = getOzoneConfiguration()
+        .getBoolean(OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED,
+            OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED_DEFAULT);
+    if (isFsoDirCreationEnabled &&
+        !key.isFile() &&
+        !keyPath.endsWith("/")) {
+      throw new OMException(ResultCodes.KEY_NOT_FOUND);
+    }
+  }
+
   protected static String extractPartsCount(String eTag) {
     if (eTag.contains("-")) {
       String[] parts = eTag.replace("\"", "").split("-");
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/GetObjectAttributesResponse.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/GetObjectAttributesResponse.java
new file mode 100644
index 00000000000..612ade64a8a
--- /dev/null
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/GetObjectAttributesResponse.java
@@ -0,0 +1,206 @@
+/*
+ * 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 java.util.ArrayList;
+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 org.apache.hadoop.ozone.s3.util.S3Consts;
+
+/**
+ * XML response for the GetObjectAttributes S3 API.
+ *
+ * <p>See 
https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectAttributes.html
+ *
+ * <p>The {@code Checksum} field is intentionally omitted: Ozone does not yet 
store
+ * non-MD5 checksum algorithms in key metadata. When checksum storage is 
implemented,
+ * this field can be added here without any API contract change.
+ */
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement(name = "GetObjectAttributesResponse", namespace = 
S3Consts.S3_XML_NAMESPACE)
+public class GetObjectAttributesResponse {
+
+  @XmlElement(name = "ETag")
+  private String eTag;
+
+  @XmlElement(name = "ObjectSize")
+  private Long objectSize;
+
+  @XmlElement(name = "StorageClass")
+  private String storageClass;
+
+  @XmlElement(name = "ObjectParts")
+  private ObjectParts objectParts;
+
+  public String getETag() {
+    return eTag;
+  }
+
+  public void setETag(String tag) {
+    this.eTag = tag;
+  }
+
+  public Long getObjectSize() {
+    return objectSize;
+  }
+
+  public void setObjectSize(Long objectSize) {
+    this.objectSize = objectSize;
+  }
+
+  public String getStorageClass() {
+    return storageClass;
+  }
+
+  public void setStorageClass(String storageClass) {
+    this.storageClass = storageClass;
+  }
+
+  public ObjectParts getObjectParts() {
+    return objectParts;
+  }
+
+  public void setObjectParts(ObjectParts objectParts) {
+    this.objectParts = objectParts;
+  }
+
+  /**
+   * Represents the ObjectParts element in the GetObjectAttributes response.
+   *
+   * <p>For completed multipart-uploaded objects, {@code partsCount} is 
derived from
+   * the composite ETag suffix (e.g. {@code "hash-15"} → 15 parts). Per-part 
sizes
+   * are not stored for completed multipart uploads in Ozone and are therefore 
omitted
+   * from the part list in this response.
+   * TODO: Will support completed multipart uploads in this ticket: HDDS-16073
+   */
+  @XmlAccessorType(XmlAccessType.FIELD)
+  @XmlRootElement(name = "ObjectParts")
+  public static class ObjectParts {
+
+    @XmlElement(name = "IsTruncated")
+    private boolean truncated;
+
+    @XmlElement(name = "MaxParts")
+    private Integer maxParts;
+
+    @XmlElement(name = "PartNumberMarker")
+    private Integer partNumberMarker;
+
+    @XmlElement(name = "NextPartNumberMarker")
+    private Integer nextPartNumberMarker;
+
+    @XmlElement(name = "PartsCount")
+    private Integer partsCount;
+
+    @XmlElement(name = "Part")
+    private List<Part> parts = new ArrayList<>();
+
+    public boolean isTruncated() {
+      return truncated;
+    }
+
+    public void setTruncated(boolean truncated) {
+      this.truncated = truncated;
+    }
+
+    public Integer getMaxParts() {
+      return maxParts;
+    }
+
+    public void setMaxParts(Integer maxParts) {
+      this.maxParts = maxParts;
+    }
+
+    public Integer getPartNumberMarker() {
+      return partNumberMarker;
+    }
+
+    public void setPartNumberMarker(Integer partNumberMarker) {
+      this.partNumberMarker = partNumberMarker;
+    }
+
+    public Integer getNextPartNumberMarker() {
+      return nextPartNumberMarker;
+    }
+
+    public void setNextPartNumberMarker(Integer nextPartNumberMarker) {
+      this.nextPartNumberMarker = nextPartNumberMarker;
+    }
+
+    public Integer getPartsCount() {
+      return partsCount;
+    }
+
+    public void setPartsCount(Integer partsCount) {
+      this.partsCount = partsCount;
+    }
+
+    public List<Part> getParts() {
+      return parts;
+    }
+
+    public void setParts(List<Part> parts) {
+      this.parts = parts;
+    }
+
+    public void addPart(Part part) {
+      this.parts.add(part);
+    }
+  }
+
+  /**
+   * A single part entry within {@link ObjectParts}.
+   */
+  @XmlAccessorType(XmlAccessType.FIELD)
+  @XmlRootElement(name = "Part")
+  public static class Part {
+
+    @XmlElement(name = "PartNumber")
+    private int partNumber;
+
+    @XmlElement(name = "Size")
+    private long size;
+
+    public Part() {
+    }
+
+    public Part(int partNumber, long size) {
+      this.partNumber = partNumber;
+      this.size = size;
+    }
+
+    public int getPartNumber() {
+      return partNumber;
+    }
+
+    public void setPartNumber(int partNumber) {
+      this.partNumber = partNumber;
+    }
+
+    public long getSize() {
+      return size;
+    }
+
+    public void setSize(long size) {
+      this.size = size;
+    }
+  }
+}
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectAttributesHandler.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectAttributesHandler.java
new file mode 100644
index 00000000000..ab4934b3d67
--- /dev/null
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectAttributesHandler.java
@@ -0,0 +1,173 @@
+/*
+ * 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.exception.S3ErrorTable.ACCESS_DENIED;
+import static 
org.apache.hadoop.ozone.s3.exception.S3ErrorTable.INVALID_ARGUMENT;
+import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.NO_SUCH_KEY;
+import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.newError;
+import static 
org.apache.hadoop.ozone.s3.util.S3Consts.OBJECT_ATTRIBUTES_HEADER;
+import static org.apache.hadoop.ozone.s3.util.S3Consts.QueryParams;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.ozone.OzoneConsts;
+import org.apache.hadoop.ozone.audit.S3GAction;
+import org.apache.hadoop.ozone.client.OzoneKey;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
+import org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes;
+import org.apache.hadoop.ozone.s3.endpoint.ObjectEndpoint.ObjectRequestContext;
+import org.apache.hadoop.ozone.s3.exception.OS3Exception;
+import org.apache.hadoop.ozone.s3.util.S3StorageType;
+
+/**
+ * Handles the GetObjectAttributes S3 API ({@code GET 
/{bucket}/{key}?attributes}).
+ *
+ * <p>Returns selected metadata about an object without transferring the 
object body.
+ * Supported attributes: {@code ETag}, {@code ObjectSize}, {@code 
StorageClass}, {@code ObjectParts}.
+ *
+ * <p>The {@code Checksum} attribute is not yet supported because Ozone does 
not store
+ * non-MD5 checksum algorithms in key metadata. Object versioning ({@code 
versionId}) and
+ * SSE-C encryption headers are also not supported and are silently ignored.
+ *
+ * <p>See 
https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectAttributes.html
+ */
+class ObjectAttributesHandler extends ObjectOperationHandler {
+
+  /** Valid values for the x-amz-object-attributes request header. */
+  static final String ATTR_ETAG = "ETag";
+  static final String ATTR_CHECKSUM = "Checksum";
+  static final String ATTR_OBJECT_PARTS = "ObjectParts";
+  static final String ATTR_STORAGE_CLASS = "StorageClass";
+  static final String ATTR_OBJECT_SIZE = "ObjectSize";
+
+  private static final Set<String> KNOWN_ATTRIBUTES = new 
HashSet<>(Arrays.asList(
+      ATTR_ETAG, ATTR_CHECKSUM, ATTR_OBJECT_PARTS, ATTR_STORAGE_CLASS, 
ATTR_OBJECT_SIZE));
+
+  @Override
+  Response handleGetRequest(ObjectRequestContext context, String keyPath)
+      throws IOException, OS3Exception {
+
+    if (queryParams().get(QueryParams.ATTRIBUTES) == null) {
+      return null;
+    }
+
+    context.setAction(S3GAction.GET_OBJECT_ATTRIBUTES);
+
+    final long startNanos = context.getStartNanos();
+    try {
+      Set<String> requestedAttributes = parseAttributesHeader(keyPath);
+      String bucketName = context.getBucketName();
+
+      OzoneKey key;
+      try {
+        key = getClientProtocol().headS3Object(bucketName, keyPath);
+        validateFileKey(keyPath, key);
+      } catch (OMException ex) {
+        if (ex.getResult() == ResultCodes.KEY_NOT_FOUND) {
+          throw newError(NO_SUCH_KEY, keyPath, ex);
+        } else if (isAccessDenied(ex)) {
+          throw newError(ACCESS_DENIED, bucketName + "/" + keyPath, ex);
+        }
+        throw ex;
+      }
+
+      GetObjectAttributesResponse response = buildResponse(key, 
requestedAttributes);
+
+      Response.ResponseBuilder rb = Response.ok(response, 
MediaType.APPLICATION_XML_TYPE);
+      ObjectEndpoint.addLastModifiedDate(rb, key);
+      getMetrics().updateGetObjectAttributesSuccessStats(startNanos);
+      return rb.build();
+
+    } catch (OS3Exception | IOException ex) {
+      getMetrics().updateGetObjectAttributesFailureStats(startNanos);
+      throw ex;
+    }
+  }
+
+  /**
+   * Parses and validates the {@code x-amz-object-attributes} request header.
+   *
+   * <p>The header is required. Unknown attribute names that are not in the 
S3-defined
+   * set are rejected with {@code InvalidArgument}.
+   */
+  private Set<String> parseAttributesHeader(String keyPath) throws 
OS3Exception {
+    String headerValue = 
getHeaders().getHeaderString(OBJECT_ATTRIBUTES_HEADER);
+    if (StringUtils.isBlank(headerValue)) {
+      throw newError(INVALID_ARGUMENT, keyPath,
+          new IllegalArgumentException(OBJECT_ATTRIBUTES_HEADER + " is 
required"));
+    }
+
+    Set<String> requested = new HashSet<>();
+    for (String token : headerValue.split(",")) {
+      String attr = token.trim();
+      if (!KNOWN_ATTRIBUTES.contains(attr)) {
+        throw newError(INVALID_ARGUMENT, keyPath,
+            new IllegalArgumentException("Invalid value for " + 
OBJECT_ATTRIBUTES_HEADER + ": " + attr));
+      }
+      requested.add(attr);
+    }
+    return requested;
+  }
+
+  private GetObjectAttributesResponse buildResponse(OzoneKey key, Set<String> 
requested) {
+    GetObjectAttributesResponse resp = new GetObjectAttributesResponse();
+
+    if (requested.contains(ATTR_ETAG)) {
+      String eTag = key.getMetadata().get(OzoneConsts.ETAG);
+      if (eTag != null) {
+        resp.setETag(eTag);
+      }
+    }
+
+    if (requested.contains(ATTR_OBJECT_SIZE)) {
+      resp.setObjectSize(key.getDataSize());
+    }
+
+    if (requested.contains(ATTR_STORAGE_CLASS)) {
+      S3StorageType storageType = key.getReplicationConfig() == null
+          ? S3StorageType.STANDARD
+          : S3StorageType.fromReplicationConfig(key.getReplicationConfig());
+      resp.setStorageClass(storageType.toString());
+    }
+
+    if (requested.contains(ATTR_OBJECT_PARTS)) {
+      String eTag = key.getMetadata().get(OzoneConsts.ETAG);
+      if (eTag != null) {
+        String partsCountStr = extractPartsCount(eTag);
+        if (partsCountStr != null) {
+          GetObjectAttributesResponse.ObjectParts parts = new 
GetObjectAttributesResponse.ObjectParts();
+          parts.setPartsCount(Integer.parseInt(partsCountStr));
+          parts.setTruncated(false);
+          resp.setObjectParts(parts);
+        }
+      }
+    }
+
+    // ATTR_CHECKSUM is intentionally not populated: Ozone does not store 
non-MD5
+    // checksum algorithms in key metadata. The field is simply omitted from 
the
+    // response, which is valid per the S3 spec.
+
+    return resp;
+  }
+}
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java
index 6e8260d5e49..21be929e211 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java
@@ -152,6 +152,7 @@ protected void init() {
         .add(new ObjectGetTorrentHandler())
         .add(new ObjectAclHandler())
         .add(new ObjectTaggingHandler())
+        .add(new ObjectAttributesHandler())
         .add(new MultipartKeyHandler())
         .add(this)
         .build();
@@ -396,7 +397,7 @@ Response handleGetRequest(ObjectRequestContext context, 
String keyPath)
           getClientProtocol().getS3KeyDetails(bucketName, keyPath, partNumber) 
:
           getClientProtocol().getS3KeyDetails(bucketName, keyPath);
 
-      isFile(keyPath, keyDetails);
+      validateFileKey(keyPath, keyDetails);
 
       Response conditionalResponse = 
S3ConditionalRequest.evaluatePreconditions(
           getHeaders(), keyPath, keyDetails, 
S3ConditionalRequest.PreconditionContext.READ);
@@ -647,7 +648,7 @@ public Response head(
           getClientProtocol().headS3Object(bucketName, keyPath, partNumber) :
           getClientProtocol().headS3Object(bucketName, keyPath);
 
-      isFile(keyPath, key);
+      validateFileKey(keyPath, key);
       Response conditionalResponse = 
S3ConditionalRequest.evaluatePreconditions(
           getHeaders(), keyPath, key, 
S3ConditionalRequest.PreconditionContext.READ);
       if (conditionalResponse != null) {
@@ -689,22 +690,6 @@ public Response head(
     return response.build();
   }
 
-  private void isFile(String keyPath, OzoneKey key) throws OMException {
-    /*
-      Necessary for directories in buckets with FSO layout.
-      Intended for apps which use Hadoop S3A.
-      Example of such app is Trino (through Hive connector).
-     */
-    boolean isFsoDirCreationEnabled = getOzoneConfiguration()
-        .getBoolean(OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED,
-            OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED_DEFAULT);
-    if (isFsoDirCreationEnabled &&
-        !key.isFile() &&
-        !keyPath.endsWith("/")) {
-      throw new OMException(ResultCodes.KEY_NOT_FOUND);
-    }
-  }
-
   /**
    * Delete a specific object from a bucket, if query param uploadId is
    * specified, this request is for abort multipart upload.
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/metrics/S3GatewayMetrics.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/metrics/S3GatewayMetrics.java
index 160102e7290..0c8f8e504ec 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/metrics/S3GatewayMetrics.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/metrics/S3GatewayMetrics.java
@@ -109,6 +109,8 @@ public final class S3GatewayMetrics implements Closeable, 
MetricsSource {
   private @Metric MutableCounterLong deleteBucketTaggingFailure;
   private @Metric MutableCounterLong putObjectAclSuccess;
   private @Metric MutableCounterLong putObjectAclFailure;
+  private @Metric MutableCounterLong getObjectAttributesSuccess;
+  private @Metric MutableCounterLong getObjectAttributesFailure;
 
   // S3 Gateway Latency Metrics
   // BucketEndpoint
@@ -304,6 +306,12 @@ public final class S3GatewayMetrics implements Closeable, 
MetricsSource {
       "in nanoseconds")
   private PerformanceMetrics putObjectAclFailureLatencyNs;
 
+  @Metric(about = "Latency for successfully getting S3 object attributes in 
nanoseconds")
+  private PerformanceMetrics getObjectAttributesSuccessLatencyNs;
+
+  @Metric(about = "Latency for failing to get S3 object attributes in 
nanoseconds")
+  private PerformanceMetrics getObjectAttributesFailureLatencyNs;
+
   private final Map<String, PerformanceMetrics> performanceMetrics;
 
   /**
@@ -467,6 +475,10 @@ public void getMetrics(MetricsCollector collector, boolean 
all) {
     putObjectAclSuccessLatencyNs.snapshot(recordBuilder, true);
     putObjectAclFailure.snapshot(recordBuilder, true);
     putObjectAclFailureLatencyNs.snapshot(recordBuilder, true);
+    getObjectAttributesSuccess.snapshot(recordBuilder, true);
+    getObjectAttributesSuccessLatencyNs.snapshot(recordBuilder, true);
+    getObjectAttributesFailure.snapshot(recordBuilder, true);
+    getObjectAttributesFailureLatencyNs.snapshot(recordBuilder, true);
   }
 
   // INC and UPDATE
@@ -758,6 +770,16 @@ public void updatePutObjectAclFailureStats(long 
startNanos) {
     this.putObjectAclFailureLatencyNs.add(Time.monotonicNowNanos() - 
startNanos);
   }
 
+  public void updateGetObjectAttributesSuccessStats(long startNanos) {
+    this.getObjectAttributesSuccess.incr();
+    this.getObjectAttributesSuccessLatencyNs.add(Time.monotonicNowNanos() - 
startNanos);
+  }
+
+  public void updateGetObjectAttributesFailureStats(long startNanos) {
+    this.getObjectAttributesFailure.incr();
+    this.getObjectAttributesFailureLatencyNs.add(Time.monotonicNowNanos() - 
startNanos);
+  }
+
   // GET
   public long getListS3BucketsSuccess() {
     return listS3BucketsSuccess.value();
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 c8a2f7f4809..8477d8c64a1 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
@@ -128,6 +128,9 @@ public final class S3Consts {
   // tag limit for bucket
   public static final int TAG_BUCKET_NUM_LIMIT = 50;
 
+  /** Request header carrying the list of object attributes to return. */
+  public static final String OBJECT_ATTRIBUTES_HEADER = 
"x-amz-object-attributes";
+
   //Never Constructed
   private S3Consts() {
 
@@ -170,6 +173,7 @@ public static final class QueryParams {
     public static final String UPLOAD_ID_MARKER = "upload-id-marker";
     public static final String UPLOADS = "uploads";
     public static final String LIFECYCLE = "lifecycle";
+    public static final String ATTRIBUTES = "attributes";
 
     private QueryParams() {
       // no instances
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/EndpointTestUtils.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/EndpointTestUtils.java
index 1e48f6c9be9..0bbbf4eb18e 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/EndpointTestUtils.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/EndpointTestUtils.java
@@ -62,6 +62,20 @@ public static Response getTagging(
     return subject.get(bucket, key);
   }
 
+  /** Get object attributes (?attributes). */
+  public static Response getObjectAttributes(
+      ObjectEndpoint subject,
+      String bucket,
+      String key,
+      String attributesHeader
+  ) throws IOException, OS3Exception {
+    subject.queryParamsForTest().set(S3Consts.QueryParams.ATTRIBUTES, "");
+    when(subject.getContext().getMethod()).thenReturn(HttpMethod.GET);
+    
when(subject.getHeaders().getHeaderString(S3Consts.OBJECT_ATTRIBUTES_HEADER))
+        .thenReturn(attributesHeader);
+    return subject.get(bucket, key);
+  }
+
   /** List parts of MPU. */
   public static Response listParts(
       ObjectEndpoint subject,
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectAttributesGet.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectAttributesGet.java
new file mode 100644
index 00000000000..0f2cca58b3f
--- /dev/null
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectAttributesGet.java
@@ -0,0 +1,176 @@
+/*
+ * 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 java.net.HttpURLConnection.HTTP_OK;
+import static 
org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED;
+import static 
org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertErrorResponse;
+import static 
org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertSucceeds;
+import static 
org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.getObjectAttributes;
+import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.put;
+import static 
org.apache.hadoop.ozone.s3.exception.S3ErrorTable.INVALID_ARGUMENT;
+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.X_AMZ_CONTENT_SHA256;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.io.IOException;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.Response;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.ozone.client.OzoneBucket;
+import org.apache.hadoop.ozone.client.OzoneClient;
+import org.apache.hadoop.ozone.client.OzoneClientStub;
+import org.apache.hadoop.ozone.s3.exception.OS3Exception;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+/**
+ * Tests for GetObjectAttributes.
+ */
+public class TestObjectAttributesGet {
+
+  private static final String CONTENT = "0123456789";
+  private static final String BUCKET_NAME = "b1";
+  private static final String KEY_NAME = "key1";
+  private ObjectEndpoint rest;
+  private OzoneBucket bucket;
+
+  @BeforeEach
+  public void init() throws Exception {
+    OzoneClient client = new OzoneClientStub();
+    client.getObjectStore().createS3Bucket(BUCKET_NAME);
+    bucket = client.getObjectStore().getS3Bucket(BUCKET_NAME);
+
+    HttpHeaders headers = Mockito.mock(HttpHeaders.class);
+    Mockito.when(headers.getHeaderString(X_AMZ_CONTENT_SHA256))
+        .thenReturn("UNSIGNED-PAYLOAD");
+
+    rest = EndpointBuilder.newObjectEndpointBuilder()
+        .setClient(client)
+        .setHeaders(headers)
+        .build();
+  }
+
+  @Test
+  public void testGetObjectAttributesAllSupportedFields() throws IOException, 
OS3Exception {
+    assertSucceeds(() -> put(rest, BUCKET_NAME, KEY_NAME, CONTENT));
+
+    Response response = getObjectAttributes(rest, BUCKET_NAME, KEY_NAME,
+        "ETag,ObjectSize,StorageClass");
+
+    assertEquals(HTTP_OK, response.getStatus());
+    assertNotNull(response.getHeaderString(HttpHeaders.LAST_MODIFIED));
+
+    GetObjectAttributesResponse attributes = (GetObjectAttributesResponse) 
response.getEntity();
+    assertNotNull(attributes);
+    assertNotNull(attributes.getETag());
+    assertFalse(attributes.getETag().startsWith("\""));
+    assertEquals(CONTENT.length(), attributes.getObjectSize().longValue());
+    assertEquals("STANDARD", attributes.getStorageClass());
+    assertNull(attributes.getObjectParts());
+  }
+
+  @Test
+  public void testGetObjectAttributesEtagOnly() throws IOException, 
OS3Exception {
+    assertSucceeds(() -> put(rest, BUCKET_NAME, KEY_NAME, CONTENT));
+
+    Response response = getObjectAttributes(rest, BUCKET_NAME, KEY_NAME, 
"ETag");
+
+    assertEquals(HTTP_OK, response.getStatus());
+    GetObjectAttributesResponse attributes = (GetObjectAttributesResponse) 
response.getEntity();
+    assertNotNull(attributes.getETag());
+    assertNull(attributes.getObjectSize());
+    assertNull(attributes.getStorageClass());
+  }
+
+  @Test
+  public void testGetObjectAttributesChecksumRequestedButOmitted() throws 
IOException, OS3Exception {
+    assertSucceeds(() -> put(rest, BUCKET_NAME, KEY_NAME, CONTENT));
+
+    Response response = getObjectAttributes(rest, BUCKET_NAME, KEY_NAME, 
"Checksum,ETag");
+
+    assertEquals(HTTP_OK, response.getStatus());
+    GetObjectAttributesResponse attributes = (GetObjectAttributesResponse) 
response.getEntity();
+    assertNotNull(attributes.getETag());
+  }
+
+  @Test
+  public void testGetObjectAttributesMissingHeader() {
+    assertErrorResponse(INVALID_ARGUMENT,
+        () -> getObjectAttributes(rest, BUCKET_NAME, KEY_NAME, null));
+  }
+
+  @Test
+  public void testGetObjectAttributesInvalidAttribute() {
+    assertErrorResponse(INVALID_ARGUMENT,
+        () -> getObjectAttributes(rest, BUCKET_NAME, KEY_NAME, 
"NotAValidAttribute"));
+  }
+
+  @Test
+  public void testGetObjectAttributesNoKeyFound() {
+    assertErrorResponse(NO_SUCH_KEY,
+        () -> getObjectAttributes(rest, BUCKET_NAME, "nonexistent", "ETag"));
+  }
+
+  @Test
+  public void testGetObjectAttributesNoBucketFound() {
+    assertErrorResponse(NO_SUCH_BUCKET,
+        () -> getObjectAttributes(rest, "nonexistent", "any", "ETag"));
+  }
+
+  @Test
+  public void testGetObjectAttributesNonMultipartObjectHasNoParts() throws 
IOException, OS3Exception {
+    assertSucceeds(() -> put(rest, BUCKET_NAME, KEY_NAME, CONTENT));
+
+    Response response = getObjectAttributes(rest, BUCKET_NAME, KEY_NAME, 
"ObjectParts");
+
+    assertEquals(HTTP_OK, response.getStatus());
+    GetObjectAttributesResponse attributes = (GetObjectAttributesResponse) 
response.getEntity();
+    assertNull(attributes.getObjectParts());
+  }
+
+  @Test
+  public void testWhenKeyIsDirectoryAndKeyPathDoesNotEndWithASlash() throws 
Exception {
+    final String keyPath = "keyDir";
+    OzoneConfiguration config = new OzoneConfiguration();
+    config.setBoolean(OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED, true);
+    rest.setOzoneConfiguration(config);
+    bucket.createDirectory(keyPath);
+
+    assertErrorResponse(NO_SUCH_KEY,
+        () -> getObjectAttributes(rest, BUCKET_NAME, keyPath, "ETag"));
+  }
+
+  @Test
+  public void testWhenKeyIsDirectoryAndKeyPathEndsWithASlash() throws 
Exception {
+    final String keyPath = "keyDir/";
+    OzoneConfiguration config = new OzoneConfiguration();
+    config.setBoolean(OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED, true);
+    rest.setOzoneConfiguration(config);
+    bucket.createDirectory(keyPath);
+
+    Response response = getObjectAttributes(rest, BUCKET_NAME, keyPath, 
"ObjectSize");
+
+    assertEquals(HTTP_OK, response.getStatus());
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to