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

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


The following commit(s) were added to refs/heads/main by this push:
     new 344adf9d12 Core: Make namespace separator configurable (#10877)
344adf9d12 is described below

commit 344adf9d123cfb45a14d8d0812c1d299753bee76
Author: Eduard Tudenhoefner <[email protected]>
AuthorDate: Tue Dec 9 10:09:13 2025 +0100

    Core: Make namespace separator configurable (#10877)
---
 .../apache/iceberg/rest/RESTCatalogProperties.java |   2 +
 .../apache/iceberg/rest/RESTSessionCatalog.java    |   9 +-
 .../java/org/apache/iceberg/rest/RESTUtil.java     | 138 ++++++++++++++++++---
 .../org/apache/iceberg/rest/ResourcePaths.java     |  39 ++++--
 .../apache/iceberg/rest/RESTCatalogAdapter.java    |  13 +-
 .../org/apache/iceberg/rest/TestRESTCatalog.java   |  12 +-
 .../java/org/apache/iceberg/rest/TestRESTUtil.java |  79 ++++++++++--
 .../org/apache/iceberg/rest/TestResourcePaths.java |  46 +++++++
 8 files changed, 297 insertions(+), 41 deletions(-)

diff --git 
a/core/src/main/java/org/apache/iceberg/rest/RESTCatalogProperties.java 
b/core/src/main/java/org/apache/iceberg/rest/RESTCatalogProperties.java
index 264c314360..e71610622b 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTCatalogProperties.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTCatalogProperties.java
@@ -35,6 +35,8 @@ public final class RESTCatalogProperties {
 
   public static final String PAGE_SIZE = "rest-page-size";
 
+  public static final String NAMESPACE_SEPARATOR = "namespace-separator";
+
   public enum SnapshotMode {
     ALL,
     REFS
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java 
b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
index fe0d13217b..814ed978c4 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
@@ -161,6 +161,7 @@ public class RESTSessionCatalog extends 
BaseViewSessionCatalog
   private CloseableGroup closeables = null;
   private Set<Endpoint> endpoints;
   private Supplier<Map<String, String>> mutationHeaders = Map::of;
+  private String namespaceSeparator = null;
 
   public RESTSessionCatalog() {
     this(
@@ -263,6 +264,12 @@ public class RESTSessionCatalog extends 
BaseViewSessionCatalog
             mergedProps,
             RESTCatalogProperties.METRICS_REPORTING_ENABLED,
             RESTCatalogProperties.METRICS_REPORTING_ENABLED_DEFAULT);
+    this.namespaceSeparator =
+        PropertyUtil.propertyAsString(
+            mergedProps,
+            RESTCatalogProperties.NAMESPACE_SEPARATOR,
+            RESTUtil.NAMESPACE_SEPARATOR_URLENCODED_UTF_8);
+
     super.initialize(name, mergedProps);
   }
 
@@ -580,7 +587,7 @@ public class RESTSessionCatalog extends 
BaseViewSessionCatalog
 
     Map<String, String> queryParams = Maps.newHashMap();
     if (!namespace.isEmpty()) {
-      queryParams.put("parent", RESTUtil.namespaceToQueryParam(namespace));
+      queryParams.put("parent", RESTUtil.namespaceToQueryParam(namespace, 
namespaceSeparator));
     }
 
     ImmutableList.Builder<Namespace> namespaces = ImmutableList.builder();
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTUtil.java 
b/core/src/main/java/org/apache/iceberg/rest/RESTUtil.java
index ec02a9dc84..f4fdf3af26 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTUtil.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTUtil.java
@@ -26,29 +26,33 @@ import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.relocated.com.google.common.base.Joiner;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.base.Splitter;
+import org.apache.iceberg.relocated.com.google.common.base.Strings;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
 import org.apache.iceberg.relocated.com.google.common.collect.Iterables;
 import org.apache.iceberg.util.PropertyUtil;
 import org.apache.iceberg.util.UUIDUtil;
 
+@SuppressWarnings("UnicodeEscape")
 public class RESTUtil {
-  private static final char NAMESPACE_SEPARATOR = '\u001f';
-  private static final String NAMESPACE_ESCAPED_SEPARATOR = "%1F";
-  private static final Joiner NAMESPACE_ESCAPED_JOINER = 
Joiner.on(NAMESPACE_ESCAPED_SEPARATOR);
-  private static final Splitter NAMESPACE_ESCAPED_SPLITTER =
-      Splitter.on(NAMESPACE_ESCAPED_SEPARATOR);
+  /** The namespace separator as Unicode character */
+  private static final char NAMESPACE_SEPARATOR_AS_UNICODE = '\u001f';
+
+  /** The namespace separator as url encoded UTF-8 character */
+  static final String NAMESPACE_SEPARATOR_URLENCODED_UTF_8 = "%1F";
 
   /**
    * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link
    *     RESTUtil#namespaceToQueryParam(Namespace)}} instead.
    */
-  @Deprecated public static final Joiner NAMESPACE_JOINER = 
Joiner.on(NAMESPACE_SEPARATOR);
+  @Deprecated
+  public static final Joiner NAMESPACE_JOINER = 
Joiner.on(NAMESPACE_SEPARATOR_AS_UNICODE);
 
   /**
    * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link
    *     RESTUtil#namespaceFromQueryParam(String)} instead.
    */
-  @Deprecated public static final Splitter NAMESPACE_SPLITTER = 
Splitter.on(NAMESPACE_SEPARATOR);
+  @Deprecated
+  public static final Splitter NAMESPACE_SPLITTER = 
Splitter.on(NAMESPACE_SEPARATOR_AS_UNICODE);
 
   public static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
 
@@ -179,8 +183,31 @@ public class RESTUtil {
    *     separated using the unicode character '\u001f'
    */
   public static String namespaceToQueryParam(Namespace namespace) {
+    return namespaceToQueryParam(namespace, 
String.valueOf(NAMESPACE_SEPARATOR_AS_UNICODE));
+  }
+
+  /**
+   * This converts the given namespace to a string and separates each part in 
a multipart namespace
+   * using the provided unicode separator. Note that this method is different 
from {@link
+   * RESTUtil#encodeNamespace(Namespace)}, which uses a UTF-8 escaped 
separator.
+   *
+   * <p>{@link #namespaceFromQueryParam(String, String)} should be used to 
convert the namespace
+   * string back to a {@link Namespace} instance.
+   *
+   * @param namespace The namespace to convert
+   * @param unicodeNamespaceSeparator The unicode namespace separator to use, 
such as '\u002e'
+   * @return The namespace converted to a string where each part in a 
multipart namespace is
+   *     separated using the given unicode separator
+   */
+  public static String namespaceToQueryParam(
+      Namespace namespace, String unicodeNamespaceSeparator) {
     Preconditions.checkArgument(null != namespace, "Invalid namespace: null");
-    return RESTUtil.NAMESPACE_JOINER.join(namespace.levels());
+    Preconditions.checkArgument(
+        !Strings.isNullOrEmpty(unicodeNamespaceSeparator), "Invalid separator: 
null or empty");
+
+    // decode in case the separator was already encoded with UTF-8
+    String separator = URLDecoder.decode(unicodeNamespaceSeparator, 
StandardCharsets.UTF_8);
+    return Joiner.on(separator).join(namespace.levels());
   }
 
   /**
@@ -191,13 +218,39 @@ public class RESTUtil {
    * a string.
    *
    * @param namespace The namespace to convert
-   * @return The namespace instance from the given namespace string, where 
each multipart separator
-   *     ('\u001f') is converted to a separate namespace level
+   * @return The namespace instance from the given namespace string, where 
each part in a multipart
+   *     namespace is converted using the unicode separator '\u001f'
    */
   public static Namespace namespaceFromQueryParam(String namespace) {
+    return namespaceFromQueryParam(namespace, 
String.valueOf(NAMESPACE_SEPARATOR_AS_UNICODE));
+  }
+
+  /**
+   * This converts a namespace where each part in a multipart namespace has 
been separated using the
+   * provided unicode separator to its original {@link Namespace} instance.
+   *
+   * <p>{@link #namespaceToQueryParam(Namespace, String)} should be used to 
convert the {@link
+   * Namespace} to a string.
+   *
+   * @param namespace The namespace to convert
+   * @param unicodeNamespaceSeparator The unicode namespace separator to use, 
such as '\u002e'
+   * @return The namespace instance from the given namespace string, where 
each part in a multipart
+   *     namespace is converted using the given unicode namespace separator
+   */
+  public static Namespace namespaceFromQueryParam(
+      String namespace, String unicodeNamespaceSeparator) {
     Preconditions.checkArgument(null != namespace, "Invalid namespace: null");
-    return Namespace.of(
-        
RESTUtil.NAMESPACE_SPLITTER.splitToStream(namespace).toArray(String[]::new));
+    Preconditions.checkArgument(
+        !Strings.isNullOrEmpty(unicodeNamespaceSeparator), "Invalid separator: 
null or empty");
+
+    // decode in case the separator was already encoded with UTF-8
+    String separator = URLDecoder.decode(unicodeNamespaceSeparator, 
StandardCharsets.UTF_8);
+    Splitter splitter =
+        namespace.contains(String.valueOf(NAMESPACE_SEPARATOR_AS_UNICODE))
+            ? Splitter.on(NAMESPACE_SEPARATOR_AS_UNICODE)
+            : Splitter.on(separator);
+
+    return 
Namespace.of(splitter.splitToStream(namespace).toArray(String[]::new));
   }
 
   /**
@@ -210,17 +263,40 @@ public class RESTUtil {
    *
    * @param ns namespace to encode
    * @return UTF-8 encoded string representing the namespace, suitable for use 
as a URL parameter
+   * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link
+   *     RESTUtil#encodeNamespace(Namespace, String)} instead.
    */
+  @Deprecated
   public static String encodeNamespace(Namespace ns) {
-    Preconditions.checkArgument(ns != null, "Invalid namespace: null");
-    String[] levels = ns.levels();
+    return encodeNamespace(ns, NAMESPACE_SEPARATOR_URLENCODED_UTF_8);
+  }
+
+  /**
+   * Returns a String representation of a namespace that is suitable for use 
in a URL / URI.
+   *
+   * <p>This function needs to be called when a namespace is used as a path 
variable (or query
+   * parameter etc.), to format the namespace per the spec.
+   *
+   * <p>{@link RESTUtil#decodeNamespace(String, String)} should be used to 
parse the namespace from
+   * a URL parameter.
+   *
+   * @param namespace namespace to encode
+   * @param separator The namespace separator to be used for encoding. The 
separator will be used
+   *     as-is and won't be UTF-8 encoded.
+   * @return UTF-8 encoded string representing the namespace, suitable for use 
as a URL parameter
+   */
+  public static String encodeNamespace(Namespace namespace, String separator) {
+    Preconditions.checkArgument(namespace != null, "Invalid namespace: null");
+    Preconditions.checkArgument(
+        !Strings.isNullOrEmpty(separator), "Invalid separator: null or empty");
+    String[] levels = namespace.levels();
     String[] encodedLevels = new String[levels.length];
 
     for (int i = 0; i < levels.length; i++) {
       encodedLevels[i] = encodeString(levels[i]);
     }
 
-    return NAMESPACE_ESCAPED_JOINER.join(encodedLevels);
+    return Joiner.on(separator).join(encodedLevels);
   }
 
   /**
@@ -231,10 +307,38 @@ public class RESTUtil {
    *
    * @param encodedNs a namespace to decode
    * @return a namespace
+   * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link
+   *     RESTUtil#decodeNamespace(String, String)} instead.
    */
+  @Deprecated
   public static Namespace decodeNamespace(String encodedNs) {
-    Preconditions.checkArgument(encodedNs != null, "Invalid namespace: null");
-    String[] levels = 
Iterables.toArray(NAMESPACE_ESCAPED_SPLITTER.split(encodedNs), String.class);
+    return decodeNamespace(encodedNs, NAMESPACE_SEPARATOR_URLENCODED_UTF_8);
+  }
+
+  /**
+   * Takes in a string representation of a namespace as used for a URL 
parameter and returns the
+   * corresponding namespace.
+   *
+   * <p>See also {@link #encodeNamespace} for generating correctly formatted 
URLs.
+   *
+   * @param encodedNamespace a namespace to decode
+   * @param separator The namespace separator to be used as-is for decoding. 
This should be the same
+   *     separator that was used when calling {@link 
RESTUtil#encodeNamespace(Namespace, String)}
+   * @return a namespace
+   */
+  public static Namespace decodeNamespace(String encodedNamespace, String 
separator) {
+    Preconditions.checkArgument(encodedNamespace != null, "Invalid namespace: 
null");
+    Preconditions.checkArgument(
+        !Strings.isNullOrEmpty(separator), "Invalid separator: null or empty");
+
+    // use legacy splitter for backwards compatibility in case an old clients 
encoded the namespace
+    // with %1F
+    Splitter splitter =
+        Splitter.on(
+            encodedNamespace.contains(NAMESPACE_SEPARATOR_URLENCODED_UTF_8)
+                ? NAMESPACE_SEPARATOR_URLENCODED_UTF_8
+                : separator);
+    String[] levels = Iterables.toArray(splitter.split(encodedNamespace), 
String.class);
 
     // Decode levels in place
     for (int i = 0; i < levels.length; i++) {
diff --git a/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java 
b/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java
index 275a87e968..d85b00d02e 100644
--- a/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java
+++ b/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java
@@ -22,6 +22,7 @@ import java.util.Map;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.relocated.com.google.common.base.Joiner;
+import org.apache.iceberg.util.PropertyUtil;
 
 public class ResourcePaths {
   private static final Joiner SLASH = Joiner.on("/").skipNulls();
@@ -50,7 +51,12 @@ public class ResourcePaths {
   public static final String V1_VIEW_RENAME = "/v1/{prefix}/views/rename";
 
   public static ResourcePaths forCatalogProperties(Map<String, String> 
properties) {
-    return new ResourcePaths(properties.get(PREFIX));
+    return new ResourcePaths(
+        properties.get(PREFIX),
+        PropertyUtil.propertyAsString(
+            properties,
+            RESTCatalogProperties.NAMESPACE_SEPARATOR,
+            RESTUtil.NAMESPACE_SEPARATOR_URLENCODED_UTF_8));
   }
 
   public static String config() {
@@ -62,9 +68,20 @@ public class ResourcePaths {
   }
 
   private final String prefix;
+  private final String namespaceSeparator;
 
+  /**
+   * @deprecated since 1.11.0, will be made private in 1.12.0; use {@link
+   *     ResourcePaths#forCatalogProperties(Map)} instead.
+   */
+  @Deprecated
   public ResourcePaths(String prefix) {
+    this(prefix, RESTUtil.NAMESPACE_SEPARATOR_URLENCODED_UTF_8);
+  }
+
+  private ResourcePaths(String prefix, String namespaceSeparator) {
     this.prefix = prefix;
+    this.namespaceSeparator = namespaceSeparator;
   }
 
   public String namespaces() {
@@ -72,15 +89,15 @@ public class ResourcePaths {
   }
 
   public String namespace(Namespace ns) {
-    return SLASH.join("v1", prefix, "namespaces", 
RESTUtil.encodeNamespace(ns));
+    return SLASH.join("v1", prefix, "namespaces", pathEncode(ns));
   }
 
   public String namespaceProperties(Namespace ns) {
-    return SLASH.join("v1", prefix, "namespaces", 
RESTUtil.encodeNamespace(ns), "properties");
+    return SLASH.join("v1", prefix, "namespaces", pathEncode(ns), 
"properties");
   }
 
   public String tables(Namespace ns) {
-    return SLASH.join("v1", prefix, "namespaces", 
RESTUtil.encodeNamespace(ns), "tables");
+    return SLASH.join("v1", prefix, "namespaces", pathEncode(ns), "tables");
   }
 
   public String table(TableIdentifier ident) {
@@ -88,13 +105,13 @@ public class ResourcePaths {
         "v1",
         prefix,
         "namespaces",
-        RESTUtil.encodeNamespace(ident.namespace()),
+        pathEncode(ident.namespace()),
         "tables",
         RESTUtil.encodeString(ident.name()));
   }
 
   public String register(Namespace ns) {
-    return SLASH.join("v1", prefix, "namespaces", 
RESTUtil.encodeNamespace(ns), "register");
+    return SLASH.join("v1", prefix, "namespaces", pathEncode(ns), "register");
   }
 
   public String rename() {
@@ -106,7 +123,7 @@ public class ResourcePaths {
         "v1",
         prefix,
         "namespaces",
-        RESTUtil.encodeNamespace(identifier.namespace()),
+        pathEncode(identifier.namespace()),
         "tables",
         RESTUtil.encodeString(identifier.name()),
         "metrics");
@@ -117,7 +134,7 @@ public class ResourcePaths {
   }
 
   public String views(Namespace ns) {
-    return SLASH.join("v1", prefix, "namespaces", 
RESTUtil.encodeNamespace(ns), "views");
+    return SLASH.join("v1", prefix, "namespaces", pathEncode(ns), "views");
   }
 
   public String view(TableIdentifier ident) {
@@ -125,7 +142,7 @@ public class ResourcePaths {
         "v1",
         prefix,
         "namespaces",
-        RESTUtil.encodeNamespace(ident.namespace()),
+        pathEncode(ident.namespace()),
         "views",
         RESTUtil.encodeString(ident.name()));
   }
@@ -133,4 +150,8 @@ public class ResourcePaths {
   public String renameView() {
     return SLASH.join("v1", prefix, "views", "rename");
   }
+
+  private String pathEncode(Namespace ns) {
+    return RESTUtil.encodeNamespace(ns, namespaceSeparator);
+  }
 }
diff --git a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java 
b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
index ff6daa61e3..586da0d107 100644
--- a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
+++ b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
@@ -81,6 +81,12 @@ import org.apache.iceberg.util.PropertyUtil;
 
 /** Adaptor class to translate REST requests into {@link Catalog} API calls. */
 public class RESTCatalogAdapter extends BaseHTTPClient {
+
+  @SuppressWarnings("AvoidEscapedUnicodeCharacters")
+  private static final String NAMESPACE_SEPARATOR_UNICODE = "\u002e";
+
+  private static final String NAMESPACE_SEPARATOR_URLENCODED_UTF_8 = "%2E";
+
   private static final Map<Class<? extends Exception>, Integer> 
EXCEPTION_ERROR_CODES =
       ImmutableMap.<Class<? extends Exception>, Integer>builder()
           .put(IllegalArgumentException.class, 400)
@@ -168,13 +174,15 @@ public class RESTCatalogAdapter extends BaseHTTPClient {
                     Arrays.stream(Route.values())
                         .map(r -> Endpoint.create(r.method().name(), 
r.resourcePath()))
                         .collect(Collectors.toList()))
+                .withOverride(
+                    RESTCatalogProperties.NAMESPACE_SEPARATOR, 
NAMESPACE_SEPARATOR_URLENCODED_UTF_8)
                 .build());
 
       case LIST_NAMESPACES:
         if (asNamespaceCatalog != null) {
           Namespace ns;
           if (vars.containsKey("parent")) {
-            ns = RESTUtil.namespaceFromQueryParam(vars.get("parent"));
+            ns = RESTUtil.namespaceFromQueryParam(vars.get("parent"), 
NAMESPACE_SEPARATOR_UNICODE);
           } else {
             ns = Namespace.empty();
           }
@@ -645,7 +653,8 @@ public class RESTCatalogAdapter extends BaseHTTPClient {
   }
 
   private static Namespace namespaceFromPathVars(Map<String, String> pathVars) 
{
-    return RESTUtil.decodeNamespace(pathVars.get("namespace"));
+    return RESTUtil.decodeNamespace(
+        pathVars.get("namespace"), NAMESPACE_SEPARATOR_URLENCODED_UTF_8);
   }
 
   private static TableIdentifier tableIdentFromPathVars(Map<String, String> 
pathVars) {
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java 
b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
index 385893ea71..753d8cb247 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
@@ -2943,7 +2943,7 @@ public class TestRESTCatalog extends 
CatalogTests<RESTCatalog> {
     assertThatThrownBy(() -> 
catalog().loadTable(TABLE)).isInstanceOf(NullPointerException.class);
 
     TableIdentifier metadataTableIdentifier =
-        TableIdentifier.of(TABLE.namespace().toString(), TABLE.name(), 
"partitions");
+        TableIdentifier.of(NS.toString(), TABLE.name(), "partitions");
 
     // TODO: This won't throw when client side of freshness-aware loading is 
implemented
     assertThatThrownBy(() -> catalog().loadTable(metadataTableIdentifier))
@@ -2956,12 +2956,14 @@ public class TestRESTCatalog extends 
CatalogTests<RESTCatalog> {
             any(),
             any());
 
+    // RESTCatalogAdapter uses %2E as a namespace separator, and we're 
verifying here which
+    // server-side path was called
+    ResourcePaths paths =
+        ResourcePaths.forCatalogProperties(
+            ImmutableMap.of(RESTCatalogProperties.NAMESPACE_SEPARATOR, "%2E"));
     verify(adapterForRESTServer)
         .execute(
-            reqMatcher(HTTPMethod.GET, 
RESOURCE_PATHS.table(metadataTableIdentifier)),
-            any(),
-            any(),
-            any());
+            reqMatcher(HTTPMethod.GET, paths.table(metadataTableIdentifier)), 
any(), any(), any());
   }
 
   @Test
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTUtil.java 
b/core/src/test/java/org/apache/iceberg/rest/TestRESTUtil.java
index fe022b3518..1ed732ebc9 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestRESTUtil.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTUtil.java
@@ -26,6 +26,8 @@ import java.util.Map;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 public class TestRESTUtil {
 
@@ -68,18 +70,24 @@ public class TestRESTUtil {
     }
   }
 
-  @Test
-  public void testRoundTripUrlEncodeDecodeNamespace() {
+  @ParameterizedTest
+  @ValueSource(strings = {"%1F", "%2D", "%2E", "#", "_"})
+  public void testRoundTripUrlEncodeDecodeNamespace(String namespaceSeparator) 
{
     // Namespace levels and their expected url encoded form
     Object[][] testCases =
         new Object[][] {
           new Object[] {new String[] {"dogs"}, "dogs"},
           new Object[] {new String[] {"dogs.named.hank"}, "dogs.named.hank"},
           new Object[] {new String[] {"dogs/named/hank"}, 
"dogs%2Fnamed%2Fhank"},
-          new Object[] {new String[] {"dogs", "named", "hank"}, 
"dogs%1Fnamed%1Fhank"},
+          new Object[] {
+            new String[] {"dogs", "named", "hank"},
+            String.format("dogs%snamed%shank", namespaceSeparator, 
namespaceSeparator)
+          },
           new Object[] {
             new String[] {"dogs.and.cats", "named", "hank.or.james-westfall"},
-            "dogs.and.cats%1Fnamed%1Fhank.or.james-westfall"
+            String.format(
+                "dogs.and.cats%snamed%shank.or.james-westfall",
+                namespaceSeparator, namespaceSeparator),
           }
         };
 
@@ -90,14 +98,35 @@ public class TestRESTUtil {
       Namespace namespace = Namespace.of(levels);
 
       // To be placed into a URL path as query parameter or path parameter
-      assertThat(RESTUtil.encodeNamespace(namespace)).isEqualTo(encodedNs);
+      assertThat(RESTUtil.encodeNamespace(namespace, 
namespaceSeparator)).isEqualTo(encodedNs);
 
       // Decoded (after pulling as String) from URL
-      Namespace asNamespace = RESTUtil.decodeNamespace(encodedNs);
-      assertThat(asNamespace).isEqualTo(namespace);
+      assertThat(RESTUtil.decodeNamespace(encodedNs, 
namespaceSeparator)).isEqualTo(namespace);
     }
   }
 
+  @Test
+  public void encodeAsOldClientAndDecodeAsNewServer() {
+    Namespace namespace = Namespace.of("first", "second", "third");
+    // old client would call encodeNamespace without specifying a separator
+    String encodedNamespace = RESTUtil.encodeNamespace(namespace);
+    
assertThat(encodedNamespace).contains(RESTUtil.NAMESPACE_SEPARATOR_URLENCODED_UTF_8);
+
+    // old client would also call namespaceToQueryParam without specifying a 
separator
+    String namespaceAsUnicode = RESTUtil.namespaceToQueryParam(namespace);
+    assertThat(namespaceAsUnicode).contains("\u001f");
+
+    // newer server would try and decode the namespace with the separator it 
communicates to clients
+    String separator = "%2E";
+    Namespace decodedNamespace = RESTUtil.decodeNamespace(encodedNamespace, 
separator);
+    assertThat(decodedNamespace).isEqualTo(namespace);
+
+    // newer server would try and split the namespace with the separator it 
communicates to clients
+    // but should detect whether the namespace contains the legacy separator
+    assertThat(RESTUtil.namespaceFromQueryParam(namespaceAsUnicode, separator))
+        .isEqualTo(namespace);
+  }
+
   @Test
   public void testNamespaceUrlEncodeDecodeDoesNotAllowNull() {
     assertThatExceptionOfType(IllegalArgumentException.class)
@@ -212,4 +241,40 @@ public class TestRESTUtil {
     assertThat(RESTUtil.namespaceFromQueryParam("one%1Ftwo\u001fns"))
         .isEqualTo(Namespace.of("one%1Ftwo", "ns"));
   }
+
+  @Test
+  public void nullOrEmptyNamespaceSeparator() {
+    String errorMsg = "Invalid separator: null or empty";
+    assertThatThrownBy(() -> RESTUtil.encodeNamespace(Namespace.empty(), null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage(errorMsg);
+
+    assertThatThrownBy(() -> RESTUtil.encodeNamespace(Namespace.empty(), ""))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage(errorMsg);
+
+    assertThatThrownBy(() -> RESTUtil.decodeNamespace("namespace", null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage(errorMsg);
+
+    assertThatThrownBy(() -> RESTUtil.decodeNamespace("namespace", ""))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage(errorMsg);
+
+    assertThatThrownBy(() -> RESTUtil.namespaceToQueryParam(Namespace.empty(), 
null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage(errorMsg);
+
+    assertThatThrownBy(() -> RESTUtil.namespaceToQueryParam(Namespace.empty(), 
""))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage(errorMsg);
+
+    assertThatThrownBy(() -> RESTUtil.namespaceFromQueryParam("namespace", 
null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage(errorMsg);
+
+    assertThatThrownBy(() -> RESTUtil.namespaceFromQueryParam("namespace", 
null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage(errorMsg);
+  }
 }
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java 
b/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java
index c2d3fe5603..ebcced1b37 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java
@@ -24,6 +24,8 @@ import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 public class TestResourcePaths {
   private final String prefix = "ws/catalog";
@@ -64,6 +66,50 @@ public class TestResourcePaths {
     assertThat(withoutPrefix.namespace(ns)).isEqualTo("v1/namespaces/n%1Fs");
   }
 
+  @ParameterizedTest
+  @ValueSource(strings = {"%1F", "%2D", "%2E"})
+  public void testNamespaceWithMultipartNamespace(String namespaceSeparator) {
+    Namespace ns = Namespace.of("n", "s");
+    String namespace = String.format("n%ss", namespaceSeparator);
+    assertThat(
+            ResourcePaths.forCatalogProperties(
+                    ImmutableMap.of(
+                        "prefix",
+                        prefix,
+                        RESTCatalogProperties.NAMESPACE_SEPARATOR,
+                        namespaceSeparator))
+                .namespace(ns))
+        .isEqualTo("v1/ws/catalog/namespaces/" + namespace);
+
+    assertThat(
+            ResourcePaths.forCatalogProperties(
+                    ImmutableMap.of(RESTCatalogProperties.NAMESPACE_SEPARATOR, 
namespaceSeparator))
+                .namespace(ns))
+        .isEqualTo("v1/namespaces/" + namespace);
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"%1F", "%2D", "%2E"})
+  public void testNamespaceWithDot(String namespaceSeparator) {
+    Namespace ns = Namespace.of("n.s", "a.b");
+    String namespace = String.format("n.s%sa.b", namespaceSeparator);
+    assertThat(
+            ResourcePaths.forCatalogProperties(
+                    ImmutableMap.of(
+                        "prefix",
+                        prefix,
+                        RESTCatalogProperties.NAMESPACE_SEPARATOR,
+                        namespaceSeparator))
+                .namespace(ns))
+        .isEqualTo("v1/ws/catalog/namespaces/" + namespace);
+
+    assertThat(
+            ResourcePaths.forCatalogProperties(
+                    ImmutableMap.of(RESTCatalogProperties.NAMESPACE_SEPARATOR, 
namespaceSeparator))
+                .namespace(ns))
+        .isEqualTo("v1/namespaces/" + namespace);
+  }
+
   @Test
   public void testNamespaceProperties() {
     Namespace ns = Namespace.of("ns");

Reply via email to