diff --git a/docs/changelog/114168.yaml b/docs/changelog/114168.yaml new file mode 100644 index 0000000000000..58f1ab7110e7d --- /dev/null +++ b/docs/changelog/114168.yaml @@ -0,0 +1,5 @@ +pr: 114168 +summary: Add a query rules tester API call +area: Relevance +type: enhancement +issues: [] diff --git a/docs/reference/query-rules/apis/index.asciidoc b/docs/reference/query-rules/apis/index.asciidoc index 53d5fc3dc4eee..fbeb477acacb5 100644 --- a/docs/reference/query-rules/apis/index.asciidoc +++ b/docs/reference/query-rules/apis/index.asciidoc @@ -23,6 +23,7 @@ Use the following APIs to manage query rulesets: * <> * <> * <> +* preview:[] <> include::put-query-ruleset.asciidoc[] include::get-query-ruleset.asciidoc[] @@ -31,4 +32,5 @@ include::delete-query-ruleset.asciidoc[] include::put-query-rule.asciidoc[] include::get-query-rule.asciidoc[] include::delete-query-rule.asciidoc[] +include::test-query-ruleset.asciidoc[] diff --git a/docs/reference/query-rules/apis/test-query-ruleset.asciidoc b/docs/reference/query-rules/apis/test-query-ruleset.asciidoc new file mode 100644 index 0000000000000..4a670645cea6e --- /dev/null +++ b/docs/reference/query-rules/apis/test-query-ruleset.asciidoc @@ -0,0 +1,133 @@ +[role="xpack"] +[[test-query-ruleset]] +=== Test query ruleset + +++++ +Tests query ruleset +++++ + +Evaluates match criteria against a query ruleset to identify the rules that would match that criteria. + +preview::[] + +[[test-query-ruleset-request]] +==== {api-request-title} + +`POST _query_rules//_test` + +[[test-query-ruleset-prereq]] +==== {api-prereq-title} + +Requires the `manage_search_query_rules` privilege. + +[[test-query-ruleset-path-params]] +==== {api-path-parms-title} + +``:: +(Required, string) + +[[test-query-rule-request-body]] +==== {api-request-body-title} + +`match_criteria`:: +(Required, object) Defines the match criteria to apply to rules in the given query ruleset. +Match criteria should match the keys defined in the `criteria.metadata` field of the rule. + +[[test-query-ruleset-response-codes]] +==== {api-response-codes-title} + +`400`:: +The `ruleset_id` or `match_criteria` were not provided. + +`404` (Missing resources):: +No query ruleset matching `ruleset_id` could be found. + +[[test-query-ruleset-example]] +==== {api-examples-title} + +To test a ruleset, provide the match criteria that you want to test against: + +//// + +[source,console] +-------------------------------------------------- +PUT _query_rules/my-ruleset +{ + "rules": [ + { + "rule_id": "my-rule1", + "type": "pinned", + "criteria": [ + { + "type": "contains", + "metadata": "query_string", + "values": [ "pugs", "puggles" ] + } + ], + "actions": { + "ids": [ + "id1", + "id2" + ] + } + }, + { + "rule_id": "my-rule2", + "type": "pinned", + "criteria": [ + { + "type": "fuzzy", + "metadata": "query_string", + "values": [ "rescue dogs" ] + } + ], + "actions": { + "docs": [ + { + "_index": "index1", + "_id": "id3" + }, + { + "_index": "index2", + "_id": "id4" + } + ] + } + } + ] +} +-------------------------------------------------- +// TESTSETUP + +[source,console] +-------------------------------------------------- +DELETE _query_rules/my-ruleset +-------------------------------------------------- +// TEARDOWN + +//// + +[source,console] +---- +POST _query_rules/my-ruleset/_test +{ + "match_criteria": { + "query_string": "puggles" + } +} +---- + +A sample response: + +[source,console-result] +---- +{ + "total_matched_rules": 1, + "matched_rules": [ + { + "ruleset_id": "my-ruleset", + "rule_id": "my-rule1" + } + ] +} +---- diff --git a/rest-api-spec/src/main/resources/rest-api-spec/api/query_rules.test.json b/rest-api-spec/src/main/resources/rest-api-spec/api/query_rules.test.json new file mode 100644 index 0000000000000..c82b45771ac7f --- /dev/null +++ b/rest-api-spec/src/main/resources/rest-api-spec/api/query_rules.test.json @@ -0,0 +1,38 @@ +{ + "query_rules.test": { + "documentation": { + "url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/test-query-ruleset.html", + "description": "Tests a query ruleset to identify the rules that would match input criteria" + }, + "stability": "experimental", + "visibility": "public", + "headers": { + "accept": [ + "application/json" + ], + "content_type": [ + "application/json" + ] + }, + "url": { + "paths": [ + { + "path": "/_query_rules/{ruleset_id}/_test", + "methods": [ + "POST" + ], + "parts": { + "ruleset_id": { + "type": "string", + "description": "The unique identifier of the ruleset to test." + } + } + } + ] + }, + "body": { + "description": "The match criteria to test against the ruleset", + "required": true + } + } +} diff --git a/server/src/main/java/org/elasticsearch/TransportVersions.java b/server/src/main/java/org/elasticsearch/TransportVersions.java index fcd87ae83d00a..e4cceb6977adb 100644 --- a/server/src/main/java/org/elasticsearch/TransportVersions.java +++ b/server/src/main/java/org/elasticsearch/TransportVersions.java @@ -242,6 +242,7 @@ static TransportVersion def(int id) { public static final TransportVersion ESQL_CACHED_STRING_SERIALIZATION = def(8_766_00_0); public static final TransportVersion CHUNK_SENTENCE_OVERLAP_SETTING_ADDED = def(8_767_00_0); public static final TransportVersion OPT_IN_ESQL_CCS_EXECUTION_INFO = def(8_768_00_0); + public static final TransportVersion QUERY_RULE_TEST_API = def(8_769_00_0); /* * STOP! READ THIS FIRST! No, really, diff --git a/x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/70_query_rule_test.yml b/x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/70_query_rule_test.yml new file mode 100644 index 0000000000000..016d9f10fe77f --- /dev/null +++ b/x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/70_query_rule_test.yml @@ -0,0 +1,252 @@ +setup: + - requires: + cluster_features: [ "query_rules.test" ] + reason: Introduced in 8.16.0 + + - do: + query_rules.put_ruleset: + ruleset_id: test-ruleset + body: + rules: + - rule_id: rule1 + type: pinned + criteria: + - type: exact + metadata: query_string + values: [ search ] + actions: + ids: + - 'doc1' + - rule_id: rule2 + type: pinned + criteria: + - type: exact + metadata: query_string + values: [ ui ] + actions: + docs: + - '_index': 'test-index1' + '_id': 'doc2' + - rule_id: rule3 + type: pinned + criteria: + - type: contains + metadata: query_string + values: [ kibana, logstash ] + actions: + ids: + - 'doc2' + - 'doc3' + - rule_id: rule4 + type: pinned + criteria: + - type: exact + metadata: query_string + values: [ ops ] + actions: + ids: + - 'doc7' + - rule_id: rule5 + type: exclude + criteria: + - type: exact + metadata: query_string + values: [ search ] + actions: + ids: + - 'doc8' + +--- +teardown: + - do: + query_rules.delete_ruleset: + ruleset_id: test-ruleset + ignore: 404 + + - do: + query_rules.delete_ruleset: + ruleset_id: combined-ruleset + ignore: 404 + + - do: + query_rules.delete_ruleset: + ruleset_id: double-jeopardy-ruleset + ignore: 404 + +--- +"Test query rules, specifying a ruleset that does not exist": + - do: + catch: /resource_not_found_exception/ + query_rules.test: + ruleset_id: nonexistent-ruleset + body: + match_criteria: + foo: bar + + +--- +"Test query rules with an empty body": + - do: + catch: bad_request + query_rules.test: + ruleset_id: nonexistent-ruleset + body: { } + +--- +"Test query rules with an ID match": + + - do: + query_rules.test: + ruleset_id: test-ruleset + body: + match_criteria: + query_string: search + + - match: { total_matched_rules: 2 } + - match: { matched_rules.0.rule_id: 'rule1' } + - match: { matched_rules.1.rule_id: 'rule5' } + +--- +"As a user, test query rules with an ID match": + - skip: + features: headers + + - do: + catch: forbidden + headers: { Authorization: "Basic ZW50c2VhcmNoLXVzZXI6ZW50c2VhcmNoLXVzZXItcGFzc3dvcmQ=" } # user + query_rules.test: + ruleset_id: test-ruleset + body: + match_criteria: + query_string: search + +--- +"Test query rules with a doc match": + + - do: + query_rules.test: + ruleset_id: test-ruleset + body: + match_criteria: + query_string: ui + + - match: { total_matched_rules: 1 } + - match: { matched_rules.0.rule_id: 'rule2' } + +--- +"As a user, test query rules with a doc match": + - skip: + features: headers + + - do: + catch: forbidden + headers: { Authorization: "Basic ZW50c2VhcmNoLXVzZXI6ZW50c2VhcmNoLXVzZXItcGFzc3dvcmQ=" } # user + query_rules.test: + ruleset_id: test-ruleset + body: + match_criteria: + query_string: ui + +--- +"Test query rules with no matching rules": + + - do: + query_rules.test: + ruleset_id: test-ruleset + body: + match_criteria: + query_string: no-match + + - match: { total_matched_rules: 0 } + +--- +"Test rules where the same ID is both pinned and excluded": + - do: + query_rules.put_ruleset: + ruleset_id: double-jeopardy-ruleset + body: + rules: + - rule_id: rule1 + type: pinned + criteria: + - type: exact + metadata: foo + values: [ bar ] + actions: + ids: + - 'doc8' + - rule_id: rule2 + type: exclude + criteria: + - type: exact + metadata: foo + values: [ bar ] + actions: + ids: + - 'doc8' + + - do: + query_rules.test: + ruleset_id: double-jeopardy-ruleset + body: + match_criteria: + foo: bar + + - match: { total_matched_rules: 2 } + - match: { matched_rules.0.rule_id: 'rule1' } + - match: { matched_rules.1.rule_id: 'rule2' } + +--- +"Perform a rule query over a ruleset with combined numeric and text rule matching": + + - do: + query_rules.put_ruleset: + ruleset_id: combined-ruleset + body: + rules: + - rule_id: rule1 + type: pinned + criteria: + - type: exact + metadata: foo + values: [ bar ] + actions: + ids: + - 'doc1' + - rule_id: rule2 + type: pinned + criteria: + - type: lte + metadata: foo + values: [ 100 ] + actions: + ids: + - 'doc2' + - do: + query_rules.test: + ruleset_id: combined-ruleset + body: + match_criteria: + foo: 100 + + - match: { total_matched_rules: 1 } + - match: { matched_rules.0.rule_id: 'rule2' } + + - do: + query_rules.test: + ruleset_id: combined-ruleset + body: + match_criteria: + foo: bar + + - match: { total_matched_rules: 1 } + - match: { matched_rules.0.rule_id: 'rule1' } + + - do: + query_rules.test: + ruleset_id: combined-ruleset + body: + match_criteria: + foo: baz + + - match: { total_matched_rules: 0 } diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearch.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearch.java index bdd4cae3dda81..d5aef3b8808e8 100644 --- a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearch.java +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearch.java @@ -165,6 +165,8 @@ import org.elasticsearch.xpack.application.rules.action.RestListQueryRulesetsAction; import org.elasticsearch.xpack.application.rules.action.RestPutQueryRuleAction; import org.elasticsearch.xpack.application.rules.action.RestPutQueryRulesetAction; +import org.elasticsearch.xpack.application.rules.action.RestTestQueryRulesetAction; +import org.elasticsearch.xpack.application.rules.action.TestQueryRulesetAction; import org.elasticsearch.xpack.application.rules.action.TransportDeleteQueryRuleAction; import org.elasticsearch.xpack.application.rules.action.TransportDeleteQueryRulesetAction; import org.elasticsearch.xpack.application.rules.action.TransportGetQueryRuleAction; @@ -172,6 +174,7 @@ import org.elasticsearch.xpack.application.rules.action.TransportListQueryRulesetsAction; import org.elasticsearch.xpack.application.rules.action.TransportPutQueryRuleAction; import org.elasticsearch.xpack.application.rules.action.TransportPutQueryRulesetAction; +import org.elasticsearch.xpack.application.rules.action.TransportTestQueryRulesetAction; import org.elasticsearch.xpack.application.search.SearchApplicationIndexService; import org.elasticsearch.xpack.application.search.action.DeleteSearchApplicationAction; import org.elasticsearch.xpack.application.search.action.GetSearchApplicationAction; @@ -266,6 +269,7 @@ protected XPackLicenseState getLicenseState() { new ActionHandler<>(DeleteQueryRuleAction.INSTANCE, TransportDeleteQueryRuleAction.class), new ActionHandler<>(GetQueryRuleAction.INSTANCE, TransportGetQueryRuleAction.class), new ActionHandler<>(PutQueryRuleAction.INSTANCE, TransportPutQueryRuleAction.class), + new ActionHandler<>(TestQueryRulesetAction.INSTANCE, TransportTestQueryRulesetAction.class), usageAction, infoAction @@ -373,7 +377,8 @@ public List getRestHandlers( new RestPutQueryRulesetAction(getLicenseState()), new RestDeleteQueryRuleAction(getLicenseState()), new RestGetQueryRuleAction(getLicenseState()), - new RestPutQueryRuleAction(getLicenseState()) + new RestPutQueryRuleAction(getLicenseState()), + new RestTestQueryRulesetAction(getLicenseState()) ) ); diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearchFeatures.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearchFeatures.java index 81e072479d402..174bcbe886dfb 100644 --- a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearchFeatures.java +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearchFeatures.java @@ -14,8 +14,17 @@ import org.elasticsearch.xpack.application.connector.ConnectorTemplateRegistry; import java.util.Map; +import java.util.Set; + +import static org.elasticsearch.xpack.application.rules.action.TestQueryRulesetAction.QUERY_RULES_TEST_API; public class EnterpriseSearchFeatures implements FeatureSpecification { + + @Override + public Set getFeatures() { + return Set.of(QUERY_RULES_TEST_API); + } + @Override public Map getHistoricalFeatures() { return Map.of( diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/QueryRule.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/QueryRule.java index 0ecb35531ac09..c14bb8e9a4ec9 100644 --- a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/QueryRule.java +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/QueryRule.java @@ -331,12 +331,8 @@ public AppliedQueryRules applyRule(AppliedQueryRules appliedRules, Map identifyMatchingDocs(Map matchCriteria) { - List matchingDocs = new ArrayList<>(); + public boolean isRuleMatch(Map matchCriteria) { Boolean isRuleMatch = null; - - // All specified criteria in a rule must match for the rule to be applied for (QueryRuleCriteria criterion : criteria) { for (String match : matchCriteria.keySet()) { final Object matchValue = matchCriteria.get(match); @@ -349,8 +345,13 @@ private List identifyMatchingDocs(Map matchCr } } } + return isRuleMatch != null && isRuleMatch; + } - if (isRuleMatch != null && isRuleMatch) { + @SuppressWarnings("unchecked") + private List identifyMatchingDocs(Map matchCriteria) { + List matchingDocs = new ArrayList<>(); + if (isRuleMatch(matchCriteria)) { if (actions.containsKey(IDS_FIELD.getPreferredName())) { matchingDocs.addAll( ((List) actions.get(IDS_FIELD.getPreferredName())).stream().map(id -> new SpecifiedDocument(null, id)).toList() diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/GetQueryRulesetAction.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/GetQueryRulesetAction.java index f7e6f166cf53f..1d5ba878264f7 100644 --- a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/GetQueryRulesetAction.java +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/GetQueryRulesetAction.java @@ -31,7 +31,8 @@ public class GetQueryRulesetAction { - public static final String NAME = "cluster:admin/xpack/query_rules/get"; + public static final ActionType TYPE = new ActionType<>("cluster:admin/xpack/query_rules/get"); + public static final String NAME = TYPE.name(); public static final ActionType INSTANCE = new ActionType<>(NAME); private GetQueryRulesetAction() {/* no instances */} diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestTestQueryRulesetAction.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestTestQueryRulesetAction.java new file mode 100644 index 0000000000000..b6e02b3c37262 --- /dev/null +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestTestQueryRulesetAction.java @@ -0,0 +1,53 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.application.rules.action; + +import org.elasticsearch.client.internal.node.NodeClient; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.Scope; +import org.elasticsearch.rest.ServerlessScope; +import org.elasticsearch.rest.action.RestToXContentListener; +import org.elasticsearch.xpack.application.EnterpriseSearch; +import org.elasticsearch.xpack.application.EnterpriseSearchBaseRestHandler; +import org.elasticsearch.xpack.application.utils.LicenseUtils; + +import java.io.IOException; +import java.util.List; + +import static org.elasticsearch.rest.RestRequest.Method.POST; + +@ServerlessScope(Scope.PUBLIC) +public class RestTestQueryRulesetAction extends EnterpriseSearchBaseRestHandler { + public RestTestQueryRulesetAction(XPackLicenseState licenseState) { + super(licenseState, LicenseUtils.Product.QUERY_RULES); + } + + @Override + public String getName() { + return "query_ruleset_test_action"; + } + + @Override + public List routes() { + return List.of(new Route(POST, "/" + EnterpriseSearch.QUERY_RULES_API_ENDPOINT + "/{ruleset_id}" + "/_test")); + } + + @Override + protected RestChannelConsumer innerPrepareRequest(RestRequest restRequest, NodeClient client) throws IOException { + final String rulesetId = restRequest.param("ruleset_id"); + TestQueryRulesetAction.Request request = null; + if (restRequest.hasContent()) { + try (var parser = restRequest.contentParser()) { + request = TestQueryRulesetAction.Request.parse(parser, rulesetId); + } + } + final TestQueryRulesetAction.Request finalRequest = request; + return channel -> client.execute(TestQueryRulesetAction.INSTANCE, finalRequest, new RestToXContentListener<>(channel)); + } +} diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetAction.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetAction.java new file mode 100644 index 0000000000000..28f4a3b38dd59 --- /dev/null +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetAction.java @@ -0,0 +1,212 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.application.rules.action; + +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.action.ActionResponse; +import org.elasticsearch.action.ActionType; +import org.elasticsearch.action.IndicesRequest; +import org.elasticsearch.action.support.IndicesOptions; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.features.NodeFeature; +import org.elasticsearch.xcontent.ConstructingObjectParser; +import org.elasticsearch.xcontent.ParseField; +import org.elasticsearch.xcontent.ToXContentObject; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xcontent.XContentParser; +import org.elasticsearch.xpack.application.rules.QueryRulesIndexService; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static org.elasticsearch.action.ValidateActions.addValidationError; +import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg; +import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg; + +public class TestQueryRulesetAction { + + public static final NodeFeature QUERY_RULES_TEST_API = new NodeFeature("query_rules.test"); + + // TODO - We'd like to transition this to require less stringent permissions + public static final ActionType TYPE = new ActionType<>("cluster:admin/xpack/query_rules/test"); + + public static final String NAME = TYPE.name(); + public static final ActionType INSTANCE = new ActionType<>(NAME); + + private TestQueryRulesetAction() {/* no instances */} + + public static class Request extends ActionRequest implements ToXContentObject, IndicesRequest { + private final String rulesetId; + private final Map matchCriteria; + + private static final ParseField RULESET_ID_FIELD = new ParseField("ruleset_id"); + private static final ParseField MATCH_CRITERIA_FIELD = new ParseField("match_criteria"); + + public Request(StreamInput in) throws IOException { + super(in); + this.rulesetId = in.readString(); + this.matchCriteria = in.readGenericMap(); + } + + public Request(String rulesetId, Map matchCriteria) { + this.rulesetId = rulesetId; + this.matchCriteria = matchCriteria; + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = null; + + if (Strings.isNullOrEmpty(rulesetId)) { + validationException = addValidationError("ruleset_id missing", validationException); + } + + return validationException; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(rulesetId); + out.writeGenericMap(matchCriteria); + } + + public String rulesetId() { + return rulesetId; + } + + public Map matchCriteria() { + return matchCriteria; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Request request = (Request) o; + return Objects.equals(rulesetId, request.rulesetId) && Objects.equals(matchCriteria, request.matchCriteria); + } + + @Override + public int hashCode() { + return Objects.hash(rulesetId, matchCriteria); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field(RULESET_ID_FIELD.getPreferredName(), rulesetId); + builder.startObject(MATCH_CRITERIA_FIELD.getPreferredName()); + builder.mapContents(matchCriteria); + builder.endObject(); + builder.endObject(); + return builder; + } + + private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "test_query_ruleset_request", + false, + (p, name) -> { + @SuppressWarnings("unchecked") + Map matchCriteria = (Map) p[0]; + return new Request(name, matchCriteria); + } + + ); + static { + PARSER.declareObject(constructorArg(), (p, c) -> p.map(), MATCH_CRITERIA_FIELD); + PARSER.declareString(optionalConstructorArg(), RULESET_ID_FIELD); // Required for parsing + } + + public static Request parse(XContentParser parser, String name) { + return PARSER.apply(parser, name); + } + + @Override + public String[] indices() { + return new String[] { QueryRulesIndexService.QUERY_RULES_ALIAS_NAME }; + } + + @Override + public IndicesOptions indicesOptions() { + return IndicesOptions.lenientExpandHidden(); + } + + } + + public static class Response extends ActionResponse implements ToXContentObject { + + private final int totalMatchedRules; + private final List matchedRules; + + private static final ParseField TOTAL_MATCHED_RULES_FIELD = new ParseField("total_matched_rules"); + private static final ParseField MATCHED_RULES_FIELD = new ParseField("matched_rules"); + + public Response(StreamInput in) throws IOException { + super(in); + this.totalMatchedRules = in.readVInt(); + this.matchedRules = in.readCollectionAsList(MatchedRule::new); + } + + public Response(int totalMatchedRules, List matchedRules) { + this.totalMatchedRules = totalMatchedRules; + this.matchedRules = matchedRules; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeVInt(totalMatchedRules); + out.writeCollection(matchedRules, (stream, matchedRule) -> matchedRule.writeTo(stream)); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field(TOTAL_MATCHED_RULES_FIELD.getPreferredName(), totalMatchedRules); + builder.startArray(MATCHED_RULES_FIELD.getPreferredName()); + for (MatchedRule matchedRule : matchedRules) { + builder.startObject(); + builder.field("ruleset_id", matchedRule.rulesetId()); + builder.field("rule_id", matchedRule.ruleId()); + builder.endObject(); + } + builder.endArray(); + builder.endObject(); + return builder; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Response response = (Response) o; + return Objects.equals(totalMatchedRules, response.totalMatchedRules) && Objects.equals(matchedRules, response.matchedRules); + } + + @Override + public int hashCode() { + return Objects.hash(totalMatchedRules, matchedRules); + } + } + + public record MatchedRule(String rulesetId, String ruleId) { + public MatchedRule(StreamInput in) throws IOException { + this(in.readString(), in.readString()); + } + + public void writeTo(StreamOutput out) throws IOException { + out.writeString(rulesetId); + out.writeString(ruleId); + } + } +} diff --git a/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TransportTestQueryRulesetAction.java b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TransportTestQueryRulesetAction.java new file mode 100644 index 0000000000000..115cdc516831a --- /dev/null +++ b/x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TransportTestQueryRulesetAction.java @@ -0,0 +1,64 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.application.rules.action; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.HandledTransportAction; +import org.elasticsearch.client.internal.Client; +import org.elasticsearch.common.util.concurrent.EsExecutors; +import org.elasticsearch.injection.guice.Inject; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.application.rules.QueryRule; + +import java.util.ArrayList; +import java.util.List; + +import static org.elasticsearch.xpack.core.ClientHelper.ENT_SEARCH_ORIGIN; +import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin; + +public class TransportTestQueryRulesetAction extends HandledTransportAction< + TestQueryRulesetAction.Request, + TestQueryRulesetAction.Response> { + + private final Client client; + + @Inject + public TransportTestQueryRulesetAction(TransportService transportService, ActionFilters actionFilters, Client client) { + super( + TestQueryRulesetAction.NAME, + transportService, + actionFilters, + TestQueryRulesetAction.Request::new, + EsExecutors.DIRECT_EXECUTOR_SERVICE + ); + this.client = client; + } + + @Override + protected void doExecute(Task task, TestQueryRulesetAction.Request request, ActionListener listener) { + GetQueryRulesetAction.Request getQueryRulesetRequest = new GetQueryRulesetAction.Request(request.rulesetId()); + executeAsyncWithOrigin( + client, + ENT_SEARCH_ORIGIN, + GetQueryRulesetAction.TYPE, + getQueryRulesetRequest, + ActionListener.wrap(getQueryRulesetResponse -> { + List matchedRules = new ArrayList<>(); + for (QueryRule rule : getQueryRulesetResponse.queryRuleset().rules()) { + if (rule.isRuleMatch(request.matchCriteria())) { + matchedRules.add(new TestQueryRulesetAction.MatchedRule(request.rulesetId(), rule.id())); + } + } + listener.onResponse(new TestQueryRulesetAction.Response(matchedRules.size(), matchedRules)); + }, listener::onFailure) + ); + } + +} diff --git a/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/EnterpriseSearchModuleTestUtils.java b/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/EnterpriseSearchModuleTestUtils.java index 06adb29e32691..190b3c3e53169 100644 --- a/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/EnterpriseSearchModuleTestUtils.java +++ b/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/EnterpriseSearchModuleTestUtils.java @@ -115,4 +115,8 @@ public static QueryRuleset randomQueryRuleset() { return new QueryRuleset(id, rules); } + public static Map randomMatchCriteria() { + return randomMap(1, 3, () -> Tuple.tuple(randomIdentifier(), randomAlphaOfLengthBetween(0, 10))); + } + } diff --git a/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/RestTestQueryRulesetActionTests.java b/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/RestTestQueryRulesetActionTests.java new file mode 100644 index 0000000000000..dc2869e3ff0be --- /dev/null +++ b/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/RestTestQueryRulesetActionTests.java @@ -0,0 +1,53 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.application.rules.action; + +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.test.rest.FakeRestRequest; +import org.elasticsearch.xcontent.NamedXContentRegistry; +import org.elasticsearch.xcontent.XContentType; +import org.elasticsearch.xpack.application.AbstractRestEnterpriseSearchActionTests; +import org.elasticsearch.xpack.application.EnterpriseSearchBaseRestHandler; +import org.elasticsearch.xpack.application.utils.LicenseUtils; + +import java.util.Map; + +public class RestTestQueryRulesetActionTests extends AbstractRestEnterpriseSearchActionTests { + public void testWithNonCompliantLicense() throws Exception { + checkLicenseForRequest( + new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withMethod(RestRequest.Method.POST) + .withParams(Map.of("ruleset_id", "ruleset-id")) + .withContent(new BytesArray(""" + { + "match_criteria": { + "foo": "bar" + } + } + """), XContentType.JSON) + .build(), + LicenseUtils.Product.QUERY_RULES + ); + } + + public void testInvalidRequestWithNonCompliantLicense() throws Exception { + checkLicenseForRequest( + new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withMethod(RestRequest.Method.POST) + .withParams(Map.of("invalid_param_name", "invalid_value")) + .withContent(new BytesArray("{}"), XContentType.JSON) + .build(), + LicenseUtils.Product.QUERY_RULES + ); + } + + @Override + protected EnterpriseSearchBaseRestHandler getRestAction(XPackLicenseState licenseState) { + return new RestTestQueryRulesetAction(licenseState); + } +} diff --git a/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetActionRequestBWCSerializingTests.java b/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetActionRequestBWCSerializingTests.java new file mode 100644 index 0000000000000..7041de1106b50 --- /dev/null +++ b/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetActionRequestBWCSerializingTests.java @@ -0,0 +1,56 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.application.rules.action; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.TransportVersions; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.test.AbstractBWCSerializationTestCase; +import org.elasticsearch.xcontent.XContentParser; +import org.elasticsearch.xpack.application.EnterpriseSearchModuleTestUtils; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +import static org.elasticsearch.test.BWCVersions.getAllBWCVersions; + +public class TestQueryRulesetActionRequestBWCSerializingTests extends AbstractBWCSerializationTestCase { + + private final String RULESET_NAME = "my-ruleset"; + + @Override + protected Writeable.Reader instanceReader() { + return TestQueryRulesetAction.Request::new; + } + + @Override + protected TestQueryRulesetAction.Request createTestInstance() { + return new TestQueryRulesetAction.Request(RULESET_NAME, EnterpriseSearchModuleTestUtils.randomMatchCriteria()); + } + + @Override + protected TestQueryRulesetAction.Request mutateInstance(TestQueryRulesetAction.Request instance) { + return randomValueOtherThan(instance, this::createTestInstance); + } + + @Override + protected TestQueryRulesetAction.Request doParseInstance(XContentParser parser) throws IOException { + return TestQueryRulesetAction.Request.parse(parser, RULESET_NAME); + } + + @Override + protected TestQueryRulesetAction.Request mutateInstanceForVersion(TestQueryRulesetAction.Request instance, TransportVersion version) { + return instance; + } + + @Override + protected List bwcVersions() { + return getAllBWCVersions().stream().filter(v -> v.onOrAfter(TransportVersions.QUERY_RULE_TEST_API)).collect(Collectors.toList()); + } +} diff --git a/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetActionResponseBWCSerializingTests.java b/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetActionResponseBWCSerializingTests.java new file mode 100644 index 0000000000000..a6562fb7b52af --- /dev/null +++ b/x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetActionResponseBWCSerializingTests.java @@ -0,0 +1,52 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.application.rules.action; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.TransportVersions; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.xpack.core.ml.AbstractBWCWireSerializationTestCase; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.elasticsearch.test.BWCVersions.getAllBWCVersions; + +public class TestQueryRulesetActionResponseBWCSerializingTests extends AbstractBWCWireSerializationTestCase< + TestQueryRulesetAction.Response> { + + @Override + protected Writeable.Reader instanceReader() { + return TestQueryRulesetAction.Response::new; + } + + @Override + protected TestQueryRulesetAction.Response mutateInstance(TestQueryRulesetAction.Response instance) { + return randomValueOtherThan(instance, this::createTestInstance); + } + + @Override + protected TestQueryRulesetAction.Response createTestInstance() { + int totalMatchedRules = randomIntBetween(0, 10); + List matchedRules = IntStream.range(0, totalMatchedRules) + .mapToObj(i -> new TestQueryRulesetAction.MatchedRule(randomAlphaOfLengthBetween(5, 10), randomAlphaOfLengthBetween(5, 10))) + .toList(); + return new TestQueryRulesetAction.Response(totalMatchedRules, matchedRules); + } + + @Override + protected TestQueryRulesetAction.Response mutateInstanceForVersion(TestQueryRulesetAction.Response instance, TransportVersion version) { + return instance; + } + + @Override + protected List bwcVersions() { + return getAllBWCVersions().stream().filter(v -> v.onOrAfter(TransportVersions.QUERY_RULE_TEST_API)).collect(Collectors.toList()); + } +} diff --git a/x-pack/plugin/ml/qa/native-multi-node-tests/build.gradle b/x-pack/plugin/ml/qa/native-multi-node-tests/build.gradle index 51e39f144e44c..554cd0489ae8a 100644 --- a/x-pack/plugin/ml/qa/native-multi-node-tests/build.gradle +++ b/x-pack/plugin/ml/qa/native-multi-node-tests/build.gradle @@ -18,6 +18,7 @@ dependencies { javaRestTestImplementation project(path: xpackModule('esql-core')) javaRestTestImplementation project(path: xpackModule('esql')) javaRestTestImplementation project(path: xpackModule('snapshot-repo-test-kit')) + javaRestTestImplementation project(path: xpackModule('ent-search')) } // location for keys and certificates diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java index 9933db84e61cf..5115b5aa380f0 100644 --- a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java @@ -244,6 +244,7 @@ public class Constants { "cluster:admin/xpack/query_rules/get", "cluster:admin/xpack/query_rules/list", "cluster:admin/xpack/query_rules/put", + "cluster:admin/xpack/query_rules/test", "cluster:admin/xpack/rollup/delete", "cluster:admin/xpack/rollup/put", "cluster:admin/xpack/rollup/start",