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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-24 10:23:52 +02:00
parent 2dcbd5a772
commit 5c9323cfed
2 changed files with 130 additions and 1 deletions

View File

@@ -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<String> attr,
@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortDir) {
List<AttributeFilter> 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<AttributeFilter> parseAttrParams(List<String> raw) {
if (raw == null || raw.isEmpty()) return List.of();
List<AttributeFilter> 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.")

View File

@@ -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<String> 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<String> 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<String> 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<String> 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<String> response = searchGet("?attr=bad%20key:x");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void attributeFilters_inPostBody_filtersCorrectly() throws Exception {
ResponseEntity<String> 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) {