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

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


The following commit(s) were added to refs/heads/master by this push:
     new 5ffc2f9991d HIVE-29877: HMSCatalogAdapter fails to route Iceberg REST 
endpoints containing an optional {prefix} (#6749)
5ffc2f9991d is described below

commit 5ffc2f9991deea506931de3e1f886bdf50a37279
Author: Raghav Aggarwal <[email protected]>
AuthorDate: Wed Sep 23 16:34:10 2026 +0530

    HIVE-29877: HMSCatalogAdapter fails to route Iceberg REST endpoints 
containing an optional {prefix} (#6749)
---
 .../org/apache/iceberg/rest/HMSCatalogAdapter.java | 197 ++++++++++-----------
 .../org/apache/iceberg/rest/HMSCatalogServlet.java |   2 +-
 .../iceberg/rest/TestHMSCatalogAdapterRoutes.java  | 127 +++++++++++++
 3 files changed, 222 insertions(+), 104 deletions(-)

diff --git 
a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java
 
b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java
index 885e3006352..127b0ac6663 100644
--- 
a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java
+++ 
b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java
@@ -82,12 +82,18 @@
 import org.slf4j.LoggerFactory;
 
 /**
- * Original @ <a 
href="https://github.com/apache/iceberg/blob/apache-iceberg-1.9.1/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java";>RESTCatalogAdapter.java</a>
+ * Original @ <a 
href="https://github.com/apache/iceberg/blob/apache-iceberg-1.11.0/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java";>RESTCatalogAdapter.java</a>
  * Adaptor class to translate REST requests into {@link Catalog} API calls.
  */
 public class HMSCatalogAdapter implements Closeable {
   private static final Logger LOG = 
LoggerFactory.getLogger(HMSCatalogAdapter.class);
-  private static final Splitter SLASH = Splitter.on('/');
+  private static final Splitter SLASH = Splitter.on('/').omitEmptyStrings();
+
+  private static final String PREFIX_VAR = "prefix";
+  private static final String PREFIX_PLACEHOLDER = "{" + PREFIX_VAR + "}";
+
+  /** Index of the first prefix segment, right after "v1". */
+  private static final int PREFIX_START = 1;
 
   private static final Map<Class<? extends Exception>, Integer> 
EXCEPTION_ERROR_CODES =
       ImmutableMap.<Class<? extends Exception>, Integer>builder()
@@ -161,7 +167,8 @@ enum Route {
     private final Map<Integer, String> requirements;
     private final Map<Integer, String> variables;
     private final Class<? extends RESTRequest> requestClass;
-    private final String resourcePath;
+    private final String pathTemplate;
+    private final boolean acceptsPrefix;
 
     Route(HTTPMethod method, String pattern) {
       this(method, pattern, null);
@@ -172,11 +179,13 @@ enum Route {
         String pattern,
         Class<? extends RESTRequest> requestClass) {
       this.method = method;
-      this.resourcePath = pattern;
+      this.pathTemplate = pattern;
+
+      List<String> segments = SLASH.splitToList(pattern);
+      this.acceptsPrefix = segments.contains(PREFIX_PLACEHOLDER);
 
       // parse the pattern into requirements and variables
-      List<String> parts =
-          SLASH.splitToList(pattern.replaceFirst("/v1/", 
"v1/").replace("/{prefix}", ""));
+      List<String> parts = segments.stream().filter(s -> 
!PREFIX_PLACEHOLDER.equals(s)).toList();
       ImmutableMap.Builder<Integer, String> requirementsBuilder = 
ImmutableMap.builder();
       ImmutableMap.Builder<Integer, String> variablesBuilder = 
ImmutableMap.builder();
       for (int pos = 0; pos < parts.size(); pos += 1) {
@@ -189,38 +198,73 @@ enum Route {
       }
 
       this.requestClass = requestClass;
-
       this.requiredLength = parts.size();
       this.requirements = requirementsBuilder.build();
       this.variables = variablesBuilder.build();
     }
 
+    /** Number of extra segments in the request, i.e. the prefix length. */
+    private int prefixLength(List<String> requestPath) {
+      return requestPath.size() - requiredLength;
+    }
+
+    /** Maps a template index to a request-path index, skipping prefix 
segments. */
+    private int mappedIndex(int templateIndex, int prefixLength) {
+      return templateIndex < PREFIX_START ? templateIndex : templateIndex + 
prefixLength;
+    }
+
     private boolean matches(HTTPMethod requestMethod, List<String> 
requestPath) {
-      return method == requestMethod
-          && requiredLength == requestPath.size()
-          && requirements.entrySet().stream()
-          .allMatch(
-              requirement ->
-                  requirement
-                      .getValue()
-                      
.equalsIgnoreCase(requestPath.get(requirement.getKey())));
+      if (method != requestMethod) {
+        return false;
+      }
+
+      // A multi-segment prefix like "catalogs/my_catalog" gives prefixLength 
== 2
+      int prefixLength = prefixLength(requestPath);
+
+      // If the path is too short, or too long but the route doesn't support a 
prefix, reject.
+      if (prefixLength < 0 || (prefixLength > 0 && !acceptsPrefix)) {
+        return false;
+      }
+
+      for (Map.Entry<Integer, String> requirement : requirements.entrySet()) {
+        String actual = requestPath.get(mappedIndex(requirement.getKey(), 
prefixLength));
+        if (!requirement.getValue().equalsIgnoreCase(actual)) {
+          return false;
+        }
+      }
+      return true;
     }
 
     private Map<String, String> variables(List<String> requestPath) {
+      int prefixLength = prefixLength(requestPath);
+
       ImmutableMap.Builder<String, String> vars = ImmutableMap.builder();
-      variables.forEach((key, value) -> vars.put(value, requestPath.get(key)));
+      for (Map.Entry<Integer, String> var : variables.entrySet()) {
+        vars.put(var.getValue(), requestPath.get(mappedIndex(var.getKey(), 
prefixLength)));
+      }
+
+      if (prefixLength > 0) {
+        // Clients insert the configured prefix verbatim, so "catalogs/sales" 
arrives
+        // as two segments; rejoin them. An encoded %2F inside a prefix is not 
preserved.
+        String prefix = String.join("/",
+            requestPath.subList(PREFIX_START, PREFIX_START + prefixLength));
+        // The HMS backend serves a single HiveCatalog and does not scope by 
prefix yet.
+        LOG.debug("Ignoring request prefix '{}' for route {}", prefix, this);
+        vars.put(PREFIX_VAR, prefix);
+      }
       return vars.build();
     }
 
     public static Pair<Route, Map<String, String>> from(HTTPMethod method, 
String path) {
       List<String> parts = SLASH.splitToList(path);
+      Route best = null;
       for (Route candidate : Route.values()) {
-        if (candidate.matches(method, parts)) {
-          return Pair.of(candidate, candidate.variables(parts));
+        if (candidate.matches(method, parts)
+            && (best == null || candidate.prefixLength(parts) < 
best.prefixLength(parts))) {
+          best = candidate;
         }
       }
-
-      return null;
+      return best == null ? null : Pair.of(best, best.variables(parts));
     }
 
     public Class<? extends RESTRequest> requestClass() {
@@ -230,7 +274,7 @@ public Class<? extends RESTRequest> requestClass() {
 
   private ConfigResponse config() {
     final List<Endpoint> endpoints = Arrays.stream(Route.values())
-        .map(r -> Endpoint.create(r.method.name(), r.resourcePath)).toList();
+        .map(r -> Endpoint.create(r.method.name(), r.pathTemplate)).toList();
     return castResponse(ConfigResponse.class, 
ConfigResponse.builder().withEndpoints(endpoints).build());
   }
 
@@ -434,92 +478,39 @@ private static void commitTransaction(Catalog catalog, 
CommitTransactionRequest
     // only commit if validations passed previously
     transactions.forEach(Transaction::commitTransaction);
   }
-  
-  @SuppressWarnings({"MethodLength", "unchecked"})
+
+  @SuppressWarnings({"unchecked"})
   private <T extends RESTResponse> T handleRequest(
       Route route, Map<String, String> vars, Object body) {
-    switch (route) {
-      case CONFIG:
-        return (T) config();
-
-      case LIST_NAMESPACES:
-        return (T) listNamespaces(vars);
-
-      case CREATE_NAMESPACE:
-        return (T) createNamespace(body);
-
-      case NAMESPACE_EXISTS:
-        return (T) namespaceExists(vars);
-
-      case LOAD_NAMESPACE:
-        return (T) loadNamespace(vars);
-
-      case DROP_NAMESPACE:
-        return (T) dropNamespace(vars);
-
-      case UPDATE_NAMESPACE:
-        return (T) updateNamespace(vars, body);
-
-      case LIST_TABLES:
-        return (T) listTables(vars);
-
-      case CREATE_TABLE:
-        return (T) createTable(vars, body);
-
-      case DROP_TABLE:
-        return (T) dropTable(vars);
-
-      case TABLE_EXISTS:
-        return (T) tableExists(vars);
-
-      case LOAD_TABLE:
-        return (T) loadTable(vars);
-
-      case REGISTER_TABLE:
-        return (T) registerTable(vars, body);
-
-      case UPDATE_TABLE:
-        return (T) updateTable(vars, body);
-
-      case RENAME_TABLE:
-        return (T) renameTable(body);
-
-      case REPORT_METRICS:
-        return (T) reportMetrics(vars, body);
-
-      case COMMIT_TRANSACTION:
-        return (T) commitTransaction(body);
-        
-      case LIST_VIEWS:
-        return (T) listViews(vars);
-
-      case CREATE_VIEW:
-          return (T) createView(vars, body);
-
-      case VIEW_EXISTS:
-        return (T) viewExists(vars);
-
-      case LOAD_VIEW:
-        return (T) loadView(vars);
-
-      case UPDATE_VIEW:
-        return (T) updateView(vars, body);
-        
-      case RENAME_VIEW:
-        return (T) renameView(body);
-        
-      case DROP_VIEW:
-        return (T) dropView(vars);
-
-      case REGISTER_VIEW:
-        return (T) registerView(vars, body);
-
-      default:
-    }
-    return null;
+    return (T) switch (route) {
+      case CONFIG -> config();
+      case LIST_NAMESPACES -> listNamespaces(vars);
+      case CREATE_NAMESPACE -> createNamespace(body);
+      case NAMESPACE_EXISTS -> namespaceExists(vars);
+      case LOAD_NAMESPACE -> loadNamespace(vars);
+      case DROP_NAMESPACE -> dropNamespace(vars);
+      case UPDATE_NAMESPACE -> updateNamespace(vars, body);
+      case LIST_TABLES -> listTables(vars);
+      case CREATE_TABLE -> createTable(vars, body);
+      case DROP_TABLE -> dropTable(vars);
+      case TABLE_EXISTS -> tableExists(vars);
+      case LOAD_TABLE -> loadTable(vars);
+      case REGISTER_TABLE -> registerTable(vars, body);
+      case UPDATE_TABLE -> updateTable(vars, body);
+      case RENAME_TABLE -> renameTable(body);
+      case REPORT_METRICS -> reportMetrics(vars, body);
+      case COMMIT_TRANSACTION -> commitTransaction(body);
+      case LIST_VIEWS -> listViews(vars);
+      case CREATE_VIEW -> createView(vars, body);
+      case VIEW_EXISTS -> viewExists(vars);
+      case LOAD_VIEW -> loadView(vars);
+      case UPDATE_VIEW -> updateView(vars, body);
+      case RENAME_VIEW -> renameView(body);
+      case DROP_VIEW -> dropView(vars);
+      case REGISTER_VIEW -> registerView(vars, body);
+    };
   }
 
-
   <T extends RESTResponse> T execute(
       HTTPMethod method,
       String path,
diff --git 
a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java
 
b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java
index 92cd4af484a..3aab5d418e7 100644
--- 
a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java
+++ 
b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java
@@ -35,7 +35,7 @@
 import org.slf4j.LoggerFactory;
 
 /**
- * Original @ <a 
href="https://github.com/apache/iceberg/blob/apache-iceberg-1.9.1/core/src/test/java/org/apache/iceberg/rest/RESTCatalogServlet.java";>RESTCatalogServlet.java</a>
+ * Original @ <a 
href="https://github.com/apache/iceberg/blob/apache-iceberg-1.11.0/core/src/test/java/org/apache/iceberg/rest/RESTCatalogServlet.java";>RESTCatalogServlet.java</a>
  * The RESTCatalogServlet provides a servlet implementation used in 
combination with a
  * RESTCatalogAdaptor to proxy the REST Spec to any Catalog implementation.
  */
diff --git 
a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCatalogAdapterRoutes.java
 
b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCatalogAdapterRoutes.java
new file mode 100644
index 00000000000..f98d8d7c468
--- /dev/null
+++ 
b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCatalogAdapterRoutes.java
@@ -0,0 +1,127 @@
+/*
+ * 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.iceberg.rest;
+
+import java.util.Map;
+import org.apache.iceberg.util.Pair;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.apache.iceberg.rest.HTTPRequest.HTTPMethod;
+
+public class TestHMSCatalogAdapterRoutes {
+
+  @Test
+  public void testNoPrefix() {
+    Pair<HMSCatalogAdapter.Route, Map<String, String>> route =
+        HMSCatalogAdapter.Route.from(HTTPMethod.GET, "v1/namespaces");
+    Assertions.assertNotNull(route, "Route should match");
+    Assertions.assertEquals(HMSCatalogAdapter.Route.LIST_NAMESPACES, 
route.first());
+    Assertions.assertNull(route.second().get("prefix"), "Should not have 
prefix");
+  }
+
+  @Test
+  public void testSingleSegmentPrefix() {
+    Pair<HMSCatalogAdapter.Route, Map<String, String>> route =
+        HMSCatalogAdapter.Route.from(HTTPMethod.GET, 
"v1/my_catalog/namespaces/accounting/tables");
+    Assertions.assertNotNull(route, "Route should match");
+    Assertions.assertEquals(HMSCatalogAdapter.Route.LIST_TABLES, 
route.first());
+    Assertions.assertEquals("my_catalog", route.second().get("prefix"));
+    Assertions.assertEquals("accounting", route.second().get("namespace"));
+  }
+
+  @Test
+  public void testMultiSegmentPrefix() {
+    Pair<HMSCatalogAdapter.Route, Map<String, String>> route =
+        HMSCatalogAdapter.Route.from(
+            HTTPMethod.GET, 
"v1/catalogs/sales/namespaces/accounting/tables/my_table");
+    Assertions.assertNotNull(route, "Route should match");
+    Assertions.assertEquals(HMSCatalogAdapter.Route.LOAD_TABLE, route.first());
+    Assertions.assertEquals("catalogs/sales", route.second().get("prefix"));
+    Assertions.assertEquals("accounting", route.second().get("namespace"));
+    Assertions.assertEquals("my_table", route.second().get("table"));
+  }
+
+  @Test
+  public void testTripleSegmentPrefix() {
+    Pair<HMSCatalogAdapter.Route, Map<String, String>> route =
+        HMSCatalogAdapter.Route.from(
+            HTTPMethod.GET, 
"v1/us-east-1/prod/tenant_99/namespaces/accounting/tables");
+    Assertions.assertNotNull(route, "Route should match");
+    Assertions.assertEquals(HMSCatalogAdapter.Route.LIST_TABLES, 
route.first());
+    Assertions.assertEquals("us-east-1/prod/tenant_99", 
route.second().get("prefix"));
+    Assertions.assertEquals("accounting", route.second().get("namespace"));
+  }
+
+  @Test
+  public void testNegativeCases() {
+    // 1. Wrong HTTP Method (POST instead of GET for LIST_NAMESPACES)
+    Pair<HMSCatalogAdapter.Route, Map<String, String>> wrongMethod =
+        HMSCatalogAdapter.Route.from(HTTPMethod.POST, 
"v1/catalogs/sales/namespaces");
+    // Should match CREATE_NAMESPACE instead of LIST_NAMESPACES
+    Assertions.assertNotNull(wrongMethod, "Route should match 
CREATE_NAMESPACE");
+    Assertions.assertEquals(HMSCatalogAdapter.Route.CREATE_NAMESPACE, 
wrongMethod.first());
+
+    // 2. Bad path (wrong resource string)
+    Pair<HMSCatalogAdapter.Route, Map<String, String>> badResource =
+        HMSCatalogAdapter.Route.from(HTTPMethod.GET, 
"v1/catalogs/sales/views/accounting");
+    Assertions.assertNull(badResource, "Should not match any known pattern");
+
+    // 3. Path too short (missing required anchors)
+    Pair<HMSCatalogAdapter.Route, Map<String, String>> pathTooShort =
+        HMSCatalogAdapter.Route.from(HTTPMethod.GET, "v1/");
+    Assertions.assertNull(pathTooShort, "Should not match, missing namespaces 
anchor");
+
+    // 4. Path too long for a route without a prefix placeholder (config route)
+    Pair<HMSCatalogAdapter.Route, Map<String, String>> configTooLong =
+        HMSCatalogAdapter.Route.from(HTTPMethod.GET, "v1/my_catalog/config");
+    Assertions.assertNull(configTooLong, "Should not match config route, 
doesn't accept prefix");
+  }
+
+  private Pair<HMSCatalogAdapter.Route, Map<String, String>> route(String 
path) {
+    return HMSCatalogAdapter.Route.from(HTTPMethod.GET, path);
+  }
+
+  @Test
+  public void testResourceNamedNamespacesIsNotTreatedAsPrefix() {
+    // Case 1: LOAD_NAMESPACE
+    Pair<HMSCatalogAdapter.Route, Map<String, String>> loadNamespace =
+        route("v1/namespaces/namespaces");
+    Assertions.assertNotNull(loadNamespace, "Should match LOAD_NAMESPACE");
+    Assertions.assertEquals(HMSCatalogAdapter.Route.LOAD_NAMESPACE, 
loadNamespace.first());
+
+    // Case 2: LOAD_TABLE
+    Pair<HMSCatalogAdapter.Route, Map<String, String>> loadTable =
+        route("v1/namespaces/db/tables/namespaces");
+    Assertions.assertNotNull(loadTable, "Should match LOAD_TABLE");
+    Assertions.assertEquals(HMSCatalogAdapter.Route.LOAD_TABLE, 
loadTable.first());
+
+    // Case 3: UPDATE_TABLE
+    Pair<HMSCatalogAdapter.Route, Map<String, String>> updateTable =
+        HMSCatalogAdapter.Route.from(HTTPMethod.POST, 
"v1/namespaces/db/tables/namespaces");
+    Assertions.assertNotNull(updateTable, "Should match UPDATE_TABLE");
+    Assertions.assertEquals(HMSCatalogAdapter.Route.UPDATE_TABLE, 
updateTable.first());
+
+    // Case 4: LOAD_TABLE with prefix
+    Pair<HMSCatalogAdapter.Route, Map<String, String>> loadTableWithPrefix =
+        route("v1/catalogs/sales/namespaces/db/tables/namespaces");
+    Assertions.assertNotNull(loadTableWithPrefix, "Should match LOAD_TABLE 
with prefix");
+    Assertions.assertEquals(HMSCatalogAdapter.Route.LOAD_TABLE, 
loadTableWithPrefix.first());
+  }
+}

Reply via email to