diff --git a/src/main/java/org/phoebus/channelfinder/common/SearchParameterMergerUtil.java b/src/main/java/org/phoebus/channelfinder/common/SearchParameterMergerUtil.java
new file mode 100644
index 00000000..9680c0ce
--- /dev/null
+++ b/src/main/java/org/phoebus/channelfinder/common/SearchParameterMergerUtil.java
@@ -0,0 +1,89 @@
+package org.phoebus.channelfinder.common;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+/**
+ * Utility class for merging search parameters from URL query string and JSON request body.
+ *
+ *
Merging strategy:
+ *
+ *
+ * - For regular search parameters (e.g., ~name, property names, ~tag): values from both URL
+ * and body are added as separate values under the same key in the MultiValueMap.
+ *
- For control parameters (~size, ~from, ~search_after, ~track_total_hits): URL values take
+ * precedence over body values (body values ignored if URL value exists).
+ *
+ */
+public final class SearchParameterMergerUtil {
+
+ private static final String CONTROL_SIZE = "~size";
+ private static final String CONTROL_FROM = "~from";
+ private static final String CONTROL_SEARCH_AFTER = "~search_after";
+ private static final String CONTROL_TRACK_TOTAL_HITS = "~track_total_hits";
+
+ private SearchParameterMergerUtil() {
+ // Utility class - private constructor
+ }
+
+ private static boolean isControlParameter(String key) {
+ return CONTROL_SIZE.equals(key)
+ || CONTROL_FROM.equals(key)
+ || CONTROL_SEARCH_AFTER.equals(key)
+ || CONTROL_TRACK_TOTAL_HITS.equals(key);
+ }
+
+ /**
+ * Merges URL request parameters with body parameters.
+ *
+ * URL parameters take precedence for control keys. For regular search parameters, body values
+ * are added as additional values for the same key in the MultiValueMap.
+ *
+ * @param urlParams URL query parameters (may be null or empty)
+ * @param bodyParams JSON request body as a map (may be null or empty)
+ * @return merged parameters as a MultiValueMap
+ */
+ public static MultiValueMap mergeParameters(
+ MultiValueMap urlParams, Map bodyParams) {
+ MultiValueMap merged = new LinkedMultiValueMap<>();
+
+ // Add URL parameters first
+ if (urlParams != null && !urlParams.isEmpty()) {
+ for (Map.Entry> entry : urlParams.entrySet()) {
+ merged.put(entry.getKey(), new LinkedList<>(entry.getValue()));
+ }
+ }
+
+ // Merge body parameters if present
+ if (bodyParams != null && !bodyParams.isEmpty()) {
+ mergeBodyParams(merged, bodyParams);
+ }
+
+ return merged;
+ }
+
+ private static void mergeBodyParams(
+ MultiValueMap merged, Map bodyParams) {
+ for (Map.Entry entry : bodyParams.entrySet()) {
+ String key = entry.getKey();
+ String bodyValue = entry.getValue();
+
+ if (bodyValue == null || bodyValue.trim().isEmpty()) {
+ continue; // Skip empty body values
+ }
+
+ if (isControlParameter(key)) {
+ // For control parameters, URL takes precedence
+ if (!merged.containsKey(key)) {
+ merged.set(key, bodyValue);
+ }
+ } else {
+ // For regular search parameters, add the body value to the same key.
+ merged.add(key, bodyValue);
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannel.java b/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannel.java
index 4295d710..700d954d 100644
--- a/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannel.java
+++ b/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannel.java
@@ -10,6 +10,7 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import java.util.List;
+import java.util.Map;
import org.phoebus.channelfinder.entity.Channel;
import org.phoebus.channelfinder.entity.SearchResult;
import org.springframework.util.MultiValueMap;
@@ -27,7 +28,10 @@ public interface IChannel {
@Operation(
summary = "Query channels",
description =
- "Query a collection of Channel instances based on tags, property values, and channel names.",
+ "Query a collection of Channel instances based on tags, property values, and channel names. "
+ + "Search parameters can be provided via URL query string or JSON request body, or both. "
+ + "URL parameters take precedence for control parameters (~size, ~from, ~search_after, ~track_total_hits). "
+ + "Regular search parameters from URL and body are combined as separate values in the query.",
operationId = "queryChannels",
tags = {"Channel"})
@ApiResponses(
@@ -49,12 +53,25 @@ public interface IChannel {
@GetMapping
List query(
@Parameter(description = SEARCH_PARAM_DESCRIPTION) @RequestParam
- MultiValueMap allRequestParams);
+ MultiValueMap allRequestParams,
+ @Parameter(
+ description =
+ "Optional JSON request body containing search parameters. Used to bypass URL length limitations.")
+ @RequestBody(required = false)
+ Map searchParamsBody);
+
+ // Backward-compatible overload when no request body is provided.
+ default List query(MultiValueMap allRequestParams) {
+ return query(allRequestParams, null);
+ }
@Operation(
summary = "Combined query for channels",
description =
- "Query for a collection of Channel instances and get a count and the first 10k hits.",
+ "Query for a collection of Channel instances and get a count and the first 10k hits. "
+ + "Search parameters can be provided via URL query string or JSON request body, or both. "
+ + "URL parameters take precedence for control parameters (~size, ~from, ~search_after, ~track_total_hits). "
+ + "Regular search parameters from URL and body are combined as separate values in the query.",
operationId = "combinedQueryChannels",
tags = {"Channel"})
@ApiResponses(
@@ -77,11 +94,25 @@ List query(
@GetMapping("/combined")
SearchResult combinedQuery(
@Parameter(description = SEARCH_PARAM_DESCRIPTION) @RequestParam
- MultiValueMap allRequestParams);
+ MultiValueMap allRequestParams,
+ @Parameter(
+ description =
+ "Optional JSON request body containing search parameters. Used to bypass URL length limitations.")
+ @RequestBody(required = false)
+ Map searchParamsBody);
+
+ // Backward-compatible overload when no request body is provided.
+ default SearchResult combinedQuery(MultiValueMap allRequestParams) {
+ return combinedQuery(allRequestParams, null);
+ }
@Operation(
summary = "Count channels matching query",
- description = "Get the number of channels matching the given query parameters.",
+ description =
+ "Get the number of channels matching the given query parameters. "
+ + "Search parameters can be provided via URL query string or JSON request body, or both. "
+ + "URL parameters take precedence for control parameters (~size, ~from, ~search_after, ~track_total_hits). "
+ + "Regular search parameters from URL and body are combined as separate values in the query.",
operationId = "countChannels",
tags = {"Channel"})
@ApiResponses(
@@ -98,7 +129,17 @@ SearchResult combinedQuery(
@GetMapping("/count")
long queryCount(
@Parameter(description = SEARCH_PARAM_DESCRIPTION) @RequestParam
- MultiValueMap allRequestParams);
+ MultiValueMap allRequestParams,
+ @Parameter(
+ description =
+ "Optional JSON request body containing search parameters. Used to bypass URL length limitations.")
+ @RequestBody(required = false)
+ Map searchParamsBody);
+
+ // Backward-compatible overload when no request body is provided.
+ default long queryCount(MultiValueMap allRequestParams) {
+ return queryCount(allRequestParams, null);
+ }
@Operation(
summary = "Get channel by name",
diff --git a/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannelScroll.java b/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannelScroll.java
index f0a77e4e..63f3e779 100644
--- a/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannelScroll.java
+++ b/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannelScroll.java
@@ -6,11 +6,13 @@
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import java.util.Map;
import org.phoebus.channelfinder.common.CFResourceDescriptors;
import org.phoebus.channelfinder.entity.Scroll;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
@@ -18,7 +20,11 @@ public interface IChannelScroll {
@Operation(
summary = "Scroll query for channels",
- description = "Retrieve a collection of Channel instances based on multi-parameter search.",
+ description =
+ "Retrieve a collection of Channel instances based on multi-parameter search. "
+ + "Search parameters can be provided via URL query string or JSON request body, or both. "
+ + "URL parameters take precedence for control parameters (~size, ~from, ~search_after, ~track_total_hits). "
+ + "Regular search parameters from URL and body are combined as separate values in the query.",
operationId = "scrollQueryChannels",
tags = {"ChannelScroll"})
@ApiResponses(
@@ -35,12 +41,25 @@ public interface IChannelScroll {
@GetMapping
Scroll query(
@Parameter(description = CFResourceDescriptors.SEARCH_PARAM_DESCRIPTION) @RequestParam
- MultiValueMap allRequestParams);
+ MultiValueMap allRequestParams,
+ @Parameter(
+ description =
+ "Optional JSON request body containing search parameters. Used to bypass URL length limitations.")
+ @RequestBody(required = false)
+ Map searchParamsBody);
+
+ // Backward-compatible overload when no request body is provided.
+ default Scroll query(MultiValueMap allRequestParams) {
+ return query(allRequestParams, null);
+ }
@Operation(
summary = "Scroll query by scrollId",
description =
- "Retrieve a collection of Channel instances using a scrollId and search parameters.",
+ "Retrieve a collection of Channel instances using a scrollId and search parameters. "
+ + "Search parameters can be provided via URL query string or JSON request body, or both. "
+ + "URL parameters take precedence for control parameters (~size, ~from, ~search_after, ~track_total_hits). "
+ + "Regular search parameters from URL and body are combined as separate values in the query.",
operationId = "scrollQueryById",
tags = {"ChannelScroll"})
@ApiResponses(
@@ -59,5 +78,15 @@ Scroll query(
@Parameter(description = "Scroll ID from previous query") @PathVariable("scrollId")
String scrollId,
@Parameter(description = CFResourceDescriptors.SEARCH_PARAM_DESCRIPTION) @RequestParam
- MultiValueMap searchParameters);
+ MultiValueMap searchParameters,
+ @Parameter(
+ description =
+ "Optional JSON request body containing search parameters. Used to bypass URL length limitations.")
+ @RequestBody(required = false)
+ Map searchParamsBody);
+
+ // Backward-compatible overload when no request body is provided.
+ default Scroll query(String scrollId, MultiValueMap searchParameters) {
+ return query(scrollId, searchParameters, null);
+ }
}
diff --git a/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelController.java b/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelController.java
index ab094ad1..bf3501a4 100644
--- a/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelController.java
+++ b/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelController.java
@@ -1,6 +1,8 @@
package org.phoebus.channelfinder.web.v0.controller;
import java.util.List;
+import java.util.Map;
+import org.phoebus.channelfinder.common.SearchParameterMergerUtil;
import org.phoebus.channelfinder.entity.Channel;
import org.phoebus.channelfinder.entity.SearchResult;
import org.phoebus.channelfinder.service.ChannelService;
@@ -22,18 +24,27 @@ public ChannelController(ChannelService channelService) {
}
@Override
- public List query(MultiValueMap allRequestParams) {
- return channelService.query(allRequestParams);
+ public List query(
+ MultiValueMap allRequestParams, Map searchParamsBody) {
+ MultiValueMap mergedParams =
+ SearchParameterMergerUtil.mergeParameters(allRequestParams, searchParamsBody);
+ return channelService.query(mergedParams);
}
@Override
- public SearchResult combinedQuery(MultiValueMap allRequestParams) {
- return channelService.combinedQuery(allRequestParams);
+ public SearchResult combinedQuery(
+ MultiValueMap allRequestParams, Map searchParamsBody) {
+ MultiValueMap mergedParams =
+ SearchParameterMergerUtil.mergeParameters(allRequestParams, searchParamsBody);
+ return channelService.combinedQuery(mergedParams);
}
@Override
- public long queryCount(MultiValueMap allRequestParams) {
- return channelService.queryCount(allRequestParams);
+ public long queryCount(
+ MultiValueMap allRequestParams, Map searchParamsBody) {
+ MultiValueMap mergedParams =
+ SearchParameterMergerUtil.mergeParameters(allRequestParams, searchParamsBody);
+ return channelService.queryCount(mergedParams);
}
@Override
diff --git a/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelScrollController.java b/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelScrollController.java
index fe8c6b9d..7451b863 100644
--- a/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelScrollController.java
+++ b/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelScrollController.java
@@ -1,5 +1,7 @@
package org.phoebus.channelfinder.web.v0.controller;
+import java.util.Map;
+import org.phoebus.channelfinder.common.SearchParameterMergerUtil;
import org.phoebus.channelfinder.entity.Scroll;
import org.phoebus.channelfinder.service.ChannelScrollService;
import org.phoebus.channelfinder.web.v0.api.IChannelScroll;
@@ -20,12 +22,20 @@ public ChannelScrollController(ChannelScrollService channelScrollService) {
}
@Override
- public Scroll query(MultiValueMap allRequestParams) {
- return channelScrollService.search(null, allRequestParams);
+ public Scroll query(
+ MultiValueMap allRequestParams, Map searchParamsBody) {
+ MultiValueMap mergedParams =
+ SearchParameterMergerUtil.mergeParameters(allRequestParams, searchParamsBody);
+ return channelScrollService.search(null, mergedParams);
}
@Override
- public Scroll query(String scrollId, MultiValueMap searchParameters) {
- return channelScrollService.search(scrollId, searchParameters);
+ public Scroll query(
+ String scrollId,
+ MultiValueMap searchParameters,
+ Map searchParamsBody) {
+ MultiValueMap mergedParams =
+ SearchParameterMergerUtil.mergeParameters(searchParameters, searchParamsBody);
+ return channelScrollService.search(scrollId, mergedParams);
}
}
diff --git a/src/test/java/org/phoebus/channelfinder/common/SearchParameterMergerUtilTest.java b/src/test/java/org/phoebus/channelfinder/common/SearchParameterMergerUtilTest.java
new file mode 100644
index 00000000..5bc721e3
--- /dev/null
+++ b/src/test/java/org/phoebus/channelfinder/common/SearchParameterMergerUtilTest.java
@@ -0,0 +1,185 @@
+package org.phoebus.channelfinder.common;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+/** Unit tests for SearchParameterMergerUtil. */
+class SearchParameterMergerUtilTest {
+
+ @Test
+ void testMergeParameters_urlOnly() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~name", "name1");
+ urlParams.add("prop", "value1");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, null);
+
+ assertEquals(2, merged.size());
+ assertEquals("name1", merged.getFirst("~name"));
+ assertEquals("value1", merged.getFirst("prop"));
+ }
+
+ @Test
+ void testMergeParameters_bodyOnly() {
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~name", "name2");
+ bodyParams.put("prop", "value2");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(new LinkedMultiValueMap<>(), bodyParams);
+
+ assertEquals(2, merged.size());
+ assertEquals("name2", merged.getFirst("~name"));
+ assertEquals("value2", merged.getFirst("prop"));
+ }
+
+ @Test
+ void testMergeParameters_bothUrlAndBody_mergeRegularParams() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~name", "name1");
+ urlParams.add("prop", "value1");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~name", "name2");
+ bodyParams.put("prop", "value2");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // Regular params should be added as separate values in MultiValueMap
+ assertEquals(2, merged.get("~name").size());
+ assertTrue(merged.get("~name").contains("name1"));
+ assertTrue(merged.get("~name").contains("name2"));
+ assertEquals(2, merged.get("prop").size());
+ assertTrue(merged.get("prop").contains("value1"));
+ assertTrue(merged.get("prop").contains("value2"));
+ }
+
+ @Test
+ void testMergeParameters_controlParams_urlTakesPrecedence() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~size", "100");
+ urlParams.add("~from", "0");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~size", "50");
+ bodyParams.put("~from", "10");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // Control params: URL should take precedence
+ assertEquals("100", merged.getFirst("~size"));
+ assertEquals("0", merged.getFirst("~from"));
+ }
+
+ @Test
+ void testMergeParameters_controlParams_bodyUsedWhenUrlMissing() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~name", "name1");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~size", "50");
+ bodyParams.put("~from", "10");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // Control params from body should be used if not in URL
+ assertEquals("50", merged.getFirst("~size"));
+ assertEquals("10", merged.getFirst("~from"));
+ }
+
+ @Test
+ void testMergeParameters_searchAfterControl() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~search_after", "abc123");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~search_after", "def456");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // URL should take precedence
+ assertEquals("abc123", merged.getFirst("~search_after"));
+ }
+
+ @Test
+ void testMergeParameters_trackTotalHitsControl() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~track_total_hits", "true");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~track_total_hits", "false");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // URL should take precedence
+ assertEquals("true", merged.getFirst("~track_total_hits"));
+ }
+
+ @Test
+ void testMergeParameters_emptyBodyValues() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~name", "name1");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~name", "name2");
+ bodyParams.put("prop", "");
+ bodyParams.put("tag", " ");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // Empty/whitespace values should be skipped
+ assertEquals(2, merged.get("~name").size());
+ assertTrue(merged.get("~name").contains("name1"));
+ assertTrue(merged.get("~name").contains("name2"));
+ assertFalse(merged.containsKey("prop"));
+ assertFalse(merged.containsKey("tag"));
+ }
+
+ @Test
+ void testMergeParameters_nullInputs() {
+ MultiValueMap merged = SearchParameterMergerUtil.mergeParameters(null, null);
+
+ assertTrue(merged.isEmpty());
+ }
+
+ @Test
+ void testMergeParameters_complexMix() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~name", "ch1");
+ urlParams.add("~size", "100");
+ urlParams.add("prop1", "val1");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~name", "ch2");
+ bodyParams.put("~size", "50");
+ bodyParams.put("prop1", "val2");
+ bodyParams.put("prop2", "val3");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // Regular params merged as multiple values, control params URL takes precedence
+ assertEquals(2, merged.get("~name").size());
+ assertTrue(merged.get("~name").contains("ch1"));
+ assertTrue(merged.get("~name").contains("ch2"));
+ assertEquals("100", merged.getFirst("~size"));
+ assertEquals(2, merged.get("prop1").size());
+ assertTrue(merged.get("prop1").contains("val1"));
+ assertTrue(merged.get("prop1").contains("val2"));
+ assertEquals("val3", merged.getFirst("prop2"));
+ }
+}