Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
51a5d1e
Add RestUtils.decodeQueryStringMulti for repeated query parameters
felixbarny Mar 18, 2026
09c97c8
[CI] Auto commit changes from spotless
Mar 18, 2026
23044f7
Merge branch 'main' into prometheus-series-rest-utils
felixbarny Mar 19, 2026
9310802
Merge branch 'main' into prometheus-series-rest-utils
felixbarny Mar 23, 2026
3ca02b6
Introduce ParameterMap and migrate decodeQueryString callers
felixbarny Mar 24, 2026
2e10f20
Use decodeQueryStringMulti in Watcher HttpRequest/Template fromUrl
felixbarny Mar 24, 2026
36e3c24
Remove decodeQueryString(Map) and drop Multi suffix from RestUtils
felixbarny Mar 24, 2026
8804194
Move query-string parsing to ParameterMap as fromQueryString/fromUrl/…
felixbarny Mar 24, 2026
285febe
Rename ParameterMap to RequestParams
felixbarny Mar 24, 2026
0b5404b
Make RequestParams immutable
felixbarny Mar 24, 2026
3c6cc06
Address code review findings in RequestParams
felixbarny Mar 24, 2026
786b94c
Merge origin/main into prometheus-series-rest-utils
felixbarny Mar 24, 2026
d77afa3
Throw RestRequest.BadParameterException from requireSingle
felixbarny Mar 24, 2026
a638f86
Inline parseQueryStringPairs into decodeQueryString
felixbarny Mar 24, 2026
a6e9742
Inline parseQueryStringPairs into decodeQueryString
felixbarny Mar 24, 2026
9f24d72
Inline parseQueryStringPairs into decodeQueryString
felixbarny Mar 24, 2026
60cb694
Simplify RestUtilsTests diff
felixbarny Mar 24, 2026
e61f873
Merge felixbarny/prometheus-series-rest-utils
felixbarny Mar 24, 2026
a6dcfa5
Fix testRequireSingleThrowsOnMultipleValues
felixbarny Mar 24, 2026
02a21df
->
felixbarny Mar 24, 2026
4c1de93
Rename RequestParams.fromUrl to fromUri
felixbarny Mar 24, 2026
8dd9c25
Rename RestRequest paramAsList to repeatedParamAsList
felixbarny Mar 24, 2026
fcfefd2
Update GCS fixture to use RequestParams.from(URI) instead of removed …
felixbarny Mar 24, 2026
3bea1e0
Fix setParamTrueOnceAndConsume to use a mutable overlay instead of mu…
felixbarny Mar 24, 2026
dc006aa
Restore mutable put() semantics on RequestParams, revert markerParams…
felixbarny Mar 24, 2026
c8961ac
Move BadParameterException wrapping into RequestParams.fromQueryString
felixbarny Mar 24, 2026
201b5a5
Fix fromUri(String) to delegate to fromQueryString for BadParameterEx…
felixbarny Mar 24, 2026
aded238
Add @throws BadParameterException to RequestParams parsing factory me…
felixbarny Mar 24, 2026
9948847
Override remove(), clear(), and entrySet iterator remove() in Request…
felixbarny Mar 25, 2026
144a1f9
Use Maps.newLinkedHashMapWithExpectedSize
felixbarny Mar 25, 2026
2e5a3a6
Merge branch 'main' into prometheus-series-rest-utils
felixbarny Mar 25, 2026
42a651b
Fix fromSingleValues null handling and update stale test assertions
felixbarny Mar 25, 2026
de9bbf8
Use RequestParams in RestUtils.decodeQueryString and addParam
felixbarny Mar 25, 2026
e708c64
Add missing test coverage per review feedback
felixbarny Mar 27, 2026
0a0a7e8
Merge branch 'main' into prometheus-series-rest-utils
felixbarny Mar 27, 2026
18390cd
Merge branch 'main' into prometheus-series-rest-utils
felixbarny Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@
import org.elasticsearch.mocksocket.MockHttpServer;
import org.elasticsearch.repositories.RepositoriesMetrics;
import org.elasticsearch.repositories.blobstore.AbstractBlobContainerRetriesTestCase;
import org.elasticsearch.rest.RequestParams;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.rest.RestUtils;
import org.elasticsearch.test.ClusterServiceUtils;
import org.elasticsearch.test.fixture.HttpHeaderParser;
import org.elasticsearch.threadpool.TestThreadPool;
Expand All @@ -66,7 +66,6 @@
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
Expand Down Expand Up @@ -373,8 +372,7 @@ public void testWriteLargeBlob() throws Exception {

try {
if ("PUT".equals(exchange.getRequestMethod())) {
final Map<String, String> params = new HashMap<>();
RestUtils.decodeQueryString(exchange.getRequestURI().getRawQuery(), 0, params);
final var params = RequestParams.fromQueryString(exchange.getRequestURI().getRawQuery());

final String blockId = params.get("blockid");
assert Strings.hasText(blockId) == false || AzureFixtureHelper.assertValidBlockId(blockId);
Expand Down Expand Up @@ -451,8 +449,7 @@ public void testWriteLargeBlobStreaming() throws Exception {
httpServer.createContext(downloadStorageEndpoint(blobContainer, "write_large_blob_streaming"), exchange -> {

if ("PUT".equals(exchange.getRequestMethod())) {
final Map<String, String> params = new HashMap<>();
RestUtils.decodeQueryString(exchange.getRequestURI().getRawQuery(), 0, params);
final var params = RequestParams.fromQueryString(exchange.getRequestURI().getRawQuery());

final String blockId = params.get("blockid");
assert Strings.hasText(blockId) == false || AzureFixtureHelper.assertValidBlockId(blockId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@
import org.elasticsearch.http.ResponseInjectingHttpHandler;
import org.elasticsearch.repositories.blobstore.AbstractBlobContainerRetriesTestCase;
import org.elasticsearch.repositories.blobstore.ESMockAPIBasedRepositoryIntegTestCase;
import org.elasticsearch.rest.RequestParams;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.rest.RestUtils;
import org.elasticsearch.test.ClusterServiceUtils;
import org.elasticsearch.test.fixture.HttpHeaderParser;
import org.threeten.bp.Duration;
Expand All @@ -68,7 +68,6 @@
import java.net.SocketTimeoutException;
import java.nio.file.NoSuchFileException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
Expand Down Expand Up @@ -421,8 +420,7 @@ public void testWriteLargeBlob() throws IOException {
httpServer.createContext("/upload/storage/v1/b/bucket/o", safeHandler(exchange -> {
final BytesReference requestBody = Streams.readFully(exchange.getRequestBody());

final Map<String, String> params = new HashMap<>();
RestUtils.decodeQueryString(exchange.getRequestURI().getQuery(), 0, params);
final var params = RequestParams.fromQueryString(exchange.getRequestURI().getQuery());
assertThat(params.get("uploadType"), equalTo("resumable"));

if ("POST".equals(exchange.getRequestMethod())) {
Expand Down
261 changes: 261 additions & 0 deletions server/src/main/java/org/elasticsearch/rest/RequestParams.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.rest;

import org.elasticsearch.common.util.Maps;

import java.net.URI;
import java.util.AbstractMap;
import java.util.AbstractSet;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
* A {@link Map}{@code <String, String>} for HTTP request parameters that preserves multiple values
* per key (e.g. repeated query parameters such as {@code match[]=foo&match[]=bar}).
*
* <p>Each key maps to a non-empty ordered list of values. The standard {@link Map} interface
* operates on the <em>last</em> value in that list: {@link #get(Object)} returns the last value for a key, or {@code null} if absent,
* and {@link #put(String, String)} sets a key to a single value (stored as a one-element list, so {@link #getAll(String)} returns
* a singleton list after a {@code put}).
* Use {@link #getAll(String)} to retrieve all values for a repeated key.
*
* <p>The value lists returned by {@link #getAll(String)} are always non-empty and immutable, so
* {@link List#getFirst()} and {@link List#getLast()} are always safe to call on a non-{@code null} result.
*/
public final class RequestParams extends AbstractMap<String, String> {

/** Single backing store: key → non-empty ordered list of all values. */
private final Map<String, List<String>> map;

// Factory methods

/**
* Returns an empty {@code RequestParams} instance.
*/
public static RequestParams empty() {
return new RequestParams(new LinkedHashMap<>());
}

/**
* Creates a {@code RequestParams} from a multi-value map. Each list must be non-empty.
* The last value in each list is what {@link #get(Object)} returns.
*
* @param multiValues a map from parameter name to all its values, in encounter order
* @throws IllegalArgumentException if any value list is empty
*/
static RequestParams of(Map<String, List<String>> multiValues) {
LinkedHashMap<String, List<String>> copy = Maps.newLinkedHashMapWithExpectedSize(multiValues.size());
multiValues.forEach((k, v) -> {
if (v.isEmpty()) {
throw new IllegalArgumentException("value list for parameter [" + k + "] must not be empty");
}
copy.put(k, List.copyOf(v));
});
return new RequestParams(copy);
}

/**
* Parses a URL-encoded query string into a {@code RequestParams}, preserving all values for
* repeated parameters (e.g. {@code match[]=foo&match[]=bar} → {@code ["foo", "bar"]}).
*
* @param queryString the raw query string (the part after {@code ?}, without the {@code ?} itself)
* @return a {@code RequestParams} from parameter name to all its values, in encounter order
* @throws RestRequest.BadParameterException if the query string cannot be decoded
*/
public static RequestParams fromQueryString(String queryString) {
try {
return RestUtils.decodeQueryString(queryString, 0);
} catch (IllegalArgumentException e) {
throw new RestRequest.BadParameterException(e);
}
}

/**
* Parses the query string from a URI string into a {@code RequestParams}, preserving all
* values for repeated parameters. Returns an empty map if the URI contains no {@code ?}.
*
* @param uri a URI string, e.g. {@code /index/_search?pretty&size=10}
* @return a {@code RequestParams} from parameter name to all its values, in encounter order
* @throws RestRequest.BadParameterException if the query string cannot be decoded
*/
public static RequestParams fromUri(String uri) {
int index = uri.indexOf('?');
return index >= 0 ? fromQueryString(uri.substring(index + 1)) : RequestParams.empty();
}
Comment thread
felixbarny marked this conversation as resolved.

/**
* Parses the query string from a {@link URI} into a {@code RequestParams}, preserving all
* values for repeated parameters. Returns an empty map if the URI has no query.
*
* @param uri the URI whose raw query string is parsed
* @return a {@code RequestParams} from parameter name to all its values, in encounter order
* @throws RestRequest.BadParameterException if the query string cannot be decoded
*/
public static RequestParams from(URI uri) {
final var rawQuery = uri.getRawQuery();
return rawQuery != null && rawQuery.isEmpty() == false ? fromQueryString(rawQuery) : RequestParams.empty();
}

/**
* Creates a {@code RequestParams} from a plain single-value map.
* Each value is wrapped in a singleton list so that {@link #getAll(String)} returns a
* one-element list rather than an empty one.
*
* @param singleValues a map whose values are treated as the sole value for each key
*/
public static RequestParams fromSingleValues(Map<String, String> singleValues) {
LinkedHashMap<String, List<String>> wrapped = Maps.newLinkedHashMapWithExpectedSize(singleValues.size());
singleValues.forEach((k, v) -> wrapped.put(k, Collections.singletonList(v)));
return new RequestParams(wrapped);
}

private RequestParams(Map<String, List<String>> map) {
this.map = map;
}

/**
* Appends {@code value} to the list of values for {@code key}.
* If the key is not yet present, a new entry is created.
*/
void addValue(String key, String value) {
map.computeIfAbsent(key, k -> new ArrayList<>(1)).add(value);
}
Comment thread
felixbarny marked this conversation as resolved.

/**
* Returns all values for {@code key} in the order they were added,
* or an empty list if the key is absent.
*
* @param key the parameter name
* @return an unmodifiable non-empty list of all values, or an empty list if absent; never {@code null}
*/
public List<String> getAll(String key) {
var list = map.get(key);
return list == null ? List.of() : Collections.unmodifiableList(list);
}

/**
* Returns the single value for {@code key}, or {@code null} if absent.
*
* @param key the parameter name
* @return the single value, or {@code null} if absent
* @throws RestRequest.BadParameterException if the key has multiple values
*/
public String requireSingle(String key) {
var list = map.get(key);
if (list == null) {
return null;
}
if (list.size() > 1) {
throw new RestRequest.BadParameterException(
new IllegalArgumentException("parameter [" + key + "] must have a single value, but found: " + list)
);
}
return list.getFirst();
}

/**
* Returns the last value associated with {@code key}, or {@code null} if absent.
*
* <p>When a query parameter appears multiple times (e.g. {@code a=1&a=2}), this method returns
* the <em>last</em> value ({@code "2"}). Use {@link #getAll(String)} to retrieve all values, or
* {@link #requireSingle(String)} to assert that only one value is present.
*/
@Override
public String get(Object key) {
var list = map.get(key);
return list == null ? null : list.getLast();
}

/**
* Sets {@code key} to a single {@code value}, replacing any previous values for that key.
* The value is stored as a one-element immutable list, so {@link #getAll(String)} will return
* a singleton list and {@link List#getFirst()}/{@link List#getLast()} remain safe to call.
*
* @return the previous last value for {@code key}, or {@code null} if absent
*/
@Override
public String put(String key, String value) {
var prev = map.put(key, Collections.singletonList(value));
return prev == null ? null : prev.getLast();
}

@Override
public int size() {
return map.size();
}

@Override
public String remove(Object key) {
var prev = map.remove(key);
return prev == null ? null : prev.getLast();
}

@Override
public void clear() {
map.clear();
}

@Override
public boolean containsKey(Object key) {
return map.containsKey(key);
}

@Override
public Set<String> keySet() {
return Collections.unmodifiableSet(map.keySet());
}

/**
* Returns an entry set where each entry's value is the <em>last</em> value for that key.
* Supports removal via the iterator, which removes the entire key from the underlying map.
*/
@Override
public Set<Entry<String, String>> entrySet() {
return new AbstractSet<>() {
@Override
public Iterator<Entry<String, String>> iterator() {
var inner = map.entrySet().iterator();
return new Iterator<>() {
@Override
public boolean hasNext() {
return inner.hasNext();
}

@Override
public Entry<String, String> next() {
var e = inner.next();
return Map.entry(e.getKey(), e.getValue().getLast());
}

@Override
public void remove() {
inner.remove();
}
};
}

@Override
public int size() {
return map.size();
}

@Override
public void clear() {
map.clear();
}
};
}
}
Loading
Loading