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

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 9c03883a86 [#9736][followup] feat(iceberg-rest): Add ETag header to 
registerTable response (#10641)
9c03883a86 is described below

commit 9c03883a8649299432729de253c120862daf6721
Author: akshay thorat <[email protected]>
AuthorDate: Mon Apr 6 00:54:43 2026 -0700

    [#9736][followup] feat(iceberg-rest): Add ETag header to registerTable 
response (#10641)
    
    ### What changes were proposed in this pull request?
    
    Add ETag header to the `registerTable` response for consistency with
    `createTable` and `updateTable`, which already include ETags. This was
    identified by @roryqi in [#10536
    
(comment)](https://github.com/apache/gravitino/pull/10536#issuecomment-4168013863).
    
    ### Why are the changes needed?
    
    The `registerTable` endpoint returns a `LoadTableResponse` but did not
    include an ETag header, unlike `createTable` and `updateTable`. This
    meant clients could not reuse ETags from `registerTable` for subsequent
    conditional `loadTable` requests (`If-None-Match`), breaking ETag
    consistency across all endpoints that return `LoadTableResponse`.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No user-facing API changes. The `registerTable` response now includes an
    `ETag` header when the table metadata has a metadata file location.
    
    ### How was this patch tested?
    
    - Added `testRegisterTableReturnsETag` test verifying ETag presence in
    `registerTable` response.
    - Updated test mock (`CatalogWrapperForTest`) to return `TableMetadata`
    with a `metadataFileLocation` so ETag can be generated.
    - All existing ETag and namespace tests pass.
---
 .../iceberg/service/IcebergRESTUtils.java          | 106 +++++++++++++++++++++
 .../service/rest/IcebergNamespaceOperations.java   |   2 +-
 .../service/rest/IcebergTableOperations.java       |  97 +++----------------
 .../service/rest/CatalogWrapperForTest.java        |   6 +-
 .../service/rest/IcebergNamespaceTestBase.java     |   2 +-
 .../rest/TestIcebergNamespaceOperations.java       |  23 +++++
 6 files changed, 151 insertions(+), 85 deletions(-)

diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
index 78ffd7e801..42ccd94ee4 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
@@ -21,6 +21,9 @@ package org.apache.gravitino.iceberg.service;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.base.Preconditions;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.ZoneId;
@@ -28,8 +31,10 @@ import java.util.Arrays;
 import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
 import java.util.stream.Stream;
 import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.EntityTag;
 import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
 import javax.ws.rs.core.Response.Status;
@@ -39,15 +44,116 @@ import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerConte
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.rest.responses.ErrorResponse;
+import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class IcebergRESTUtils {
 
+  private static final Logger LOG = 
LoggerFactory.getLogger(IcebergRESTUtils.class);
+
+  public static final String SNAPSHOT_ALL = "all";
+
+  public static final String SNAPSHOT_REFS = "refs";
+
+  /** Snapshot modes for the Iceberg loadTable endpoint. */
+  public enum SnapshotMode {
+    ALL(SNAPSHOT_ALL),
+    REFS(SNAPSHOT_REFS);
+
+    private final String value;
+
+    SnapshotMode(String value) {
+      this.value = value;
+    }
+
+    public String getValue() {
+      return value;
+    }
+  }
+
   private IcebergRESTUtils() {}
 
   public static <T> Response ok(T t) {
     return 
Response.status(Response.Status.OK).entity(t).type(MediaType.APPLICATION_JSON).build();
   }
 
+  /**
+   * Builds an OK response with the ETag header derived from the table 
metadata location. Uses the
+   * default snapshots value to ensure ETags from create/update/register are 
consistent with the
+   * default loadTable endpoint.
+   *
+   * @param loadTableResponse the table response to include in the body
+   * @return a Response with ETag header set
+   */
+  public static Response buildResponseWithETag(LoadTableResponse 
loadTableResponse) {
+    Optional<EntityTag> etag =
+        generateETag(
+            loadTableResponse.tableMetadata().metadataFileLocation(), 
SnapshotMode.ALL.getValue());
+    return buildResponseWithETag(loadTableResponse, etag);
+  }
+
+  /**
+   * Builds an OK response with the given ETag header.
+   *
+   * @param loadTableResponse the table response to include in the body
+   * @param etag the pre-computed ETag
+   * @return a Response with ETag header set if etag is present
+   */
+  public static Response buildResponseWithETag(
+      LoadTableResponse loadTableResponse, Optional<EntityTag> etag) {
+    Response.ResponseBuilder responseBuilder =
+        Response.ok(loadTableResponse, MediaType.APPLICATION_JSON_TYPE);
+    etag.ifPresent(responseBuilder::tag);
+    return responseBuilder.build();
+  }
+
+  /**
+   * Generates an ETag based on the table metadata file location. The ETag is 
a SHA-256 hash of the
+   * metadata location, which changes whenever the table metadata is updated. 
Uses the default
+   * snapshots value to ensure consistency.
+   *
+   * @param metadataLocation the metadata file location
+   * @return the generated ETag
+   */
+  public static Optional<EntityTag> generateETag(String metadataLocation) {
+    return generateETag(metadataLocation, SnapshotMode.ALL.getValue());
+  }
+
+  /**
+   * Generates an ETag based on the table metadata file location and snapshot 
mode. The ETag is a
+   * SHA-256 hash that incorporates both the metadata location and the 
snapshots parameter, ensuring
+   * distinct ETags for different representations of the same table version 
(e.g., snapshots=all vs
+   * snapshots=refs).
+   *
+   * @param metadataLocation the metadata file location
+   * @param snapshots the snapshots query parameter value (e.g., "all", "refs")
+   * @return the generated ETag
+   */
+  public static Optional<EntityTag> generateETag(String metadataLocation, 
String snapshots) {
+    if (StringUtils.isBlank(metadataLocation)) {
+      return Optional.empty();
+    }
+    try {
+      MessageDigest digest = MessageDigest.getInstance("SHA-256");
+      digest.update(metadataLocation.getBytes(StandardCharsets.UTF_8));
+      digest.update(snapshots.getBytes(StandardCharsets.UTF_8));
+      byte[] hash = digest.digest();
+      StringBuilder hexString = new StringBuilder();
+      for (byte b : hash) {
+        String hex = Integer.toHexString(0xff & b);
+        if (hex.length() == 1) {
+          hexString.append('0');
+        }
+        hexString.append(hex);
+      }
+      return Optional.of(new EntityTag(hexString.toString()));
+    } catch (NoSuchAlgorithmException e) {
+      LOG.warn("Failed to generate ETag for metadata location: {}", 
metadataLocation, e);
+      return Optional.empty();
+    }
+  }
+
   public static Response okWithoutContent() {
     return Response.status(Response.Status.OK).build();
   }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
index 1c669b86b6..ff09be9828 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceOperations.java
@@ -317,7 +317,7 @@ public class IcebergNamespaceOperations {
             LoadTableResponse loadTableResponse =
                 namespaceOperationDispatcher.registerTable(
                     context, icebergNS, registerTableRequest);
-            return IcebergRESTUtils.ok(loadTableResponse);
+            return IcebergRESTUtils.buildResponseWithETag(loadTableResponse);
           });
     } catch (Exception e) {
       return IcebergExceptionMapper.toRESTResponse(e);
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
index 7754f1037c..18d5adbdde 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
@@ -23,9 +23,6 @@ import com.codahale.metrics.annotation.Timed;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.annotations.VisibleForTesting;
-import java.nio.charset.StandardCharsets;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -98,9 +95,6 @@ public class IcebergTableOperations {
 
   @VisibleForTesting public static final String IF_NONE_MATCH = 
"If-None-Match";
 
-  @VisibleForTesting static final String DEFAULT_SNAPSHOTS = "all";
-  @VisibleForTesting static final String SNAPSHOTS_REFS = "refs";
-
   private IcebergMetricsManager icebergMetricsManager;
 
   private ObjectMapper icebergObjectMapper;
@@ -297,7 +291,7 @@ public class IcebergTableOperations {
           String namespace,
       @IcebergAuthorizationMetadata(type = RequestType.LOAD_TABLE) @Encoded() 
@PathParam("table")
           String table,
-      @DefaultValue(DEFAULT_SNAPSHOTS) @QueryParam("snapshots") String 
snapshots,
+      @DefaultValue(IcebergRESTUtils.SNAPSHOT_ALL) @QueryParam("snapshots") 
String snapshots,
       @HeaderParam(X_ICEBERG_ACCESS_DELEGATION) String accessDelegation,
       @HeaderParam(IF_NONE_MATCH) String ifNoneMatch) {
     String catalogName = IcebergRESTUtils.getCatalogName(prefix);
@@ -325,21 +319,21 @@ public class IcebergTableOperations {
               Optional<String> metadataLocation =
                   tableOperationDispatcher.getTableMetadataLocation(context, 
tableIdentifier);
               if (metadataLocation.isPresent()) {
-                EntityTag etag = generateETag(metadataLocation.get(), 
snapshots);
-                if (etag != null && etagMatches(ifNoneMatch, etag)) {
-                  return Response.notModified(etag).build();
+                Optional<EntityTag> etag = 
generateETag(metadataLocation.get(), snapshots);
+                if (etag.isPresent() && etagMatches(ifNoneMatch, etag.get())) {
+                  return Response.notModified(etag.get()).build();
                 }
               }
             }
 
             LoadTableResponse loadTableResponse =
                 tableOperationDispatcher.loadTable(context, tableIdentifier);
-            EntityTag etag =
+            Optional<EntityTag> etag =
                 
generateETag(loadTableResponse.tableMetadata().metadataFileLocation(), 
snapshots);
-            if (etag != null && etagMatches(ifNoneMatch, etag)) {
-              return Response.notModified(etag).build();
+            if (etag.isPresent() && etagMatches(ifNoneMatch, etag.get())) {
+              return Response.notModified(etag.get()).build();
             }
-            if (SNAPSHOTS_REFS.equals(snapshots)) {
+            if 
(IcebergRESTUtils.SnapshotMode.REFS.getValue().equals(snapshots)) {
               loadTableResponse = filterSnapshotsByRefs(loadTableResponse);
             }
             return buildResponseWithETag(loadTableResponse, etag);
@@ -571,84 +565,23 @@ public class IcebergTableOperations {
         .build();
   }
 
-  /**
-   * Builds an OK response with the ETag header derived from the table 
metadata location. Uses the
-   * default snapshots value to ensure ETags from create/update are consistent 
with the default
-   * loadTable endpoint.
-   *
-   * @param loadTableResponse the table response to include in the body
-   * @return a Response with ETag header set
-   */
   private static Response buildResponseWithETag(LoadTableResponse 
loadTableResponse) {
-    EntityTag etag =
-        generateETag(loadTableResponse.tableMetadata().metadataFileLocation(), 
DEFAULT_SNAPSHOTS);
-    return buildResponseWithETag(loadTableResponse, etag);
+    return IcebergRESTUtils.buildResponseWithETag(loadTableResponse);
   }
 
-  /**
-   * Builds an OK response with the given ETag header.
-   *
-   * @param loadTableResponse the table response to include in the body
-   * @param etag the pre-computed ETag, may be null
-   * @return a Response with ETag header set if etag is non-null
-   */
   private static Response buildResponseWithETag(
-      LoadTableResponse loadTableResponse, EntityTag etag) {
-    Response.ResponseBuilder responseBuilder =
-        Response.ok(loadTableResponse, MediaType.APPLICATION_JSON_TYPE);
-    if (etag != null) {
-      responseBuilder.tag(etag);
-    }
-    return responseBuilder.build();
+      LoadTableResponse loadTableResponse, Optional<EntityTag> etag) {
+    return IcebergRESTUtils.buildResponseWithETag(loadTableResponse, etag);
   }
 
-  /**
-   * Generates an ETag based on the table metadata file location. The ETag is 
a SHA-256 hash of the
-   * metadata location, which changes whenever the table metadata is updated.
-   *
-   * @param metadataLocation the metadata file location
-   * @return the generated ETag, or null if generation fails
-   */
   @VisibleForTesting
-  static EntityTag generateETag(String metadataLocation) {
-    return generateETag(metadataLocation, null);
+  static Optional<EntityTag> generateETag(String metadataLocation) {
+    return IcebergRESTUtils.generateETag(metadataLocation);
   }
 
-  /**
-   * Generates an ETag based on the table metadata file location and snapshot 
mode. The ETag is a
-   * SHA-256 hash that incorporates both the metadata location and the 
snapshots parameter, ensuring
-   * distinct ETags for different representations of the same table version 
(e.g., snapshots=all vs
-   * snapshots=refs).
-   *
-   * @param metadataLocation the metadata file location
-   * @param snapshots the snapshots query parameter value (e.g., "all", 
"refs"), may be null
-   * @return the generated ETag, or null if generation fails
-   */
   @VisibleForTesting
-  static EntityTag generateETag(String metadataLocation, String snapshots) {
-    if (metadataLocation == null) {
-      return null;
-    }
-    try {
-      MessageDigest digest = MessageDigest.getInstance("SHA-256");
-      digest.update(metadataLocation.getBytes(StandardCharsets.UTF_8));
-      if (snapshots != null) {
-        digest.update(snapshots.getBytes(StandardCharsets.UTF_8));
-      }
-      byte[] hash = digest.digest();
-      StringBuilder hexString = new StringBuilder();
-      for (byte b : hash) {
-        String hex = Integer.toHexString(0xff & b);
-        if (hex.length() == 1) {
-          hexString.append('0');
-        }
-        hexString.append(hex);
-      }
-      return new EntityTag(hexString.toString());
-    } catch (NoSuchAlgorithmException e) {
-      LOG.warn("Failed to generate ETag for metadata location: {}", 
metadataLocation, e);
-      return null;
-    }
+  static Optional<EntityTag> generateETag(String metadataLocation, String 
snapshots) {
+    return IcebergRESTUtils.generateETag(metadataLocation, snapshots);
   }
 
   /**
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/CatalogWrapperForTest.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/CatalogWrapperForTest.java
index 89150a02c8..94c29e5de1 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/CatalogWrapperForTest.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/CatalogWrapperForTest.java
@@ -31,6 +31,7 @@ import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.Table;
 import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableMetadataParser;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
@@ -66,9 +67,12 @@ public class CatalogWrapperForTest extends 
CatalogWrapperForREST {
     }
 
     Schema mockSchema = new Schema(NestedField.of(1, false, "foo_string", 
StringType.get()));
-    TableMetadata tableMetadata =
+    TableMetadata baseMetadata =
         TableMetadata.newTableMetadata(
             mockSchema, PartitionSpec.unpartitioned(), "/mock", 
ImmutableMap.of());
+    String json = TableMetadataParser.toJson(baseMetadata);
+    TableMetadata tableMetadata =
+        TableMetadataParser.fromJson("/mock/metadata/v1.metadata.json", json);
     LoadTableResponse loadTableResponse =
         LoadTableResponse.builder()
             .withTableMetadata(tableMetadata)
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceTestBase.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceTestBase.java
index 1362bb2461..bd749821fa 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceTestBase.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergNamespaceTestBase.java
@@ -52,7 +52,7 @@ public class IcebergNamespaceTestBase extends IcebergTestBase 
{
         .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
   }
 
-  private Response doRegisterTable(String tableName, Namespace ns) {
+  protected Response doRegisterTable(String tableName, Namespace ns) {
     RegisterTableRequest request =
         
ImmutableRegisterTableRequest.builder().name(tableName).metadataLocation("mock").build();
     return getNamespaceClientBuilder(Optional.of(ns), Optional.of("register"), 
Optional.empty())
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceOperations.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceOperations.java
index 1f07808687..2bd62af968 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergNamespaceOperations.java
@@ -22,6 +22,9 @@ import java.util.Arrays;
 import java.util.Optional;
 import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.core.Application;
+import javax.ws.rs.core.EntityTag;
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
 import org.apache.gravitino.listener.api.event.Event;
 import org.apache.gravitino.listener.api.event.IcebergCreateNamespaceEvent;
 import 
org.apache.gravitino.listener.api.event.IcebergCreateNamespaceFailureEvent;
@@ -189,6 +192,26 @@ public class TestIcebergNamespaceOperations extends 
IcebergNamespaceTestBase {
     verifyRegisterTableSucc("register_foo2", Namespace.of("register_ns_2", 
"a"));
   }
 
+  @Test
+  void testRegisterTableReturnsETag() {
+    Namespace ns = Namespace.of("register_etag_ns");
+    Response response = doRegisterTable("register_etag_foo1", ns);
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
response.getStatus());
+    String etag = response.getHeaderString("ETag");
+    Assertions.assertNotNull(etag, "ETag header should be present in register 
table response");
+    Assertions.assertFalse(etag.isEmpty(), "ETag header should not be empty");
+
+    // Verify the ETag value matches the expected SHA-256 hash of the known 
mock metadata location
+    Optional<EntityTag> expectedETag =
+        IcebergRESTUtils.generateETag(
+            "/mock/metadata/v1.metadata.json", 
IcebergRESTUtils.SnapshotMode.ALL.getValue());
+    Assertions.assertTrue(expectedETag.isPresent(), "Expected ETag should be 
generated");
+    Assertions.assertEquals(
+        "\"" + expectedETag.get().getValue() + "\"",
+        etag,
+        "ETag should match SHA-256 hash of mock metadata location with default 
snapshots");
+  }
+
   @ParameterizedTest
   @ValueSource(strings = {"", IcebergRestTestUtil.PREFIX})
   void testListNamespace(String prefix) {

Reply via email to