diff --git a/api/src/main/java/io/grpc/QueryParams.java b/api/src/main/java/io/grpc/QueryParams.java new file mode 100644 index 00000000000..1bc1635eca1 --- /dev/null +++ b/api/src/main/java/io/grpc/QueryParams.java @@ -0,0 +1,287 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed 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 io.grpc; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Splitter; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * A parser and mutable container class for {@code application/x-www-form-urlencoded}-style URL + * parameters as conceived by + * RFC 1866 Section 8.2.1. + * + *

For example, a URI like {@code "http://who?name=John+Doe&role=admin&role=user&active"} has: + * + *

+ * + *

This class is meant to be used with {@link io.grpc.Uri}. For example: + * + *

{@code
+ * Uri uri = Uri.parse("http://who?name=John+Doe&role=admin&role=user&active");
+ * QueryParams params = QueryParams.fromRawQuery(uri.getRawQuery());
+ * params.asList().removeIf(e -> "role".equals(e.getKey()) && "admin".equals(e.getValue()));
+ *
+ * Uri modifiedUri = uri.toBuilder().setRawQuery(params.toRawQuery()).build();
+ * }
+ * + *

Note that the empty collection is encoded as a null raw query string, which means "absent" to + * {@link io.grpc.Uri.Builder#setRawQuery}. An empty string query component (""), on the other hand, + * is modeled as an instance of QueryParams containing a single lone key. It must be this way if we + * are to simultaneously 1) support lone keys, 2) have parse/toRawQuery round-trip transparency, and + * 3) never fail to parse a valid RFC 3986 query component. + * + *

This container and its {@link Entry} take the same position as {@link io.grpc.Uri} on + * equality: raw keys and values must match exactly to be equal. Most callers won't care about how + * keys and values are encoded on the wire and will work with the getters for cooked keys and values + * instead. + * + *

Instances are not safe for concurrent access by multiple threads, including by way of the + * {@link #asList()} view method. + */ +@Internal +public final class QueryParams { + + private static final String UTF_8 = "UTF-8"; + private final List entries = new ArrayList<>(); + + /** Creates a new, empty {@code QueryParams} instance. */ + public QueryParams() {} + + /** + * Parses a raw query string into a {@code QueryParams} instance. + * + *

The input is split on {@code '&'} and each parameter is parsed as either a key/value pair + * (if it contains an equals sign) or a "lone" key (if it does not). + * + *

Passing the value returned by {@link io.grpc.Uri#getRawQuery()} is always well-defined and + * will never fail. + * + *

Calling {@link #toRawQuery()} on the returned object is guaranteed to return exactly + * 'rawQuery'. + * + * @param rawQuery the raw query component to parse, or null to return an empty container + * @return a new instance of {@code QueryParams} representing the input + * @throws IllegalArgumentException if {@code rawQuery} is not a valid RFC 3986 query component. + */ + public static QueryParams fromRawQuery(@Nullable String rawQuery) { + QueryParams params = new QueryParams(); + if (rawQuery != null) { + for (String part : Splitter.on('&').split(rawQuery)) { + int equalsIndex = part.indexOf('='); + if (equalsIndex == -1) { + params.entries.add(Entry.forRawLoneKey(part)); + } else { + String rawKey = part.substring(0, equalsIndex); + String rawValue = part.substring(equalsIndex + 1); + params.entries.add(Entry.forRawKeyValue(rawKey, rawValue)); + } + } + } + return params; + } + + /** + * Returns a mutable list view of the query parameters. + * + * @return the mutable list of entries + */ + public List asList() { + return entries; + } + + /** + * Returns the "raw" query string representation of these parameters, suitable for passing to the + * {@link io.grpc.Uri.Builder#setRawQuery} method. + * + * @return the raw query string + */ + @Nullable + public String toRawQuery() { + if (entries.isEmpty()) { + return null; + } + StringBuilder resultBuilder = new StringBuilder(); + boolean first = true; + for (Entry entry : entries) { + if (!first) { + resultBuilder.append('&'); + } + entry.appendToRawQueryStringBuilder(resultBuilder); + first = false; + } + return resultBuilder.toString(); + } + + @Override + public String toString() { + return entries.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof QueryParams)) { + return false; + } + QueryParams other = (QueryParams) o; + return entries.equals(other.entries); + } + + @Override + public int hashCode() { + return entries.hashCode(); + } + + /** A single query parameter entry. */ + public static final class Entry { + private final String rawKey; + @Nullable private final String rawValue; + private final String key; + @Nullable private final String value; + + private Entry(String rawKey, @Nullable String rawValue, String key, @Nullable String value) { + this.rawKey = checkNotNull(rawKey, "rawKey"); + this.rawValue = rawValue; + this.key = checkNotNull(key, "key"); + this.value = value; + } + + /** + * Returns the key. + * + *

Any characters that needed URL encoding have already been decoded. + */ + public String getKey() { + return key; + } + + /** + * Returns the value, or {@code null} if this is a "lone" key. + * + *

Any characters that needed URL encoding have already been decoded. + */ + @Nullable + public String getValue() { + return value; + } + + /** Returns {@code true} if this entry has a value, {@code false} if it is a "lone" key. */ + public boolean hasValue() { + return value != null; + } + + /** + * Creates a new key/value pair entry. + * + *

Both key and value can contain any character. They will be URL encoded for you later, if + * necessary. + */ + public static Entry forKeyValue(String key, String value) { + checkNotNull(key, "key"); + checkNotNull(value, "value"); + return new Entry(encode(key), encode(value), key, value); + } + + /** + * Creates a new query parameter with a "lone" key. + * + *

'key' can contain any character. It will be URL encoded for you later, as necessary. + * + * @param key the decoded key, must not be null + * @return a new {@code Entry} + */ + public static Entry forLoneKey(String key) { + checkNotNull(key, "key"); + return new Entry(encode(key), null, key, null); + } + + static Entry forRawKeyValue(String rawKey, String rawValue) { + checkNotNull(rawKey, "rawKey"); + checkNotNull(rawValue, "rawValue"); + return new Entry(rawKey, rawValue, decode(rawKey), decode(rawValue)); + } + + static Entry forRawLoneKey(String rawKey) { + checkNotNull(rawKey, "rawKey"); + return new Entry(rawKey, null, decode(rawKey), null); + } + + void appendToRawQueryStringBuilder(StringBuilder sb) { + sb.append(rawKey); + if (rawValue != null) { + sb.append('=').append(rawValue); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Entry)) { + return false; + } + Entry entry = (Entry) o; + return Objects.equals(rawKey, entry.rawKey) && Objects.equals(rawValue, entry.rawValue); + } + + @Override + public int hashCode() { + return Objects.hash(rawKey, rawValue); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + appendToRawQueryStringBuilder(sb); + return sb.toString(); + } + } + + private static String decode(String s) { + try { + // TODO: Use URLDecoder.decode(String, Charset) when available + return URLDecoder.decode(s, UTF_8); + } catch (UnsupportedEncodingException impossible) { + throw new AssertionError("UTF-8 is not supported", impossible); + } + } + + private static String encode(String s) { + try { + // TODO: Use URLEncoder.encode(String, Charset) when available + return URLEncoder.encode(s, UTF_8); + } catch (UnsupportedEncodingException impossible) { + throw new AssertionError("UTF-8 is not supported", impossible); + } + } +} diff --git a/api/src/main/java/io/grpc/Uri.java b/api/src/main/java/io/grpc/Uri.java index 9f8a5a87848..42cc48044e9 100644 --- a/api/src/main/java/io/grpc/Uri.java +++ b/api/src/main/java/io/grpc/Uri.java @@ -546,24 +546,18 @@ public String getRawPath() { return path; } - /** - * Returns the percent-decoded "query" component of this URI, or null if not present. - * - *

NB: This method assumes the query was encoded as UTF-8, although RFC 3986 doesn't specify an - * encoding. - * - *

Decoding errors are indicated by a {@code '\u005CuFFFD'} unicode replacement character in - * the output. Callers who want to detect and handle errors in some other way should call {@link - * #getRawQuery()}, {@link #percentDecode(CharSequence)}, then decode the bytes for themselves. - */ - @Nullable - public String getQuery() { - return percentDecodeAssumedUtf8(query); - } - /** * Returns the query component of this URI in its originally parsed, possibly percent-encoded - * form, without any leading '?' character. + * form, without any leading '?' character, or null if not present. + * + *

The query component can only be read in its raw form. That’s because virtually everyone uses + * query as a container for structured data, with some additional layer of encoding not present in + * RFC-3986. Like 'application/x-www-form-urlencoded', which encodes key/value pairs like so: + * ?k1=v1&k2=v+2. The encoding of these containers always has characters that take on + * a special delimiter meaning when not percent-encoded and a literal meaning when they are (like + * '&', '=' and '+' above). Since it matters whether a character was percent encoded or not, + * offering a '#getQuery()' method that percent-decodes everything like we do for other components + * would be error-prone. */ @Nullable public String getRawQuery() { @@ -776,10 +770,20 @@ public Builder setRawPath(String path) { } /** - * Specifies the query component of the new URI (not including the leading '?'). + * Specifies the query component of the new URI, possibly percent-encoded, exactly as it will + * appear in the string form of the built URI. * - *

Query can contain any string of codepoints. Codepoints that can't be encoded literally - * will be percent-encoded for you as UTF-8. + *

'query' must only contain codepoints from RFC 3986's "query" character class. Any other + * characters must be percent-encoded using UTF-8. Do not include the leading '?' delimiter. + * + *

The query component can only be provided in its raw form. That’s because virtually + * everyone uses query as a container for structured data, with some additional layer of + * encoding not present in RFC-3986. Like 'application/x-www-form-urlencoded', which encodes + * key/value pairs like so: ?k1=v1&k2=v+2. The encoding of these containers always + * has characters that take on a special delimiter meaning when not percent-encoded and a + * literal meaning when they are (like '&', '=' and '+' above). Since 'query' must have already + * been carefully percent-encoded externally, a '#setQuery(String)' method that percent-encodes + * an assumed-cooked string would be error-prone. * *

This field is optional. * @@ -787,14 +791,10 @@ public Builder setRawPath(String path) { * @return this, for fluent building */ @CanIgnoreReturnValue - public Builder setQuery(@Nullable String query) { - this.query = percentEncode(query, queryChars); - return this; - } - - @CanIgnoreReturnValue - Builder setRawQuery(String query) { - checkPercentEncodedArg(query, "query", queryChars); + public Builder setRawQuery(@Nullable String query) { + if (query != null) { + checkPercentEncodedArg(query, "query", queryChars); + } this.query = query; return this; } diff --git a/api/src/test/java/io/grpc/QueryParamsTest.java b/api/src/test/java/io/grpc/QueryParamsTest.java new file mode 100644 index 00000000000..d7596103cde --- /dev/null +++ b/api/src/test/java/io/grpc/QueryParamsTest.java @@ -0,0 +1,271 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed 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 io.grpc; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import io.grpc.QueryParams.Entry; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link QueryParams}. */ +@RunWith(JUnit4.class) +public class QueryParamsTest { + + @Test + public void emptyInstance() { + QueryParams params = new QueryParams(); + assertThat(params.asList()).isEmpty(); + assertThat(params.toRawQuery()).isNull(); + } + + @Test + public void parseNull_yieldsEmptyInstance() { + QueryParams params = QueryParams.fromRawQuery(null); + assertThat(params.asList()).isEmpty(); + assertThat(params.toRawQuery()).isNull(); + } + + @Test + public void parseEmptyString_yieldsSingleLoneKey() { + QueryParams params = QueryParams.fromRawQuery(""); + assertThat(params.toRawQuery()).isEmpty(); + assertThat(params.asList()).isNotEmpty(); + Entry entry = params.asList().get(0); + assertThat(entry).isNotNull(); + assertThat(entry.getKey()).isEmpty(); + assertThat(entry.hasValue()).isFalse(); + assertThat(entry.getValue()).isNull(); + } + + @Test + public void parseNormalPairs() { + QueryParams params = QueryParams.fromRawQuery("a=b&c=d"); + assertThat(params.toRawQuery()).isEqualTo("a=b&c=d"); + + QueryParams.Entry a = params.asList().get(0); + assertThat(a.getKey()).isEqualTo("a"); + assertThat(a.hasValue()).isTrue(); + assertThat(a.getValue()).isEqualTo("b"); + + QueryParams.Entry c = params.asList().get(1); + assertThat(c.getKey()).isEqualTo("c"); + assertThat(c.getValue()).isEqualTo("d"); + } + + @Test + public void parseLoneKey() { + QueryParams params = QueryParams.fromRawQuery("a&b"); + assertThat(params.toRawQuery()).isEqualTo("a&b"); + + QueryParams.Entry a = params.asList().get(0); + assertThat(a.getKey()).isEqualTo("a"); + assertThat(a.hasValue()).isFalse(); + + QueryParams.Entry b = params.asList().get(1); + assertThat(b.getKey()).isEqualTo("b"); + assertThat(b.hasValue()).isFalse(); + } + + @Test + public void parseEmptyKeysAndValues() { + QueryParams params = QueryParams.fromRawQuery("=&="); + assertThat(params.toRawQuery()).isEqualTo("=&="); + + assertThat(params.asList()).hasSize(2); + assertThat(params.asList().get(0).getKey()).isEmpty(); + assertThat(params.asList().get(0).hasValue()).isTrue(); + assertThat(params.asList().get(0).getValue()).isEmpty(); + assertThat(params.asList().get(1).getKey()).isEmpty(); + assertThat(params.asList().get(1).hasValue()).isTrue(); + assertThat(params.asList().get(1).getValue()).isEmpty(); + } + + @Test + public void roundTripPreservesEncodingOfSpaces() { + // Spaces can be encoded as + or %20. + QueryParams params = QueryParams.fromRawQuery("a+b=c%20d"); + assertThat(params.asList().get(0).getKey()).isEqualTo("a b"); + assertThat(params.asList().get(0).getValue()).isEqualTo("c d"); + assertThat(params.toRawQuery()).isEqualTo("a+b=c%20d"); + } + + @Test + public void roundTripPreservesCaseOfHexDigits() { + // Percent encoding can use upper or lower case. + QueryParams params = QueryParams.fromRawQuery("%4A%4a=%4B%4b"); + assertThat(params.asList().get(0).getKey()).isEqualTo("JJ"); + assertThat(params.asList().get(0).getValue()).isEqualTo("KK"); + assertThat(params.toRawQuery()).isEqualTo("%4A%4a=%4B%4b"); + } + + @Test + public void asListMethod() { + QueryParams params = new QueryParams(); + params.asList().add(QueryParams.Entry.forKeyValue("a b", "c d")); + params.asList().add(QueryParams.Entry.forLoneKey("e f")); + + // URLEncoder encodes spaces as + + assertThat(params.toRawQuery()).isEqualTo("a+b=c+d&e+f"); + } + + @Test + public void parseInvalidPercentEncodingThrows() { + assertThrows(IllegalArgumentException.class, () -> QueryParams.fromRawQuery("a=%GH")); + } + + @Test + public void parseInvalidKeyValueEncodingSucceeds() { + QueryParams params = QueryParams.fromRawQuery("===="); + assertThat(params.toRawQuery()).isEqualTo("===="); + } + + @Test + public void uriIntegration_canBuild() { + QueryParams params = new QueryParams(); + params.asList().add(Entry.forKeyValue("a", "b")); + params.asList().add(Entry.forKeyValue("c", "d")); + + Uri uri = + Uri.newBuilder() + .setScheme("http") + .setHost("example.com") + .setRawQuery(params.toRawQuery()) + .build(); + + assertThat(uri.toString()).isEqualTo("http://example.com?a=b&c=d"); + assertThat(uri.getRawQuery()).isEqualTo("a=b&c=d"); + } + + @Test + public void uriIntegration_canBuildEmpty() { + QueryParams params = new QueryParams(); + Uri uri = + Uri.newBuilder() + .setScheme("http") + .setHost("example.com") + .setRawQuery(params.toRawQuery()) + .build(); + + assertThat(uri.toString()).isEqualTo("http://example.com"); + assertThat(uri.getRawQuery()).isNull(); + } + + @Test + public void uriIntegration_canParse() { + Uri uri = Uri.create("http://example.com?a=b&c=d&e"); + QueryParams params = QueryParams.fromRawQuery(uri.getRawQuery()); + + assertThat(params.asList()) + .containsExactly( + Entry.forKeyValue("a", "b"), Entry.forKeyValue("c", "d"), Entry.forLoneKey("e")) + .inOrder(); + } + + @Test + public void keysAndValuesWithCharactersNeedingUrlEncoding() { + QueryParams params = new QueryParams(); + params.asList().add(Entry.forKeyValue("a=b", "c&d")); + params.asList().add(Entry.forKeyValue("e+f", "g h")); + + assertThat(params.toRawQuery()).isEqualTo("a%3Db=c%26d&e%2Bf=g+h"); + + QueryParams roundTripped = QueryParams.fromRawQuery(params.toRawQuery()); + assertThat(roundTripped).isEqualTo(params); + } + + @Test + public void keysAndValuesWithCodePointsOutsideAsciiRange() { + QueryParams params = new QueryParams(); + params.asList().add(Entry.forKeyValue("€", "𐐷")); + + assertThat(params.toRawQuery()).isEqualTo("%E2%82%AC=%F0%90%90%B7"); + + QueryParams roundTripped = QueryParams.fromRawQuery(params.toRawQuery()); + assertThat(roundTripped).isEqualTo(params); + } + + @Test + public void toStringMethod() { + QueryParams params = new QueryParams(); + assertThat(params.toString()).isEqualTo("[]"); + + params.asList().add(Entry.forKeyValue("a", "b")); + assertThat(params.toString()).isEqualTo("[a=b]"); + + params.asList().add(Entry.forLoneKey("c")); + assertThat(params.toString()).isEqualTo("[a=b, c]"); + + params.asList().add(Entry.forKeyValue("d=e", "f&g")); + assertThat(params.toString()).isEqualTo("[a=b, c, d%3De=f%26g]"); + } + + @Test + public void entryProperties() { + Entry keyValue = Entry.forKeyValue("key", "val"); + assertThat(keyValue.getKey()).isEqualTo("key"); + assertThat(keyValue.getValue()).isEqualTo("val"); + assertThat(keyValue.hasValue()).isTrue(); + + Entry loneKey = Entry.forLoneKey("key"); + assertThat(loneKey.getKey()).isEqualTo("key"); + assertThat(loneKey.getValue()).isNull(); + assertThat(loneKey.hasValue()).isFalse(); + } + + @Test + public void equalsAndHashCode_container() { + QueryParams params1 = new QueryParams(); + QueryParams params2 = new QueryParams(); + + // Empty instances are equal + assertThat(params1).isEqualTo(params2); + assertThat(params1.hashCode()).isEqualTo(params2.hashCode()); + + params1.asList().add(Entry.forKeyValue("a", "b")); + params1.asList().add(Entry.forLoneKey("c")); + + params2.asList().add(Entry.forKeyValue("a", "b")); + params2.asList().add(Entry.forLoneKey("c")); + + // Identical parameters in identical order are equal + assertThat(params1).isEqualTo(params2); + assertThat(params1.hashCode()).isEqualTo(params2.hashCode()); + + // Order matters. + QueryParams params3 = new QueryParams(); + params3.asList().add(Entry.forLoneKey("c")); + params3.asList().add(Entry.forKeyValue("a", "b")); + assertThat(params1).isNotEqualTo(params3); + } + + @Test + public void equalsAndHashCode_entry() { + // Raw matches are equal. + assertThat(Entry.forRawKeyValue("a+b", "c")).isEqualTo(Entry.forRawKeyValue("a+b", "c")); + assertThat(Entry.forRawKeyValue("a+b", "c").hashCode()) + .isEqualTo(Entry.forRawKeyValue("a+b", "c").hashCode()); + + // Spaces encoding matters. + and %20 are not equal. + assertThat(Entry.forRawKeyValue("a+b", "c")).isNotEqualTo(Entry.forRawKeyValue("a%20b", "c")); + + // Case of hex digits matter: %4A vs %4a are not equal raw keys. + assertThat(Entry.forRawKeyValue("a", "%4A")).isNotEqualTo(Entry.forRawKeyValue("a", "%4a")); + } +} diff --git a/api/src/test/java/io/grpc/UriTest.java b/api/src/test/java/io/grpc/UriTest.java index a1bd550696f..71ec1749b7d 100644 --- a/api/src/test/java/io/grpc/UriTest.java +++ b/api/src/test/java/io/grpc/UriTest.java @@ -42,7 +42,7 @@ public void parse_allComponents() throws URISyntaxException { assertThat(uri.getPort()).isEqualTo(443); assertThat(uri.getRawPort()).isEqualTo("0443"); assertThat(uri.getPath()).isEqualTo("/path"); - assertThat(uri.getQuery()).isEqualTo("query"); + assertThat(uri.getRawQuery()).isEqualTo("query"); assertThat(uri.getFragment()).isEqualTo("fragment"); assertThat(uri.toString()).isEqualTo("scheme://user@host:0443/path?query#fragment"); assertThat(uri.isAbsolute()).isFalse(); // Has a fragment. @@ -56,7 +56,7 @@ public void parse_noAuthority() throws URISyntaxException { assertThat(uri.getScheme()).isEqualTo("scheme"); assertThat(uri.getAuthority()).isNull(); assertThat(uri.getPath()).isEqualTo("/path"); - assertThat(uri.getQuery()).isEqualTo("query"); + assertThat(uri.getRawQuery()).isEqualTo("query"); assertThat(uri.getFragment()).isEqualTo("fragment"); assertThat(uri.toString()).isEqualTo("scheme:/path?query#fragment"); assertThat(uri.isAbsolute()).isFalse(); // Has a fragment. @@ -102,7 +102,7 @@ public void parse_noQuery() throws URISyntaxException { assertThat(uri.getScheme()).isEqualTo("scheme"); assertThat(uri.getAuthority()).isEqualTo("authority"); assertThat(uri.getPath()).isEqualTo("/path"); - assertThat(uri.getQuery()).isNull(); + assertThat(uri.getRawQuery()).isNull(); assertThat(uri.getFragment()).isEqualTo("fragment"); assertThat(uri.toString()).isEqualTo("scheme://authority/path#fragment"); } @@ -113,7 +113,7 @@ public void parse_noFragment() throws URISyntaxException { assertThat(uri.getScheme()).isEqualTo("scheme"); assertThat(uri.getAuthority()).isEqualTo("authority"); assertThat(uri.getPath()).isEqualTo("/path"); - assertThat(uri.getQuery()).isEqualTo("query"); + assertThat(uri.getRawQuery()).isEqualTo("query"); assertThat(uri.getFragment()).isNull(); assertThat(uri.toString()).isEqualTo("scheme://authority/path?query"); assertThat(uri.isAbsolute()).isTrue(); @@ -125,7 +125,7 @@ public void parse_emptyPathWithAuthority() throws URISyntaxException { assertThat(uri.getScheme()).isEqualTo("scheme"); assertThat(uri.getAuthority()).isEqualTo("authority"); assertThat(uri.getPath()).isEmpty(); - assertThat(uri.getQuery()).isNull(); + assertThat(uri.getRawQuery()).isNull(); assertThat(uri.getFragment()).isNull(); assertThat(uri.toString()).isEqualTo("scheme://authority"); assertThat(uri.isAbsolute()).isTrue(); @@ -139,7 +139,7 @@ public void parse_rootless() throws URISyntaxException { assertThat(uri.getScheme()).isEqualTo("mailto"); assertThat(uri.getAuthority()).isNull(); assertThat(uri.getPath()).isEqualTo("ceo@company.com"); - assertThat(uri.getQuery()).isEqualTo("subject=raise"); + assertThat(uri.getRawQuery()).isEqualTo("subject=raise"); assertThat(uri.getFragment()).isNull(); assertThat(uri.toString()).isEqualTo("mailto:ceo@company.com?subject=raise"); assertThat(uri.isAbsolute()).isTrue(); @@ -153,7 +153,7 @@ public void parse_emptyPath() throws URISyntaxException { assertThat(uri.getScheme()).isEqualTo("scheme"); assertThat(uri.getAuthority()).isNull(); assertThat(uri.getPath()).isEmpty(); - assertThat(uri.getQuery()).isNull(); + assertThat(uri.getRawQuery()).isNull(); assertThat(uri.getFragment()).isNull(); assertThat(uri.toString()).isEqualTo("scheme:"); assertThat(uri.isAbsolute()).isTrue(); @@ -165,7 +165,7 @@ public void parse_emptyPath() throws URISyntaxException { public void parse_emptyQuery() { Uri uri = Uri.create("scheme:?"); assertThat(uri.getScheme()).isEqualTo("scheme"); - assertThat(uri.getQuery()).isEmpty(); + assertThat(uri.getRawQuery()).isEmpty(); } @Test @@ -322,7 +322,6 @@ public void parse_decoding() throws URISyntaxException { assertThat(uri.getPort()).isEqualTo(1234); assertThat(uri.getPath()).isEqualTo("/p ath"); assertThat(uri.getRawPath()).isEqualTo("/p%20ath"); - assertThat(uri.getQuery()).isEqualTo("q uery"); assertThat(uri.getRawQuery()).isEqualTo("q%20uery"); assertThat(uri.getFragment()).isEqualTo("f ragment"); assertThat(uri.getRawFragment()).isEqualTo("f%20ragment"); @@ -336,9 +335,8 @@ public void parse_decodingNonAscii() throws URISyntaxException { @Test public void parse_decodingPercent() throws URISyntaxException { - Uri uri = Uri.parse("s://a/p%2520ath?q%25uery#f%25ragment"); + Uri uri = Uri.parse("s://a/p%2520ath#f%25ragment"); assertThat(uri.getPath()).isEqualTo("/p%20ath"); - assertThat(uri.getQuery()).isEqualTo("q%uery"); assertThat(uri.getFragment()).isEqualTo("f%ragment"); } @@ -420,7 +418,7 @@ public void toString_percentEncoding() throws URISyntaxException { .setScheme("s") .setHost("a b") .setPath("/p ath") - .setQuery("q uery") + .setRawQuery("q%20uery") .setFragment("f ragment") .build(); assertThat(uri.toString()).isEqualTo("s://a%20b/p%20ath?q%20uery#f%20ragment"); @@ -440,7 +438,6 @@ public void parse_transparentRoundTrip_ipLiteral() { assertThat(uri.getRawPath()).isEqualTo("/%4a%4B%2f%2F"); assertThat(uri.getPathSegments()).containsExactly("JK//"); assertThat(uri.getRawQuery()).isEqualTo("%4c%4D"); - assertThat(uri.getQuery()).isEqualTo("LM"); assertThat(uri.getRawFragment()).isEqualTo("%4e%4F"); assertThat(uri.getFragment()).isEqualTo("NO"); } @@ -459,7 +456,6 @@ public void parse_transparentRoundTrip_regName() { assertThat(uri.getRawPath()).isEqualTo("/%4a%4B%2f%2F"); assertThat(uri.getPathSegments()).containsExactly("JK//"); assertThat(uri.getRawQuery()).isEqualTo("%4c%4D"); - assertThat(uri.getQuery()).isEqualTo("LM"); assertThat(uri.getRawFragment()).isEqualTo("%4e%4F"); assertThat(uri.getFragment()).isEqualTo("NO"); } @@ -529,7 +525,7 @@ public void builder_encodingWithAllowedReservedChars() throws URISyntaxException .setUserInfo("u@") .setHost("a[]") .setPath("/p:/@") - .setQuery("q/?") + .setRawQuery("q/?") .setFragment("f/?") .build(); assertThat(uri.toString()).isEqualTo("s://u%40@a%5B%5D/p:/@?q/?#f/?"); @@ -600,7 +596,7 @@ public void builder_normalizesCaseWhereAppropriate() { .setScheme("hTtP") // #section-3.1 says producers (Builder) should normalize to lower. .setHost("aBc") // #section-3.2.2 says producers (Builder) should normalize to lower. .setPath("/CdE") // #section-6.2.2.1 says the rest are assumed to be case-sensitive - .setQuery("fGh") + .setRawQuery("fGh") .setFragment("IjK") .build(); assertThat(uri.toString()).isEqualTo("http://abc/CdE?fGh#IjK"); @@ -621,12 +617,32 @@ public void builder_canClearAllOptionalFields() { .setPath("") .setUserInfo(null) .setPort(-1) - .setQuery(null) + .setRawQuery(null) .setFragment(null) .build(); assertThat(uri.toString()).isEqualTo("http:"); } + @Test + public void builder_setRawQuery() { + Uri uri = Uri.newBuilder().setScheme("http").setHost("host").setRawQuery("%61=b&c=%64").build(); + assertThat(uri.getRawQuery()).isEqualTo("%61=b&c=%64"); + assertThat(uri.toString()).isEqualTo("http://host?%61=b&c=%64"); + } + + @Test + public void builder_setRawQuery_null() { + Uri uri = + Uri.newBuilder() + .setScheme("http") + .setHost("host") + .setRawQuery("a=b") + .setRawQuery(null) + .build(); + assertThat(uri.getRawQuery()).isNull(); + assertThat(uri.toString()).isEqualTo("http://host"); + } + @Test public void builder_canClearAuthorityComponents() { Uri uri = Uri.create("s://user@host:80/path").toBuilder().setRawAuthority(null).build(); @@ -692,7 +708,7 @@ public void toString_percentEncodingLiteralPercent() throws URISyntaxException { .setScheme("s") .setHost("a") .setPath("/p%20ath") - .setQuery("q%uery") + .setRawQuery("q%25uery") .setFragment("f%ragment") .build(); assertThat(uri.toString()).isEqualTo("s://a/p%2520ath?q%25uery#f%25ragment");