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 f4fdf3af26e7..dcae7f943f9c 100644 --- a/core/src/main/java/org/apache/iceberg/rest/RESTUtil.java +++ b/core/src/main/java/org/apache/iceberg/rest/RESTUtil.java @@ -22,6 +22,7 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Map; +import org.apache.hc.core5.net.PercentCodec; 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; @@ -144,12 +145,17 @@ public static Map decodeFormData(String formString) { } /** - * Encodes a string using URL encoding + * Encodes a string using application/x-www-form-urlencoded encoding, where spaces are encoded as + * {@code +}. + * + *

This method is suitable for encoding form data (e.g. OAuth2 token requests) but not + * for URL path segments, where {@code +} is a literal character. Use {@link + * #encodePathSegment(String)} for path segments. * *

{@link #decodeString(String)} should be used to decode. * * @param toEncode string to encode - * @return UTF-8 encoded string, suitable for use as a URL parameter + * @return form-encoded string, suitable for use in application/x-www-form-urlencoded content */ public static String encodeString(String toEncode) { Preconditions.checkArgument(toEncode != null, "Invalid string to encode: null"); @@ -157,9 +163,13 @@ public static String encodeString(String toEncode) { } /** - * Decodes a URL-encoded string. + * Decodes a string that was encoded using application/x-www-form-urlencoded encoding, where + * {@code +} is decoded as a space. + * + *

This method is suitable for decoding form data but not for URL path segments. Use + * {@link #decodePathSegment(String)} for path segments. * - *

See also {@link #encodeString(String)} for URL encoding. + *

See also {@link #encodeString(String)} for form encoding. * * @param encoded a string to decode * @return a decoded string @@ -169,6 +179,39 @@ public static String decodeString(String encoded) { return URLDecoder.decode(encoded, StandardCharsets.UTF_8); } + /** + * Encodes a string for use as a URL path segment per RFC 3986. Spaces are encoded as {@code %20} + * (not {@code +}), and other non-unreserved characters are percent-encoded. + * + *

{@link #decodePathSegment(String)} should be used to decode. + * + * @param segment string to encode + * @return percent-encoded string suitable for use in URL path segments + */ + public static String encodePathSegment(String segment) { + Preconditions.checkArgument(segment != null, "Invalid string to encode: null"); + return PercentCodec.RFC3986.encode(segment); + } + + /** + * Decodes a URL path segment per RFC 3986. Unlike {@link #decodeString(String)}, this method does + * not treat {@code +} as a space — it is left as a literal {@code +} character. + * + *

Note: this method is introduced in this release but is not yet wired into server-side + * decoding paths (e.g. {@link #decodeNamespace(String, String)}). It will be adopted there in a + * future release, once updated clients that use {@link #encodePathSegment(String)} have been + * widely deployed. + * + *

See also {@link #encodePathSegment(String)} for encoding. + * + * @param encoded a percent-encoded path segment + * @return a decoded string + */ + public static String decodePathSegment(String encoded) { + Preconditions.checkArgument(encoded != null, "Invalid string to decode: null"); + return PercentCodec.RFC3986.decode(encoded); + } + /** * This converts the given namespace to a string and separates each part in a multipart namespace * using the unicode character '\u001f'. Note that this method is different from {@link @@ -254,12 +297,13 @@ public static Namespace namespaceFromQueryParam( } /** - * Returns a String representation of a namespace that is suitable for use in a URL / URI. + * Returns a String representation of a namespace that is suitable for use with + * application/x-www-form-urlencoded encoding. * - *

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. + *

This function needs to be called when a namespace is used in a POST request body, to format + * the namespace per the spec. * - *

{@link #decodeNamespace} should be used to parse the namespace from a URL parameter. + *

{@link #decodeNamespace} should be used to parse the namespace from a request body. * * @param ns namespace to encode * @return UTF-8 encoded string representing the namespace, suitable for use as a URL parameter @@ -272,13 +316,14 @@ public static String encodeNamespace(Namespace ns) { } /** - * Returns a String representation of a namespace that is suitable for use in a URL / URI. + * Returns a String representation of a namespace that is suitable for use with + * application/x-www-form-urlencoded encoding. * - *

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. + *

This function needs to be called when a namespace is used in a POST request body, to format + * the namespace per the spec. * *

{@link RESTUtil#decodeNamespace(String, String)} should be used to parse the namespace from - * a URL parameter. + * a request body. * * @param namespace namespace to encode * @param separator The namespace separator to be used for encoding. The separator will be used @@ -300,10 +345,10 @@ public static String encodeNamespace(Namespace namespace, String separator) { } /** - * Takes in a string representation of a namespace as used for a URL parameter and returns the - * corresponding namespace. + * Takes in a string representation of a namespace encoded with application/x-www-form-urlencoded + * encoding, and returns the corresponding namespace. * - *

See also {@link #encodeNamespace} for generating correctly formatted URLs. + *

See also {@link #encodeNamespace} for generating correctly formatted POST requests. * * @param encodedNs a namespace to decode * @return a namespace @@ -316,10 +361,10 @@ public static Namespace decodeNamespace(String encodedNs) { } /** - * Takes in a string representation of a namespace as used for a URL parameter and returns the - * corresponding namespace. + * Takes in a string representation of a namespace encoded with application/x-www-form-urlencoded + * encoding, and returns the corresponding namespace. * - *

See also {@link #encodeNamespace} for generating correctly formatted URLs. + *

See also {@link #encodeNamespace} for generating correctly formatted POST requests. * * @param encodedNamespace a namespace to decode * @param separator The namespace separator to be used as-is for decoding. This should be the same @@ -348,6 +393,67 @@ public static Namespace decodeNamespace(String encodedNamespace, String separato return Namespace.of(levels); } + /** + * Returns a String representation of a namespace that is suitable for use in a URL path segment + * per RFC 3986. Spaces are encoded as {@code %20} (not {@code +}). + * + *

This method should be used instead of {@link #encodeNamespace(Namespace, String)} when the + * result is placed into a URL path. + * + *

{@link #decodeNamespaceAsPathSegment(String, String)} should be used to decode the result. + * + * @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 encoded. + * @return percent-encoded string representing the namespace, suitable for use in URL path + * segments + */ + public static String encodeNamespaceAsPathSegment(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] = encodePathSegment(levels[i]); + } + + return Joiner.on(separator).join(encodedLevels); + } + + /** + * Decodes a URL path segment per RFC 3986 into a namespace. Unlike {@link + * #decodeNamespace(String, String)}, this method does not treat {@code +} as a space. + * + *

{@link #encodeNamespaceAsPathSegment(Namespace, String)} should be used for encoding path + * segments. + * + * @param encodedNamespace a percent-encoded namespace path segment + * @param separator The namespace separator used during encoding + * @return a namespace + */ + public static Namespace decodeNamespaceAsPathSegment(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 client 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); + + for (int i = 0; i < levels.length; i++) { + levels[i] = decodePathSegment(levels[i]); + } + + return Namespace.of(levels); + } + /** * Returns the catalog URI suffixed by the relative endpoint path. If the endpoint path is an * absolute path, then the absolute endpoint path is returned without using the catalog URI. 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 0fc55c1a44d8..c76f9f5b3cc3 100644 --- a/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java +++ b/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java @@ -108,7 +108,7 @@ public String table(TableIdentifier ident) { "namespaces", pathEncode(ident.namespace()), "tables", - RESTUtil.encodeString(ident.name())); + RESTUtil.encodePathSegment(ident.name())); } public String register(Namespace ns) { @@ -126,7 +126,7 @@ public String metrics(TableIdentifier identifier) { "namespaces", pathEncode(identifier.namespace()), "tables", - RESTUtil.encodeString(identifier.name()), + RESTUtil.encodePathSegment(identifier.name()), "metrics"); } @@ -145,7 +145,7 @@ public String view(TableIdentifier ident) { "namespaces", pathEncode(ident.namespace()), "views", - RESTUtil.encodeString(ident.name())); + RESTUtil.encodePathSegment(ident.name())); } public String renameView() { @@ -163,7 +163,7 @@ public String planTableScan(TableIdentifier ident) { "namespaces", pathEncode(ident.namespace()), "tables", - RESTUtil.encodeString(ident.name()), + RESTUtil.encodePathSegment(ident.name()), "plan"); } @@ -174,9 +174,9 @@ public String plan(TableIdentifier ident, String planId) { "namespaces", pathEncode(ident.namespace()), "tables", - RESTUtil.encodeString(ident.name()), + RESTUtil.encodePathSegment(ident.name()), "plan", - RESTUtil.encodeString(planId)); + RESTUtil.encodePathSegment(planId)); } public String fetchScanTasks(TableIdentifier ident) { @@ -186,11 +186,11 @@ public String fetchScanTasks(TableIdentifier ident) { "namespaces", pathEncode(ident.namespace()), "tables", - RESTUtil.encodeString(ident.name()), + RESTUtil.encodePathSegment(ident.name()), "tasks"); } private String pathEncode(Namespace ns) { - return RESTUtil.encodeNamespace(ns, namespaceSeparator); + return RESTUtil.encodeNamespaceAsPathSegment(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 8ba5daef3f9b..7c649c2f6df9 100644 --- a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java +++ b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java @@ -723,22 +723,22 @@ public static void configureResponseFromException( } private static Namespace namespaceFromPathVars(Map pathVars) { - return RESTUtil.decodeNamespace( + return RESTUtil.decodeNamespaceAsPathSegment( pathVars.get("namespace"), NAMESPACE_SEPARATOR_URLENCODED_UTF_8); } private static TableIdentifier tableIdentFromPathVars(Map pathVars) { return TableIdentifier.of( - namespaceFromPathVars(pathVars), RESTUtil.decodeString(pathVars.get("table"))); + namespaceFromPathVars(pathVars), RESTUtil.decodePathSegment(pathVars.get("table"))); } private static TableIdentifier viewIdentFromPathVars(Map pathVars) { return TableIdentifier.of( - namespaceFromPathVars(pathVars), RESTUtil.decodeString(pathVars.get("view"))); + namespaceFromPathVars(pathVars), RESTUtil.decodePathSegment(pathVars.get("view"))); } private static String planIDFromPathVars(Map pathVars) { - return RESTUtil.decodeString(pathVars.get("plan-id")); + return RESTUtil.decodePathSegment(pathVars.get("plan-id")); } private static SnapshotMode snapshotModeFromQueryParams(Map queryParams) { 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 c1630451a33e..e64ccd44d795 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java @@ -3954,6 +3954,28 @@ public Table registerTable( } } + @Test + public void testLoadTableWithSpecialChars() { + Namespace ns1 = Namespace.of("ns 1 ?=-+"); + Namespace ns2 = Namespace.of("ns 1 ?=-+", "ns 2 ?=-+"); + + if (requiresNamespaceCreate()) { + restCatalog.createNamespace(ns1); + restCatalog.createNamespace(ns2); + } + + TableIdentifier t1 = TableIdentifier.of(ns2, "table 1 ?=-+"); + + restCatalog.buildTable(t1, SCHEMA).create(); + assertThat(restCatalog.tableExists(t1)).as("Table should exist").isTrue(); + + Table table = restCatalog.loadTable(t1); + + String metadataFileLocation = + ((HasTableOperations) table).operations().current().metadataFileLocation(); + assertThat(metadataFileLocation).contains("ns 1 ?=-+/ns 2 ?=-+/table 1 ?=-+"); + } + private RESTCatalog catalog(RESTCatalogAdapter adapter) { RESTCatalog catalog = new RESTCatalog(SessionCatalog.SessionContext.createEmpty(), (config) -> adapter); 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 1ed732ebc91a..4bfda91556bf 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestRESTUtil.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTUtil.java @@ -23,10 +23,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.Map; +import java.util.stream.Stream; 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.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; public class TestRESTUtil { @@ -105,6 +108,52 @@ public void testRoundTripUrlEncodeDecodeNamespace(String namespaceSeparator) { } } + @ParameterizedTest + @ValueSource(strings = {"%1F", "%2D", "%2E", "#", "_"}) + public void testRoundTripEncodeDecodeNamespaceAsPathSegment(String namespaceSeparator) { + 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%20named%20hank"}, + new Object[] {new String[] {"dogs+named+hank"}, "dogs%2Bnamed%2Bhank"}, + 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"}, + String.format( + "dogs.and.cats%snamed%shank.or.james-westfall", + namespaceSeparator, namespaceSeparator), + } + }; + + for (Object[] namespaceWithEncoding : testCases) { + String[] levels = (String[]) namespaceWithEncoding[0]; + String encodedNs = (String) namespaceWithEncoding[1]; + + Namespace namespace = Namespace.of(levels); + + assertThat(RESTUtil.encodeNamespaceAsPathSegment(namespace, namespaceSeparator)) + .isEqualTo(encodedNs); + + assertThat(RESTUtil.decodeNamespaceAsPathSegment(encodedNs, namespaceSeparator)) + .isEqualTo(namespace); + } + } + + @Test + public void testDecodeNamespacePathSegmentPreservesPlusSign() { + String separator = "%1F"; + Namespace expected = Namespace.of("a+b", "c+d"); + // Both encoded forms are valid + assertThat(RESTUtil.decodeNamespaceAsPathSegment("a+b%1Fc+d", separator)).isEqualTo(expected); + assertThat(RESTUtil.decodeNamespaceAsPathSegment("a%2Bb%1Fc%2Bd", separator)) + .isEqualTo(expected); + } + @Test public void encodeAsOldClientAndDecodeAsNewServer() { Namespace namespace = Namespace.of("first", "second", "third"); @@ -142,12 +191,50 @@ public void testNamespaceUrlEncodeDecodeDoesNotAllowNull() { @SuppressWarnings("checkstyle:AvoidEscapedUnicodeCharacters") public void testOAuth2URLEncoding() { // from OAuth2, RFC 6749 Appendix B. + // encodeString uses form encoding: space -> + String utf8 = "\u0020\u0025\u0026\u002B\u00A3\u20AC"; String expected = "+%25%26%2B%C2%A3%E2%82%AC"; assertThat(RESTUtil.encodeString(utf8)).isEqualTo(expected); } + @SuppressWarnings("checkstyle:AvoidEscapedUnicodeCharacters") + static Stream pathSegmentEncodingCases() { + return Stream.of( + Arguments.of("simple", "simple"), + Arguments.of("a b", "a%20b"), + Arguments.of("a+b", "a%2Bb"), + Arguments.of("a/b", "a%2Fb"), + Arguments.of("a+b c/d", "a%2Bb%20c%2Fd"), + Arguments.of("caf\u00e9", "caf%C3%A9"), + Arguments.of("\u0020\u0025\u0026\u002B\u00A3\u20AC", "%20%25%26%2B%C2%A3%E2%82%AC")); + } + + @ParameterizedTest + @MethodSource("pathSegmentEncodingCases") + public void testRoundTripEncodeDecodePathSegment(String input, String expectedEncoded) { + String actual = RESTUtil.encodePathSegment(input); + assertThat(actual).isEqualTo(expectedEncoded); + assertThat(RESTUtil.decodePathSegment(actual)).isEqualTo(input); + } + + @Test + public void testDecodePathSegmentPreservesPlusSign() { + // Both encoded forms are valid + assertThat(RESTUtil.decodePathSegment("a%2Bb%2Bc%2Bd")).isEqualTo("a+b+c+d"); + assertThat(RESTUtil.decodePathSegment("a+b+c+d")).isEqualTo("a+b+c+d"); + } + + @Test + public void testPathSegmentEncodeDecodeNull() { + assertThatThrownBy(() -> RESTUtil.encodePathSegment(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid string to encode: null"); + assertThatThrownBy(() -> RESTUtil.decodePathSegment(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid string to decode: null"); + } + @Test @SuppressWarnings("checkstyle:AvoidEscapedUnicodeCharacters") public void testOAuth2FormDataEncoding() { @@ -261,6 +348,22 @@ public void nullOrEmptyNamespaceSeparator() { .isInstanceOf(IllegalArgumentException.class) .hasMessage(errorMsg); + assertThatThrownBy(() -> RESTUtil.encodeNamespaceAsPathSegment(Namespace.empty(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(errorMsg); + + assertThatThrownBy(() -> RESTUtil.encodeNamespaceAsPathSegment(Namespace.empty(), "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(errorMsg); + + assertThatThrownBy(() -> RESTUtil.decodeNamespaceAsPathSegment("namespace", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(errorMsg); + + assertThatThrownBy(() -> RESTUtil.decodeNamespaceAsPathSegment("namespace", "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(errorMsg); + assertThatThrownBy(() -> RESTUtil.namespaceToQueryParam(Namespace.empty(), 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 f40b1302f90e..01c13ddf1a83 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java @@ -151,6 +151,23 @@ public void nestedNamespaceWithNewSeparator() { assertThat(RESTUtil.decodeNamespace(newEncodedSeparator, newSeparator)).isEqualTo(namespace); } + @Test + public void nestedNamespaceAsPathSegmentWithCustomSeparator() { + Namespace namespace = Namespace.of("first second", "third"); + String separator = RESTCatalogAdapter.NAMESPACE_SEPARATOR_URLENCODED_UTF_8; + + ResourcePaths pathsWithCustomSeparator = + ResourcePaths.forCatalogProperties( + ImmutableMap.of(RESTCatalogProperties.NAMESPACE_SEPARATOR, separator)); + + String actual = pathsWithCustomSeparator.namespace(namespace); + assertThat(actual) + .contains(RESTUtil.encodeNamespaceAsPathSegment(namespace, separator)) + .contains(separator) + .contains("%20") + .doesNotContain("+"); + } + @Test public void testNamespaceProperties() { Namespace ns = Namespace.of("ns"); @@ -217,6 +234,62 @@ public void testTableWithMultipartNamespace() { assertThat(withoutPrefix.table(ident)).isEqualTo("v1/namespaces/n%1Fs/tables/table"); } + @Test + public void testNamespaceWithSpace() { + Namespace ns = Namespace.of("n s"); + assertThat(withPrefix.namespace(ns)).isEqualTo("v1/ws/catalog/namespaces/n%20s"); + assertThat(withoutPrefix.namespace(ns)).isEqualTo("v1/namespaces/n%20s"); + } + + @Test + public void testMultipartNamespaceWithSpace() { + Namespace ns = Namespace.of("n s", "a b"); + assertThat(withPrefix.namespace(ns)).isEqualTo("v1/ws/catalog/namespaces/n%20s%1Fa%20b"); + assertThat(withoutPrefix.namespace(ns)).isEqualTo("v1/namespaces/n%20s%1Fa%20b"); + } + + @Test + public void testNamespaceWithPlusSign() { + Namespace ns = Namespace.of("n+s"); + assertThat(withPrefix.namespace(ns)).isEqualTo("v1/ws/catalog/namespaces/n%2Bs"); + assertThat(withoutPrefix.namespace(ns)).isEqualTo("v1/namespaces/n%2Bs"); + } + + @Test + public void testMultipartNamespaceWithPlusSign() { + Namespace ns = Namespace.of("n+s", "a+b"); + assertThat(withPrefix.namespace(ns)).isEqualTo("v1/ws/catalog/namespaces/n%2Bs%1Fa%2Bb"); + assertThat(withoutPrefix.namespace(ns)).isEqualTo("v1/namespaces/n%2Bs%1Fa%2Bb"); + } + + @Test + public void testTableWithSpace() { + TableIdentifier ident = TableIdentifier.of("ns", "my table"); + assertThat(withPrefix.table(ident)).isEqualTo("v1/ws/catalog/namespaces/ns/tables/my%20table"); + assertThat(withoutPrefix.table(ident)).isEqualTo("v1/namespaces/ns/tables/my%20table"); + } + + @Test + public void testTableWithPlusSign() { + TableIdentifier ident = TableIdentifier.of("ns", "a+b"); + assertThat(withPrefix.table(ident)).isEqualTo("v1/ws/catalog/namespaces/ns/tables/a%2Bb"); + assertThat(withoutPrefix.table(ident)).isEqualTo("v1/namespaces/ns/tables/a%2Bb"); + } + + @Test + public void testViewWithSpace() { + TableIdentifier ident = TableIdentifier.of("ns", "my view"); + assertThat(withPrefix.view(ident)).isEqualTo("v1/ws/catalog/namespaces/ns/views/my%20view"); + assertThat(withoutPrefix.view(ident)).isEqualTo("v1/namespaces/ns/views/my%20view"); + } + + @Test + public void testViewWithPlusSign() { + TableIdentifier ident = TableIdentifier.of("ns", "a+b"); + assertThat(withPrefix.view(ident)).isEqualTo("v1/ws/catalog/namespaces/ns/views/a%2Bb"); + assertThat(withoutPrefix.view(ident)).isEqualTo("v1/namespaces/ns/views/a%2Bb"); + } + @Test public void testRegister() { Namespace ns = Namespace.of("ns"); @@ -320,8 +393,8 @@ public void cancelPlanEndpointPath() { // The planId contains a space which needs to be encoded String spaceSeparatedPlanId = "plan with spaces"; - // The expected encoded version of the planId - String encodedPlanId = "plan+with+spaces"; + // The expected encoded version of the planId (RFC 3986: space -> %20) + String encodedPlanId = "plan%20with%20spaces"; assertThat(withPrefix.plan(tableId, spaceSeparatedPlanId)) .isEqualTo(