From 5c9323cfed1fc7fe697eb0da9ab89aad8234c19b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:23:52 +0200 Subject: [PATCH] feat(search): accept attr= multi-value query param on /executions GET Add a repeatable attr query parameter to the GET /executions endpoint that parses key-only (exists check) and key:value (exact or wildcard-via-*) filters. Invalid keys are mapped to HTTP 400 via ResponseStatusException. The POST /executions/search path already honoured attributeFilters from the request body via the Jackson canonical ctor; an IT now proves it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/controller/SearchController.java | 37 +++++++- .../app/controller/SearchControllerIT.java | 94 +++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java index 02cb16e5..33cfd966 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java @@ -4,6 +4,7 @@ import com.cameleer.server.app.web.EnvPath; import com.cameleer.server.core.admin.AppSettings; import com.cameleer.server.core.admin.AppSettingsRepository; import com.cameleer.server.core.runtime.Environment; +import com.cameleer.server.core.search.AttributeFilter; import com.cameleer.server.core.search.ExecutionStats; import com.cameleer.server.core.search.ExecutionSummary; import com.cameleer.server.core.search.SearchRequest; @@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -57,11 +59,20 @@ public class SearchController { @RequestParam(name = "agentId", required = false) String instanceId, @RequestParam(required = false) String processorType, @RequestParam(required = false) String application, + @RequestParam(name = "attr", required = false) List attr, @RequestParam(defaultValue = "0") int offset, @RequestParam(defaultValue = "50") int limit, @RequestParam(required = false) String sortField, @RequestParam(required = false) String sortDir) { + List attributeFilters; + try { + attributeFilters = parseAttrParams(attr); + } catch (IllegalArgumentException e) { + throw new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.BAD_REQUEST, e.getMessage(), e); + } + SearchRequest request = new SearchRequest( status, timeFrom, timeTo, null, null, @@ -72,12 +83,36 @@ public class SearchController { offset, limit, sortField, sortDir, null, - env.slug() + env.slug(), + attributeFilters ); return ResponseEntity.ok(searchService.search(request)); } + /** + * Parses {@code attr} query params of the form {@code key} (key-only) or {@code key:value} + * (exact or wildcard via {@code *}). Splits on the first {@code :}; later colons are part of + * the value. Blank / null list → empty result. Key validation is delegated to + * {@link AttributeFilter}'s compact constructor, which throws {@link IllegalArgumentException} + * on invalid keys (mapped to 400 by the caller). + */ + static List parseAttrParams(List raw) { + if (raw == null || raw.isEmpty()) return List.of(); + List out = new ArrayList<>(raw.size()); + for (String entry : raw) { + if (entry == null || entry.isBlank()) continue; + int colon = entry.indexOf(':'); + if (colon < 0) { + out.add(new AttributeFilter(entry.trim(), null)); + } else { + out.add(new AttributeFilter(entry.substring(0, colon).trim(), + entry.substring(colon + 1))); + } + } + return out; + } + @PostMapping("/executions/search") @Operation(summary = "Advanced search with all filters", description = "Env from the path overrides any environment field in the body.") diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java index 7a7296e7..95cde923 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java @@ -166,6 +166,42 @@ class SearchControllerIT extends AbstractPostgresIT { """, i, i, i, i, i)); } + // Executions 11-12: carry structured attributes used by the attribute-filter tests. + ingest(""" + { + "exchangeId": "ex-search-attr-1", + "applicationId": "test-group", + "instanceId": "test-agent-search-it", + "routeId": "search-route-attr-1", + "correlationId": "corr-attr-alpha", + "status": "COMPLETED", + "startTime": "2026-03-12T10:00:00Z", + "endTime": "2026-03-12T10:00:00.050Z", + "durationMs": 50, + "attributes": {"order": "12345", "tenant": "acme"}, + "chunkSeq": 0, + "final": true, + "processors": [] + } + """); + ingest(""" + { + "exchangeId": "ex-search-attr-2", + "applicationId": "test-group", + "instanceId": "test-agent-search-it", + "routeId": "search-route-attr-2", + "correlationId": "corr-attr-beta", + "status": "COMPLETED", + "startTime": "2026-03-12T10:01:00Z", + "endTime": "2026-03-12T10:01:00.050Z", + "durationMs": 50, + "attributes": {"order": "99999"}, + "chunkSeq": 0, + "final": true, + "processors": [] + } + """); + // Wait for async ingestion + search indexing via REST (no raw SQL). // Probe the last seeded execution to avoid false positives from // other test classes that may have written into the shared CH tables. @@ -174,6 +210,11 @@ class SearchControllerIT extends AbstractPostgresIT { JsonNode body = objectMapper.readTree(r.getBody()); assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1); }); + await().atMost(30, SECONDS).untilAsserted(() -> { + ResponseEntity r = searchGet("?correlationId=corr-attr-beta"); + JsonNode body = objectMapper.readTree(r.getBody()); + assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1); + }); } @Test @@ -371,6 +412,59 @@ class SearchControllerIT extends AbstractPostgresIT { assertThat(body.get("limit").asInt()).isEqualTo(50); } + @Test + void attrParam_exactMatch_filtersToMatchingExecution() throws Exception { + ResponseEntity response = searchGet("?attr=order:12345&correlationId=corr-attr-alpha"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("total").asLong()).isEqualTo(1); + assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha"); + } + + @Test + void attrParam_keyOnly_matchesAnyExecutionCarryingTheKey() throws Exception { + ResponseEntity response = searchGet("?attr=tenant&correlationId=corr-attr-alpha"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("total").asLong()).isEqualTo(1); + assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha"); + } + + @Test + void attrParam_multipleValues_produceIntersection() throws Exception { + // order:99999 AND tenant=* should yield zero — exec-attr-2 has order=99999 but no tenant. + ResponseEntity response = searchGet("?attr=order:99999&attr=tenant"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("total").asLong()).isZero(); + } + + @Test + void attrParam_invalidKey_returns400() throws Exception { + ResponseEntity response = searchGet("?attr=bad%20key:x"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void attributeFilters_inPostBody_filtersCorrectly() throws Exception { + ResponseEntity response = searchPost(""" + { + "attributeFilters": [ + {"key": "order", "value": "12345"} + ], + "correlationId": "corr-attr-alpha" + } + """); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("total").asLong()).isEqualTo(1); + assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha"); + } + // --- Helper methods --- private void ingest(String json) {