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

jerryshao pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new f900dc599d [Cherry-pick to branch-1.3] [#13781] fix(client): Encode 
spaces as %20 in REST path segments (#13784) (#13811)
f900dc599d is described below

commit f900dc599d8cafced87b422c8cb616cae6d77800
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Oct 14 08:17:59 2026 +0800

    [Cherry-pick to branch-1.3] [#13781] fix(client): Encode spaces as %20 in 
REST path segments (#13784) (#13811)
    
    **Cherry-pick Information:**
    - Original commit: de25ce3bd82771d746253606156877988caeafb1
    - Target branch: `branch-1.3`
    - Status: ✅ Clean cherry-pick (no conflicts)
    
    ---------
    
    Co-authored-by: Yuhui <[email protected]>
---
 .../gravitino/client/TestRelationalCatalog.java    | 24 ++++++++++++++++++++
 .../java/org/apache/gravitino/rest/RESTUtils.java  | 24 ++++++++++++++------
 .../org/apache/gravitino/rest/TestRESTUtils.java   | 10 +++++++--
 .../server/web/rest/TestTableOperations.java       | 26 ++++++++++++++++++++++
 .../integration/test/hive/SparkHiveCatalogIT.java  | 13 +++++------
 5 files changed, 81 insertions(+), 16 deletions(-)

diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestRelationalCatalog.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestRelationalCatalog.java
index 64feaa121c..7c11f94707 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestRelationalCatalog.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestRelationalCatalog.java
@@ -816,6 +816,30 @@ public class TestRelationalCatalog extends TestBase {
     Assertions.assertTrue(ex.getMessage().contains("table not found"));
   }
 
+  @Test
+  public void testLoadTableWithSpaceInName() throws JsonProcessingException {
+    NameIdentifier tableId = NameIdentifier.of("schema1", "table 1");
+    Namespace fullNamespace = Namespace.of(metalakeName, catalogName, 
tableId.namespace().level(0));
+    // MockServer matches the raw request path, so the space must be sent as 
%20.
+    String tablePath =
+        withSlash(RelationalCatalog.formatTableRequestPath(fullNamespace) + 
"/table%201");
+    ColumnDTO[] columns =
+        new ColumnDTO[] {createMockColumn("col1", Types.StringType.get(), 
"comment1")};
+    TableDTO expectedTable =
+        createMockTable(
+            "table 1",
+            columns,
+            "comment",
+            Collections.emptyMap(),
+            new Partitioning[0],
+            DistributionDTO.NONE,
+            SortOrderDTO.EMPTY_SORT);
+    buildMockResource(Method.GET, tablePath, null, new 
TableResponse(expectedTable), SC_OK);
+
+    Table table = catalog.asTableCatalog().loadTable(tableId);
+    Assertions.assertEquals("table 1", table.name());
+  }
+
   @Test
   public void testRenameTable() throws JsonProcessingException {
     NameIdentifier tableId = NameIdentifier.of("schema1", "table1");
diff --git a/common/src/main/java/org/apache/gravitino/rest/RESTUtils.java 
b/common/src/main/java/org/apache/gravitino/rest/RESTUtils.java
index 8b95669cee..bdcb873f8d 100644
--- a/common/src/main/java/org/apache/gravitino/rest/RESTUtils.java
+++ b/common/src/main/java/org/apache/gravitino/rest/RESTUtils.java
@@ -44,6 +44,8 @@ public class RESTUtils {
   private static final Joiner.MapJoiner FORM_JOINER = 
Joiner.on("&").withKeyValueSeparator("=");
   private static final Splitter.MapSplitter FORM_SPLITTER =
       Splitter.on("&").withKeyValueSeparator("=");
+  private static final String FORM_ENCODED_SPACE = "+";
+  private static final String PERCENT_ENCODED_SPACE = "%20";
 
   private RESTUtils() {}
 
@@ -75,7 +77,7 @@ public class RESTUtils {
     ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
     formData.forEach(
         (key, value) ->
-            builder.put(encodeString(String.valueOf(key)), 
encodeString(String.valueOf(value))));
+            builder.put(formEncode(String.valueOf(key)), 
formEncode(String.valueOf(value))));
     return FORM_JOINER.join(builder.build());
   }
 
@@ -93,7 +95,8 @@ public class RESTUtils {
   }
 
   /**
-   * URL encode a string.
+   * URL encode a string. Spaces are encoded as {@code %20}, so the result is 
valid both as a path
+   * segment and as a query or form value.
    *
    * @param toEncode The string to encode.
    * @return The URL encoded string.
@@ -102,11 +105,9 @@ public class RESTUtils {
    */
   public static String encodeString(String toEncode) {
     Preconditions.checkArgument(toEncode != null, "Invalid string to encode: 
null");
-    try {
-      return URLEncoder.encode(toEncode, StandardCharsets.UTF_8.name());
-    } catch (UnsupportedEncodingException e) {
-      throw new GravitinoRuntimeException("Failed to encode string: %s", 
toEncode);
-    }
+    // URLEncoder follows the form encoding and turns a space into '+', which 
is a literal '+'
+    // in a path segment. A literal '+' in the input is already encoded as %2B 
at this point.
+    return formEncode(toEncode).replace(FORM_ENCODED_SPACE, 
PERCENT_ENCODED_SPACE);
   }
 
   /**
@@ -169,4 +170,13 @@ public class RESTUtils {
     }
     throw new IOException("No available port in the range: " + portRangeStart 
+ ":" + portRangeEnd);
   }
+
+  // Encodes in application/x-www-form-urlencoded format, in which a space 
becomes '+'.
+  private static String formEncode(String toEncode) {
+    try {
+      return URLEncoder.encode(toEncode, StandardCharsets.UTF_8.name());
+    } catch (UnsupportedEncodingException e) {
+      throw new GravitinoRuntimeException("Failed to encode string: %s", 
toEncode);
+    }
+  }
 }
diff --git a/common/src/test/java/org/apache/gravitino/rest/TestRESTUtils.java 
b/common/src/test/java/org/apache/gravitino/rest/TestRESTUtils.java
index 6402a60a5d..465a3331df 100644
--- a/common/src/test/java/org/apache/gravitino/rest/TestRESTUtils.java
+++ b/common/src/test/java/org/apache/gravitino/rest/TestRESTUtils.java
@@ -45,6 +45,11 @@ class TestRESTUtils {
     String expected = "key1=value1&key2=value2";
     assertEquals(expected, RESTUtils.encodeFormData(formData));
 
+    // Form data keeps the form encoding, in which a space becomes '+'.
+    assertEquals(
+        "scope=read+write&secret=a%2Bb",
+        RESTUtils.encodeFormData(ImmutableMap.of("scope", "read write", 
"secret", "a+b")));
+
     Map<Object, Object> emptyFormData = ImmutableMap.of();
     assertEquals("", RESTUtils.encodeFormData(emptyFormData));
   }
@@ -67,8 +72,9 @@ class TestRESTUtils {
   void testEncodeString() {
     assertEquals("test", RESTUtils.encodeString("test"));
     assertEquals("", RESTUtils.encodeString(""));
-    /* not %20 as you might expect */
-    assertEquals("hello+world", RESTUtils.encodeString("hello world"));
+    assertEquals("hello%20world", RESTUtils.encodeString("hello world"));
+    assertEquals("a%2Bb", RESTUtils.encodeString("a+b"));
+    assertEquals("a+b c", RESTUtils.decodeString(RESTUtils.encodeString("a+b 
c")));
     assertThrows(IllegalArgumentException.class, () -> 
RESTUtils.encodeString(null));
   }
 
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestTableOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestTableOperations.java
index 4d11ccb61e..2c8d0fdff1 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestTableOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestTableOperations.java
@@ -508,6 +508,32 @@ public class TestTableOperations extends 
BaseOperationsTest {
     Assertions.assertArrayEquals(partitioningWithAssignments, 
tableResp.getTable().partitioning());
   }
 
+  @Test
+  public void testLoadTableWithSpaceInName() {
+    when(dispatcher.loadTable(any()))
+        .thenAnswer(
+            invocation -> {
+              NameIdentifier ident = invocation.getArgument(0);
+              return mockTable(
+                  ident.name(),
+                  new Column[] {mockColumn("col1", Types.StringType.get())},
+                  "mock comment",
+                  ImmutableMap.of(),
+                  new Transform[0]);
+            });
+
+    // The client builds request paths with RESTUtils.encodeString, so the 
server must decode the
+    // encoded name back to the original one.
+    try (Response resp =
+        target(tablePath(metalake, catalog, schema) + 
RESTUtils.encodeString("table 1"))
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get()) {
+      Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
resp.getStatus());
+      Assertions.assertEquals("table 1", 
resp.readEntity(TableResponse.class).getTable().name());
+    }
+  }
+
   @Test
   public void testLoadTable() {
     Column[] columns =
diff --git 
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/hive/SparkHiveCatalogIT.java
 
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/hive/SparkHiveCatalogIT.java
index e5393b525c..ba1c68cb3a 100644
--- 
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/hive/SparkHiveCatalogIT.java
+++ 
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/integration/test/hive/SparkHiveCatalogIT.java
@@ -39,6 +39,7 @@ import 
org.apache.gravitino.spark.connector.integration.test.util.SparkTableInfo
 import 
org.apache.gravitino.spark.connector.integration.test.util.SparkTableInfoChecker;
 import org.apache.hadoop.fs.Path;
 import org.apache.spark.sql.AnalysisException;
+import org.apache.spark.sql.catalyst.analysis.PartitionsAlreadyExistException;
 import org.apache.spark.sql.connector.catalog.TableCatalog;
 import org.apache.spark.sql.types.DataTypes;
 import org.junit.jupiter.api.Assertions;
@@ -185,13 +186,11 @@ public abstract class SparkHiveCatalogIT extends 
SparkCommonIT {
     Assertions.assertEquals(1, nullPartitionInfo.size());
     
Assertions.assertTrue(String.valueOf(nullPartitionInfo.get(0)[0]).startsWith("dt="));
 
-    Exception exception =
-        Assertions.assertThrows(
-            Exception.class,
-            () -> {
-              sql("ALTER TABLE  " + tableName + " ADD PARTITION (age_p1=21, 
age_p2='twenty one')");
-            });
-    Assertions.assertTrue(exception.getMessage().contains("Partition already 
exists"));
+    // Spark checks partitionExists before adding a partition and raises its 
own exception, so
+    // assert the type rather than the message.
+    Assertions.assertThrows(
+        PartitionsAlreadyExistException.class,
+        () -> sql("ALTER TABLE  " + tableName + " ADD PARTITION (age_p1=21, 
age_p2='twenty one')"));
   }
 
   @ParameterizedTest

Reply via email to