Skip to content

Commit a0ca758

Browse files
committed
api: Add a parser/editor for URL encoded query params
1 parent 7082577 commit a0ca758

2 files changed

Lines changed: 458 additions & 0 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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+
for (int i = 0; i < entries.size(); i++) {
141+
if (i > 0) {
142+
resultBuilder.append('&');
143+
}
144+
entries.get(i).appendToRawQueryStringBuilder(resultBuilder);
145+
}
146+
return resultBuilder.toString();
147+
}
148+
149+
/** Returns true if and only if there are zero entries in this collection. */
150+
public boolean isEmpty() {
151+
return entries.isEmpty();
152+
}
153+
154+
@Override
155+
public String toString() {
156+
return toRawQueryString();
157+
}
158+
159+
/** A single query parameter entry. */
160+
public static final class Entry {
161+
private final String rawKey;
162+
@Nullable private final String rawValue;
163+
private final String key;
164+
@Nullable private final String value;
165+
166+
private Entry(String rawKey, @Nullable String rawValue, String key, @Nullable String value) {
167+
this.rawKey = checkNotNull(rawKey, "rawKey");
168+
this.rawValue = rawValue;
169+
this.key = checkNotNull(key, "key");
170+
this.value = value;
171+
}
172+
173+
/**
174+
* Returns the key.
175+
*
176+
* <p>Any characters that needed URL encoding have already been decoded.
177+
*/
178+
public String getKey() {
179+
return key;
180+
}
181+
182+
/**
183+
* Returns the value, or {@code null} if this is a "lone" key.
184+
*
185+
* <p>Any characters that needed URL encoding have already been decoded.
186+
*/
187+
@Nullable
188+
public String getValue() {
189+
return value;
190+
}
191+
192+
/**
193+
* Creates a new key/value pair entry.
194+
*
195+
* <p>Both key and value can contain any character. They will be URL encoded for you later, if
196+
* necessary.
197+
*/
198+
public static Entry forKeyValue(String key, String value) {
199+
checkNotNull(key, "key");
200+
checkNotNull(value, "value");
201+
return new Entry(encode(key), encode(value), key, value);
202+
}
203+
204+
/**
205+
* Creates a new query parameter with a "lone" key.
206+
*
207+
* <p>'key' can contain any character. It will be URL encoded for you later, as necessary.
208+
*
209+
* @param key the decoded key, must not be null
210+
* @return a new {@code Entry}
211+
*/
212+
public static Entry forLoneKey(String key) {
213+
checkNotNull(key, "key");
214+
return new Entry(encode(key), null, key, null);
215+
}
216+
217+
static Entry forRawKeyValue(String rawKey, String rawValue) {
218+
checkNotNull(rawKey, "rawKey");
219+
checkNotNull(rawValue, "rawValue");
220+
return new Entry(rawKey, rawValue, decode(rawKey), decode(rawValue));
221+
}
222+
223+
static Entry forRawLoneKey(String rawKey) {
224+
checkNotNull(rawKey, "rawKey");
225+
return new Entry(rawKey, null, decode(rawKey), null);
226+
}
227+
228+
void appendToRawQueryStringBuilder(StringBuilder sb) {
229+
sb.append(rawKey);
230+
if (rawValue != null) {
231+
sb.append('=').append(rawValue);
232+
}
233+
}
234+
235+
@Override
236+
public boolean equals(Object o) {
237+
if (this == o) {
238+
return true;
239+
}
240+
if (!(o instanceof Entry)) {
241+
return false;
242+
}
243+
Entry entry = (Entry) o;
244+
return Objects.equals(key, entry.key) && Objects.equals(value, entry.value);
245+
}
246+
247+
@Override
248+
public int hashCode() {
249+
return Objects.hash(key, value);
250+
}
251+
}
252+
253+
private static String decode(String s) {
254+
try {
255+
// TODO: Use URLDecoder.decode(String, Charset) when available
256+
return URLDecoder.decode(s, UTF_8);
257+
} catch (UnsupportedEncodingException impossible) {
258+
throw new AssertionError("UTF-8 is not supported", impossible);
259+
}
260+
}
261+
262+
private static String encode(String s) {
263+
try {
264+
// TODO: Use URLEncoder.encode(String, Charset) when available
265+
return URLEncoder.encode(s, UTF_8);
266+
} catch (UnsupportedEncodingException impossible) {
267+
throw new AssertionError("UTF-8 is not supported", impossible);
268+
}
269+
}
270+
}

0 commit comments

Comments
 (0)