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

jerryshao 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 de25ce3bd8 [#13781] fix(client): Encode spaces as %20 in REST path 
segments (#13784)
de25ce3bd8 is described below

commit de25ce3bd82771d746253606156877988caeafb1
Author: Yuhui <[email protected]>
AuthorDate: Tue Oct 13 13:42:22 2026 +0800

    [#13781] fix(client): Encode spaces as %20 in REST path segments (#13784)
    
    ### What changes were proposed in this pull request?
    
    `RESTUtils.encodeString` now encodes a space as `%20` instead of `+`. A
    literal `+` in the input is
    still encoded as `%2B`, so the result is valid as a path segment and as
    a query or form value.
    
    ### Why are the changes needed?
    
    `URLEncoder` follows form encoding and turns a space into `+`, which the
    server keeps as a literal
    `+` in a path segment. Objects whose names contain a space could be
    listed but not loaded, for
    example a JDBC table named `with space` is shown by the Trino connector
    but cannot be queried.
    
    Fix: #13781
    
    ### Does this PR introduce _any_ user-facing change?
    
    Objects whose names contain a space can be accessed through the Java
    client.
    
    ### How was this patch tested?
    
    - Updated `TestRESTUtils.testEncodeString`.
    - Added `TestTableOperations.testLoadTableWithSpaceInName`, which loads
    a table through the server
    with a path built by `RESTUtils.encodeString`; it fails without this
    change.
---
 .../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 ++++++++++++++++++++++
 4 files changed, 75 insertions(+), 9 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 =

Reply via email to