Skip to content

Commit 3411fdc

Browse files
committed
api: Add a parser/editor for URL encoded query params
1 parent 1e3472b commit 3411fdc

2 files changed

Lines changed: 460 additions & 0 deletions

File tree

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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+
* <p>Instances are not safe for concurrent access by multiple threads.
46+
*/
47+
@Internal
48+
public final class QueryParams {
49+
50+
private static final String UTF_8 = "UTF-8";
51+
private final List<Entry> entries = new ArrayList<>();
52+
53+
/** Creates a new, empty {@code QueryParameters} instance. */
54+
public QueryParams() {}
55+
56+
/**
57+
* Parses a raw query string into a {@code QueryParameters} instance.
58+
*
59+
* <p>The input is split on {@code '&'} and each parameter is parsed as either a key/value pair
60+
* (if it contains an equals sign) or a "lone" key (if it does not).
61+
*
62+
* @param rawQuery the raw query component to parse, must not be null
63+
* @return a new {@code QueryParameters} instance containing the parsed parameters
64+
*/
65+
public static QueryParams parseRawQueryString(String rawQuery) {
66+
checkNotNull(rawQuery, "rawQuery");
67+
QueryParams params = new QueryParams();
68+
if (!rawQuery.isEmpty()) {
69+
for (String part : Splitter.on('&').split(rawQuery)) {
70+
int equalsIndex = part.indexOf('=');
71+
if (equalsIndex == -1) {
72+
params.add(Entry.forRawLoneKey(part));
73+
} else {
74+
String rawKey = part.substring(0, equalsIndex);
75+
String rawValue = part.substring(equalsIndex + 1);
76+
params.add(Entry.forRawKeyValue(rawKey, rawValue));
77+
}
78+
}
79+
}
80+
return params;
81+
}
82+
83+
/**
84+
* Returns the last parameter in the parameters list having the specified key.
85+
*
86+
* @param key the key to search for (non-encoded)
87+
* @return the matching {@link Entry}, or {@code null} if no match is found
88+
*/
89+
@Nullable
90+
public Entry getLast(String key) {
91+
checkNotNull(key, "key");
92+
for (int i = entries.size() - 1; i >= 0; --i) {
93+
Entry entry = entries.get(i);
94+
if (entry.getKey().equals(key)) {
95+
return entry;
96+
}
97+
}
98+
return null;
99+
}
100+
101+
/**
102+
* Appends 'entry' to the list of query parameters.
103+
*
104+
* @param entry the entry to add
105+
*/
106+
public void add(Entry entry) {
107+
entries.add(checkNotNull(entry, "entry"));
108+
}
109+
110+
/**
111+
* Removes all entries equal to the specified entry.
112+
*
113+
* <p>Two entries are considered equal if they have the same key and value *after* any URL
114+
* decoding has been performed.
115+
*
116+
* @param entry the entry to remove, must not be null
117+
* @return the number of entries removed
118+
*/
119+
public int removeAll(Entry entry) {
120+
checkNotNull(entry, "entry");
121+
int removed = 0;
122+
Iterator<Entry> it = entries.iterator();
123+
while (it.hasNext()) {
124+
if (it.next().equals(entry)) {
125+
it.remove();
126+
removed++;
127+
}
128+
}
129+
return removed;
130+
}
131+
132+
/**
133+
* Returns the "raw" query string representation of these parameters, suitable for passing to the
134+
* {@link io.grpc.Uri.Builder#setRawQuery} method.
135+
*
136+
* @return the raw query string
137+
*/
138+
public String toRawQueryString() {
139+
StringBuilder resultBuilder = new StringBuilder();
140+
boolean first = true;
141+
for (Entry entry : entries) {
142+
if (!first) {
143+
resultBuilder.append('&');
144+
}
145+
entry.appendToRawQueryStringBuilder(resultBuilder);
146+
first = false;
147+
}
148+
return resultBuilder.toString();
149+
}
150+
151+
/** Returns true if and only if there are zero entries in this collection. */
152+
public boolean isEmpty() {
153+
return entries.isEmpty();
154+
}
155+
156+
@Override
157+
public String toString() {
158+
return toRawQueryString();
159+
}
160+
161+
/** A single query parameter entry. */
162+
public static final class Entry {
163+
private final String rawKey;
164+
@Nullable private final String rawValue;
165+
private final String key;
166+
@Nullable private final String value;
167+
168+
private Entry(String rawKey, @Nullable String rawValue, String key, @Nullable String value) {
169+
this.rawKey = checkNotNull(rawKey, "rawKey");
170+
this.rawValue = rawValue;
171+
this.key = checkNotNull(key, "key");
172+
this.value = value;
173+
}
174+
175+
/**
176+
* Returns the key.
177+
*
178+
* <p>Any characters that needed URL encoding have already been decoded.
179+
*/
180+
public String getKey() {
181+
return key;
182+
}
183+
184+
/**
185+
* Returns the value, or {@code null} if this is a "lone" key.
186+
*
187+
* <p>Any characters that needed URL encoding have already been decoded.
188+
*/
189+
@Nullable
190+
public String getValue() {
191+
return value;
192+
}
193+
194+
/**
195+
* Creates a new key/value pair entry.
196+
*
197+
* <p>Both key and value can contain any character. They will be URL encoded for you later, if
198+
* necessary.
199+
*/
200+
public static Entry forKeyValue(String key, String value) {
201+
checkNotNull(key, "key");
202+
checkNotNull(value, "value");
203+
return new Entry(encode(key), encode(value), key, value);
204+
}
205+
206+
/**
207+
* Creates a new query parameter with a "lone" key.
208+
*
209+
* <p>'key' can contain any character. It will be URL encoded for you later, as necessary.
210+
*
211+
* @param key the decoded key, must not be null
212+
* @return a new {@code Entry}
213+
*/
214+
public static Entry forLoneKey(String key) {
215+
checkNotNull(key, "key");
216+
return new Entry(encode(key), null, key, null);
217+
}
218+
219+
static Entry forRawKeyValue(String rawKey, String rawValue) {
220+
checkNotNull(rawKey, "rawKey");
221+
checkNotNull(rawValue, "rawValue");
222+
return new Entry(rawKey, rawValue, decode(rawKey), decode(rawValue));
223+
}
224+
225+
static Entry forRawLoneKey(String rawKey) {
226+
checkNotNull(rawKey, "rawKey");
227+
return new Entry(rawKey, null, decode(rawKey), null);
228+
}
229+
230+
void appendToRawQueryStringBuilder(StringBuilder sb) {
231+
sb.append(rawKey);
232+
if (rawValue != null) {
233+
sb.append('=').append(rawValue);
234+
}
235+
}
236+
237+
@Override
238+
public boolean equals(Object o) {
239+
if (this == o) {
240+
return true;
241+
}
242+
if (!(o instanceof Entry)) {
243+
return false;
244+
}
245+
Entry entry = (Entry) o;
246+
return Objects.equals(key, entry.key) && Objects.equals(value, entry.value);
247+
}
248+
249+
@Override
250+
public int hashCode() {
251+
return Objects.hash(key, value);
252+
}
253+
}
254+
255+
private static String decode(String s) {
256+
try {
257+
// TODO: Use URLDecoder.decode(String, Charset) when available
258+
return URLDecoder.decode(s, UTF_8);
259+
} catch (UnsupportedEncodingException impossible) {
260+
throw new AssertionError("UTF-8 is not supported", impossible);
261+
}
262+
}
263+
264+
private static String encode(String s) {
265+
try {
266+
// TODO: Use URLEncoder.encode(String, Charset) when available
267+
return URLEncoder.encode(s, UTF_8);
268+
} catch (UnsupportedEncodingException impossible) {
269+
throw new AssertionError("UTF-8 is not supported", impossible);
270+
}
271+
}
272+
}

0 commit comments

Comments
 (0)