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

+ */ +@Internal +public final class QueryParameters { + + private final List entries = new ArrayList<>(); + + /** Creates a new, empty {@code QueryParameters} instance. */ + public QueryParameters() {} + + /** + * Parses a raw query string into a {@code QueryParameters} 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). + * + * @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 it = entries.iterator(); + while (it.hasNext()) { + if (it.next().equals(entry)) { + it.remove(); + removed++; + } + } + return removed; + } + + /** + * 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 + */ + public String toRawQueryString() { + StringBuilder resultBuilder = new StringBuilder(); + for (int i = 0; i < entries.size(); i++) { + if (i > 0) { + resultBuilder.append('&'); + } + entries.get(i).appendToRawQueryStringBuilder(resultBuilder); + } + return resultBuilder.toString(); + } + + @Override + public String toString() { + return toRawQueryString(); + } + + /** 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; + } + + /** + * 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();