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 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 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:
+ * 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: 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");
?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.
*
- * ?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.
*
*