Skip to content

Commit 7e5aa56

Browse files
committed
api: Add a parser/editor for URL encoded query parameters
1 parent b38df6c commit 7e5aa56

4 files changed

Lines changed: 485 additions & 1 deletion

File tree

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/*
2+
* Copyright 2026 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc;
18+
19+
import static com.google.common.base.Preconditions.checkNotNull;
20+
21+
import com.google.common.base.Splitter;
22+
import java.io.UnsupportedEncodingException;
23+
import java.net.URLDecoder;
24+
import java.net.URLEncoder;
25+
import java.util.ArrayList;
26+
import java.util.Iterator;
27+
import java.util.List;
28+
import java.util.Objects;
29+
import javax.annotation.Nullable;
30+
31+
/**
32+
* A parser and mutable container class for {@code application/x-www-form-urlencoded}-style URL
33+
* parameters as conceived by <a href="https://datatracker.ietf.org/doc/html/rfc1866#section-8.2.1">
34+
* RFC 1866 Section 8.2.1</a>.
35+
*
36+
* <p>For example, a URI like {@code "http://who?name=John+Doe&role=admin&role=user&active"} has:
37+
*
38+
* <ul>
39+
* <li>A key {@code "name"} with value {@code "John Doe"}
40+
* <li>A key {@code "role"} with value {@code "admin"}
41+
* <li>A second key named {@code "role"} with value {@code "user"}
42+
* <li>"Lone" key {@code "active"} without a value.
43+
* </ul>
44+
*/
45+
@Internal
46+
public final class QueryParameters {
47+
48+
private final List<Entry> entries = new ArrayList<>();
49+
50+
/** Creates a new, empty {@code QueryParameters} instance. */
51+
public QueryParameters() {}
52+
53+
/**
54+
* Parses a raw query string into a {@code QueryParameters} instance.
55+
*
56+
* <p>The input is split on {@code '&'} and each parameter is parsed as either a key/value pair
57+
* (if it contains an equals sign) or a "lone" key (if it does not).
58+
*
59+
* @param rawQueryString the raw query string to parse, must not be null
60+
* @return a new {@code QueryParameters} instance containing the parsed parameters
61+
*/
62+
public static QueryParameters parseRawQueryString(String rawQueryString) {
63+
checkNotNull(rawQueryString, "rawQueryString");
64+
QueryParameters params = new QueryParameters();
65+
for (String part : Splitter.on('&').split(rawQueryString)) {
66+
int equalsIndex = part.indexOf('=');
67+
if (equalsIndex == -1) {
68+
params.add(Entry.forRawLoneKey(part));
69+
} else {
70+
String rawKey = part.substring(0, equalsIndex);
71+
String rawValue = part.substring(equalsIndex + 1);
72+
params.add(Entry.forRawKeyValue(rawKey, rawValue));
73+
}
74+
}
75+
return params;
76+
}
77+
78+
/**
79+
* Returns the last parameter in the parameters list having the specified key.
80+
*
81+
* @param key the key to search for (non-encoded)
82+
* @return the matching {@link Entry}, or {@code null} if no match is found
83+
*/
84+
@Nullable
85+
public Entry getLast(String key) {
86+
checkNotNull(key, "key");
87+
for (int i = entries.size() - 1; i >= 0; --i) {
88+
Entry entry = entries.get(i);
89+
if (entry.getKey().equals(key)) {
90+
return entry;
91+
}
92+
}
93+
return null;
94+
}
95+
96+
/**
97+
* Appends 'entry' to the list of query parameters.
98+
*
99+
* @param entry the entry to add
100+
*/
101+
public void add(Entry entry) {
102+
entries.add(checkNotNull(entry, "entry"));
103+
}
104+
105+
/**
106+
* Removes all entries equal to the specified entry.
107+
*
108+
* <p>Two entries are considered equal if they have the same key and value *after* any URL
109+
* decoding has been performed.
110+
*
111+
* @param entry the entry to remove, must not be null
112+
* @return the number of entries removed
113+
*/
114+
public int removeAll(Entry entry) {
115+
checkNotNull(entry, "entry");
116+
int removed = 0;
117+
Iterator<Entry> it = entries.iterator();
118+
while (it.hasNext()) {
119+
if (it.next().equals(entry)) {
120+
it.remove();
121+
removed++;
122+
}
123+
}
124+
return removed;
125+
}
126+
127+
/**
128+
* Returns the raw query string representation of these parameters, suitable for passing to the
129+
* {@link io.grpc.Uri.Builder#setRawQuery} method.
130+
*
131+
* @return the raw query string
132+
*/
133+
public String toRawQueryString() {
134+
StringBuilder resultBuilder = new StringBuilder();
135+
for (int i = 0; i < entries.size(); i++) {
136+
if (i > 0) {
137+
resultBuilder.append('&');
138+
}
139+
entries.get(i).appendToRawQueryStringBuilder(resultBuilder);
140+
}
141+
return resultBuilder.toString();
142+
}
143+
144+
@Override
145+
public String toString() {
146+
return toRawQueryString();
147+
}
148+
149+
/** A single query parameter entry. */
150+
public static final class Entry {
151+
private final String rawKey;
152+
@Nullable private final String rawValue;
153+
private final String key;
154+
@Nullable private final String value;
155+
156+
private Entry(String rawKey, @Nullable String rawValue, String key, @Nullable String value) {
157+
this.rawKey = checkNotNull(rawKey, "rawKey");
158+
this.rawValue = rawValue;
159+
this.key = checkNotNull(key, "key");
160+
this.value = value;
161+
}
162+
163+
/**
164+
* Returns the key.
165+
*
166+
* <p>Any characters that needed URL encoding have already been decoded.
167+
*/
168+
public String getKey() {
169+
return key;
170+
}
171+
172+
/**
173+
* Returns the value, or {@code null} if this is a "lone" key.
174+
*
175+
* <p>Any characters that needed URL encoding have already been decoded.
176+
*/
177+
@Nullable
178+
public String getValue() {
179+
return value;
180+
}
181+
182+
/**
183+
* Creates a new key/value pair entry.
184+
*
185+
* <p>Both key and value can contain any character. They will be URL encoded for you later, if
186+
* necessary.
187+
*/
188+
public static Entry forKeyValue(String key, String value) {
189+
checkNotNull(key, "key");
190+
checkNotNull(value, "value");
191+
return new Entry(encode(key), encode(value), key, value);
192+
}
193+
194+
/**
195+
* Creates a new query parameter with a "lone" key.
196+
*
197+
* <p>'key' can contain any character. It will be URL encoded for you later, as necessary.
198+
*
199+
* @param key the decoded key, must not be null
200+
* @return a new {@code Entry}
201+
*/
202+
public static Entry forLoneKey(String key) {
203+
checkNotNull(key, "key");
204+
return new Entry(encode(key), null, key, null);
205+
}
206+
207+
static Entry forRawKeyValue(String rawKey, String rawValue) {
208+
checkNotNull(rawKey, "rawKey");
209+
checkNotNull(rawValue, "rawValue");
210+
return new Entry(rawKey, rawValue, decode(rawKey), decode(rawValue));
211+
}
212+
213+
static Entry forRawLoneKey(String rawKey) {
214+
checkNotNull(rawKey, "rawKey");
215+
return new Entry(rawKey, null, decode(rawKey), null);
216+
}
217+
218+
void appendToRawQueryStringBuilder(StringBuilder sb) {
219+
sb.append(rawKey);
220+
if (rawValue != null) {
221+
sb.append('=').append(rawValue);
222+
}
223+
}
224+
225+
@Override
226+
public boolean equals(Object o) {
227+
if (this == o) {
228+
return true;
229+
}
230+
if (!(o instanceof Entry)) {
231+
return false;
232+
}
233+
Entry entry = (Entry) o;
234+
return Objects.equals(key, entry.key) && Objects.equals(value, entry.value);
235+
}
236+
237+
@Override
238+
public int hashCode() {
239+
return Objects.hash(key, value);
240+
}
241+
}
242+
243+
private static String decode(String s) {
244+
try {
245+
// TODO: Use URLDecoder.decode(String, Charset) when available
246+
return URLDecoder.decode(s, "UTF-8");
247+
} catch (UnsupportedEncodingException impossible) {
248+
throw new AssertionError("UTF-8 is not supported", impossible);
249+
}
250+
}
251+
252+
private static String encode(String s) {
253+
try {
254+
// TODO: Use URLEncoder.encode(String, Charset) when available
255+
return URLEncoder.encode(s, "UTF-8");
256+
} catch (UnsupportedEncodingException impossible) {
257+
throw new AssertionError("UTF-8 is not supported", impossible);
258+
}
259+
}
260+
}

api/src/main/java/io/grpc/Uri.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -792,8 +792,24 @@ public Builder setQuery(@Nullable String query) {
792792
return this;
793793
}
794794

795+
/**
796+
* Specifies the query component of the new URI in its originally parsed, possibly
797+
* percent-encoded form (not including the leading '?').
798+
*
799+
* <p>Query can contain any string of codepoints but the caller must first percent-encode
800+
* anything other than RFC 3986's "query" character class using UTF-8.
801+
*
802+
* <p>This field is optional.
803+
*
804+
* @param query the new query component, or null to clear this field
805+
* @return this, for fluent building
806+
*/
795807
@CanIgnoreReturnValue
796-
Builder setRawQuery(String query) {
808+
public Builder setRawQuery(@Nullable String query) {
809+
if (query == null) {
810+
this.query = null;
811+
return this;
812+
}
797813
checkPercentEncodedArg(query, "query", queryChars);
798814
this.query = query;
799815
return this;

0 commit comments

Comments
 (0)