diff --git a/api/src/main/java/io/grpc/QueryParameters.java b/api/src/main/java/io/grpc/QueryParameters.java new file mode 100644 index 00000000000..7929007c64d --- /dev/null +++ b/api/src/main/java/io/grpc/QueryParameters.java @@ -0,0 +1,260 @@ +/* + * 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.Iterator; +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: + * + *
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). + * + * @param rawQueryString the raw query string to parse, must not be null + * @return a new {@code QueryParameters} instance containing the parsed parameters + */ + public static QueryParameters parseRawQueryString(String rawQueryString) { + checkNotNull(rawQueryString, "rawQueryString"); + QueryParameters params = new QueryParameters(); + for (String part : Splitter.on('&').split(rawQueryString)) { + int equalsIndex = part.indexOf('='); + if (equalsIndex == -1) { + params.add(Entry.forRawLoneKey(part)); + } else { + String rawKey = part.substring(0, equalsIndex); + String rawValue = part.substring(equalsIndex + 1); + params.add(Entry.forRawKeyValue(rawKey, rawValue)); + } + } + return params; + } + + /** + * Returns the last parameter in the parameters list having the specified key. + * + * @param key the key to search for (non-encoded) + * @return the matching {@link Entry}, or {@code null} if no match is found + */ + @Nullable + public Entry getLast(String key) { + checkNotNull(key, "key"); + for (int i = entries.size() - 1; i >= 0; --i) { + Entry entry = entries.get(i); + if (entry.getKey().equals(key)) { + return entry; + } + } + return null; + } + + /** + * Appends 'entry' to the list of query parameters. + * + * @param entry the entry to add + */ + public void add(Entry entry) { + entries.add(checkNotNull(entry, "entry")); + } + + /** + * Removes all entries equal to the specified entry. + * + *
Two entries are considered equal if they have the same key and value *after* any URL
+ * decoding has been performed.
+ *
+ * @param entry the entry to remove, must not be null
+ * @return the number of entries removed
+ */
+ public int removeAll(Entry entry) {
+ checkNotNull(entry, "entry");
+ int removed = 0;
+ Iterator 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;
+ }
+
+ /**
+ * 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(key, entry.key) && Objects.equals(value, entry.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(key, value);
+ }
+ }
+
+ 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..404f7f4956e 100644
--- a/api/src/main/java/io/grpc/Uri.java
+++ b/api/src/main/java/io/grpc/Uri.java
@@ -792,8 +792,24 @@ public Builder setQuery(@Nullable String query) {
return this;
}
+ /**
+ * Specifies the query component of the new URI in its originally parsed, possibly
+ * percent-encoded form (not including the leading '?').
+ *
+ * Query can contain any string of codepoints but the caller must first percent-encode
+ * anything other than RFC 3986's "query" character class using UTF-8.
+ *
+ * This field is optional.
+ *
+ * @param query the new query component, or null to clear this field
+ * @return this, for fluent building
+ */
@CanIgnoreReturnValue
- Builder setRawQuery(String query) {
+ public Builder setRawQuery(@Nullable String query) {
+ if (query == null) {
+ this.query = null;
+ return this;
+ }
checkPercentEncodedArg(query, "query", queryChars);
this.query = query;
return this;
diff --git a/api/src/test/java/io/grpc/QueryParametersTest.java b/api/src/test/java/io/grpc/QueryParametersTest.java
new file mode 100644
index 00000000000..863850818a8
--- /dev/null
+++ b/api/src/test/java/io/grpc/QueryParametersTest.java
@@ -0,0 +1,185 @@
+/*
+ * 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 org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+
+import io.grpc.QueryParameters.Entry;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link QueryParameters}. */
+@RunWith(JUnit4.class)
+public class QueryParametersTest {
+
+ @Test
+ public void emptyInstance() {
+ QueryParameters params = new QueryParameters();
+ assertEquals("", params.toRawQueryString());
+ assertEquals("", params.toString());
+ }
+
+ @Test
+ public void parseEmptyString() {
+ QueryParameters params = QueryParameters.parseRawQueryString("");
+ assertEquals("", params.toRawQueryString());
+ }
+
+ @Test
+ public void parseNormalPairs() {
+ QueryParameters params = QueryParameters.parseRawQueryString("a=b&c=d");
+ assertEquals("a=b&c=d", params.toRawQueryString());
+
+ QueryParameters.Entry a = params.getLast("a");
+ assertEquals("a", a.getKey());
+ assertEquals("b", a.getValue());
+
+ QueryParameters.Entry c = params.getLast("c");
+ assertEquals("c", c.getKey());
+ assertEquals("d", c.getValue());
+ }
+
+ @Test
+ public void parseLoneKey() {
+ QueryParameters params = QueryParameters.parseRawQueryString("a&b");
+ assertEquals("a&b", params.toRawQueryString());
+
+ QueryParameters.Entry a = params.getLast("a");
+ assertEquals("a", a.getKey());
+ assertNull(a.getValue());
+
+ QueryParameters.Entry b = params.getLast("b");
+ assertEquals("b", b.getKey());
+ assertNull(b.getValue());
+ }
+
+ @Test
+ public void parseEmptyKeysAndValues() {
+ QueryParameters params = QueryParameters.parseRawQueryString("=&=");
+ assertEquals("=&=", params.toRawQueryString());
+
+ QueryParameters.Entry first = params.getLast("");
+ // getLast returns the LAST one
+ assertEquals("", first.getKey());
+ assertEquals("", first.getValue());
+ }
+
+ @Test
+ public void roundTripPreservesEncoding() {
+ // Spaces as +
+ QueryParameters params1 = QueryParameters.parseRawQueryString("a+b=c+d");
+ assertEquals("a+b=c+d", params1.toRawQueryString());
+ assertEquals("a b", params1.getLast("a b").getKey());
+ assertEquals("c d", params1.getLast("a b").getValue());
+
+ // Spaces as %20
+ QueryParameters params2 = QueryParameters.parseRawQueryString("a%20b=c%20d");
+ assertEquals("a%20b=c%20d", params2.toRawQueryString());
+ assertEquals("a b", params2.getLast("a b").getKey());
+ assertEquals("c d", params2.getLast("a b").getValue());
+
+ // Case of percent encoding
+ QueryParameters params3 = QueryParameters.parseRawQueryString("a=%4A");
+ assertEquals("a=%4A", params3.toRawQueryString());
+ assertEquals("J", params3.getLast("a").getValue());
+
+ QueryParameters params4 = QueryParameters.parseRawQueryString("a=%4a");
+ assertEquals("a=%4a", params4.toRawQueryString());
+ assertEquals("J", params4.getLast("a").getValue());
+ }
+
+ @Test
+ public void addMethod() {
+ QueryParameters params = new QueryParameters();
+ params.add(QueryParameters.Entry.forKeyValue("a b", "c d"));
+ params.add(QueryParameters.Entry.forLoneKey("e f"));
+
+ // URLEncoder encodes spaces as +
+ assertEquals("a+b=c+d&e+f", params.toRawQueryString());
+ }
+
+ @Test
+ public void removeAllMethod() {
+ QueryParameters params = QueryParameters.parseRawQueryString("a=b&c=d&a=b&a=c");
+
+ QueryParameters.Entry toRemove = QueryParameters.Entry.forKeyValue("a", "b");
+ int removed = params.removeAll(toRemove);
+
+ assertEquals(2, removed);
+ assertEquals("c=d&a=c", params.toRawQueryString());
+ }
+
+ @Test
+ public void removeAllLoneKey() {
+ QueryParameters params = QueryParameters.parseRawQueryString("a&b&a&a=b");
+
+ QueryParameters.Entry toRemove = QueryParameters.Entry.forLoneKey("a");
+ int removed = params.removeAll(toRemove);
+
+ assertEquals(2, removed);
+ assertEquals("b&a=b", params.toRawQueryString());
+ }
+
+ @Test
+ public void parseInvalidEncodingThrows() {
+ assertThrows(
+ IllegalArgumentException.class, () -> QueryParameters.parseRawQueryString("a=%GH"));
+ }
+
+ @Test
+ public void uriIntegration() throws Exception {
+ QueryParameters params = new QueryParameters();
+ params.add(Entry.forKeyValue("a", "b"));
+ params.add(Entry.forKeyValue("c", "d"));
+ Uri uri =
+ Uri.newBuilder()
+ .setScheme("http")
+ .setHost("example.com")
+ .setRawQuery(params.toRawQueryString())
+ .build();
+
+ assertEquals("http://example.com?a=b&c=d", uri.toString());
+ assertEquals("a=b&c=d", uri.getRawQuery());
+ }
+
+ @Test
+ public void keysAndValuesWithCharactersNeedingUrlEncoding() {
+ QueryParameters params = new QueryParameters();
+ params.add(Entry.forKeyValue("a=b", "c&d"));
+ params.add(Entry.forKeyValue("e+f", "g h"));
+
+ assertEquals("a%3Db=c%26d&e%2Bf=g+h", params.toRawQueryString());
+
+ QueryParameters roundTripped = QueryParameters.parseRawQueryString(params.toRawQueryString());
+ assertEquals("c&d", roundTripped.getLast("a=b").getValue());
+ assertEquals("g h", roundTripped.getLast("e+f").getValue());
+ }
+
+ @Test
+ public void keysAndValuesWithCodePointsOutsideAsciiRange() {
+ QueryParameters params = new QueryParameters();
+ params.add(Entry.forKeyValue("€", "𐐷"));
+
+ assertEquals("%E2%82%AC=%F0%90%90%B7", params.toRawQueryString());
+
+ QueryParameters roundTripped = QueryParameters.parseRawQueryString(params.toRawQueryString());
+ assertEquals("𐐷", roundTripped.getLast("€").getValue());
+ }
+}
diff --git a/api/src/test/java/io/grpc/UriTest.java b/api/src/test/java/io/grpc/UriTest.java
index a1bd550696f..a08b2c727ec 100644
--- a/api/src/test/java/io/grpc/UriTest.java
+++ b/api/src/test/java/io/grpc/UriTest.java
@@ -627,6 +627,26 @@ public void builder_canClearAllOptionalFields() {
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.getQuery()).isEqualTo("a=b&c=d");
+ 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();