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: + * + *

+ */ +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")); + } +}