Browse Source

REST high-level client: add validate query API (#31077)

Adds the validate query API to the high level rest client.
Sohaib Iftikhar 7 years ago
parent
commit
c4f8df3ad6

+ 32 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/IndicesClient.java

@@ -57,6 +57,8 @@ import org.elasticsearch.action.admin.indices.template.get.GetIndexTemplatesRequ
 import org.elasticsearch.action.admin.indices.template.get.GetIndexTemplatesResponse;
 import org.elasticsearch.action.admin.indices.template.put.PutIndexTemplateRequest;
 import org.elasticsearch.action.admin.indices.template.put.PutIndexTemplateResponse;
+import org.elasticsearch.action.admin.indices.validate.query.ValidateQueryRequest;
+import org.elasticsearch.action.admin.indices.validate.query.ValidateQueryResponse;
 import org.elasticsearch.rest.RestStatus;
 
 import java.io.IOException;
@@ -661,6 +663,36 @@ public final class IndicesClient {
             PutIndexTemplateResponse::fromXContent, listener, emptySet());
     }
 
+    /**
+     * Validate a potentially expensive query without executing it.
+     * <p>
+     * See <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-validate.html"> Validate Query API
+     * on elastic.co</a>
+     * @param validateQueryRequest the request
+     * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized
+     * @return the response
+     * @throws IOException in case there is a problem sending the request or parsing back the response
+     */
+    public ValidateQueryResponse validateQuery(ValidateQueryRequest validateQueryRequest, RequestOptions options) throws IOException {
+        return restHighLevelClient.performRequestAndParseEntity(validateQueryRequest, RequestConverters::validateQuery, options,
+            ValidateQueryResponse::fromXContent, emptySet());
+    }
+
+    /**
+     * Asynchronously validate a potentially expensive query without executing it.
+     * <p>
+     * See <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-validate.html"> Validate Query API
+     * on elastic.co</a>
+     * @param validateQueryRequest the request
+     * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized
+     * @param listener the listener to be notified upon request completion
+     */
+    public void validateQueryAsync(ValidateQueryRequest validateQueryRequest, RequestOptions options,
+                                   ActionListener<ValidateQueryResponse> listener) {
+        restHighLevelClient.performRequestAsyncAndParseEntity(validateQueryRequest, RequestConverters::validateQuery, options,
+            ValidateQueryResponse::fromXContent, listener, emptySet());
+    }
+
     /**
      * Gets index templates using the Index Templates API
      * See <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-templates.html"> Index Templates API

+ 15 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/RequestConverters.java

@@ -58,6 +58,7 @@ import org.elasticsearch.action.admin.indices.shrink.ResizeRequest;
 import org.elasticsearch.action.admin.indices.shrink.ResizeType;
 import org.elasticsearch.action.admin.indices.template.get.GetIndexTemplatesRequest;
 import org.elasticsearch.action.admin.indices.template.put.PutIndexTemplateRequest;
+import org.elasticsearch.action.admin.indices.validate.query.ValidateQueryRequest;
 import org.elasticsearch.action.bulk.BulkRequest;
 import org.elasticsearch.action.delete.DeleteRequest;
 import org.elasticsearch.action.fieldcaps.FieldCapabilitiesRequest;
@@ -856,6 +857,20 @@ final class RequestConverters {
         return request;
     }
 
+    static Request validateQuery(ValidateQueryRequest validateQueryRequest) throws IOException {
+        String[] indices = validateQueryRequest.indices() == null ? Strings.EMPTY_ARRAY : validateQueryRequest.indices();
+        String[] types = validateQueryRequest.types() == null || indices.length <= 0 ? Strings.EMPTY_ARRAY : validateQueryRequest.types();
+        String endpoint = endpoint(indices, types, "_validate/query");
+        Request request = new Request(HttpGet.METHOD_NAME, endpoint);
+        Params params = new Params(request);
+        params.withIndicesOptions(validateQueryRequest.indicesOptions());
+        params.putParam("explain", Boolean.toString(validateQueryRequest.explain()));
+        params.putParam("all_shards", Boolean.toString(validateQueryRequest.allShards()));
+        params.putParam("rewrite", Boolean.toString(validateQueryRequest.rewrite()));
+        request.setEntity(createEntity(validateQueryRequest, REQUEST_BODY_CONTENT_TYPE));
+        return request;
+    }
+
     static Request getAlias(GetAliasesRequest getAliasesRequest) {
         String[] indices = getAliasesRequest.indices() == null ? Strings.EMPTY_ARRAY : getAliasesRequest.indices();
         String[] aliases = getAliasesRequest.aliases() == null ? Strings.EMPTY_ARRAY : getAliasesRequest.aliases();

+ 39 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/IndicesClientIT.java

@@ -20,6 +20,7 @@
 package org.elasticsearch.client;
 
 import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
 import org.apache.http.client.methods.HttpPut;
 import org.elasticsearch.ElasticsearchException;
 import org.elasticsearch.ElasticsearchStatusException;
@@ -63,6 +64,8 @@ import org.elasticsearch.action.admin.indices.template.get.GetIndexTemplatesRequ
 import org.elasticsearch.action.admin.indices.template.get.GetIndexTemplatesResponse;
 import org.elasticsearch.action.admin.indices.template.put.PutIndexTemplateRequest;
 import org.elasticsearch.action.admin.indices.template.put.PutIndexTemplateResponse;
+import org.elasticsearch.action.admin.indices.validate.query.ValidateQueryRequest;
+import org.elasticsearch.action.admin.indices.validate.query.ValidateQueryResponse;
 import org.elasticsearch.action.index.IndexRequest;
 import org.elasticsearch.action.support.IndicesOptions;
 import org.elasticsearch.action.support.WriteRequest;
@@ -80,6 +83,8 @@ import org.elasticsearch.common.xcontent.XContentBuilder;
 import org.elasticsearch.common.xcontent.json.JsonXContent;
 import org.elasticsearch.common.xcontent.support.XContentMapValues;
 import org.elasticsearch.index.IndexSettings;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
 import org.elasticsearch.rest.RestStatus;
 
 import java.io.IOException;
@@ -1155,6 +1160,40 @@ public class IndicesClientIT extends ESRestHighLevelClientTestCase {
         assertThat(unknownSettingError.getDetailedMessage(), containsString("unknown setting [index.this-setting-does-not-exist]"));
     }
 
+    public void testValidateQuery() throws IOException{
+        String index = "some_index";
+        createIndex(index, Settings.EMPTY);
+        QueryBuilder builder = QueryBuilders
+            .boolQuery()
+            .must(QueryBuilders.queryStringQuery("*:*"))
+            .filter(QueryBuilders.termQuery("user", "kimchy"));
+        ValidateQueryRequest request = new ValidateQueryRequest(index).query(builder);
+        request.explain(randomBoolean());
+        ValidateQueryResponse response = execute(request, highLevelClient().indices()::validateQuery,
+            highLevelClient().indices()::validateQueryAsync);
+        assertTrue(response.isValid());
+    }
+
+    public void testInvalidValidateQuery() throws IOException{
+        String index = "shakespeare";
+
+        createIndex(index, Settings.EMPTY);
+        Request postDoc = new Request(HttpPost.METHOD_NAME, "/" + index + "/1");
+        postDoc.setJsonEntity(
+            "{\"type\":\"act\",\"line_id\":1,\"play_name\":\"Henry IV\", \"speech_number\":\"\"," +
+                "\"line_number\":\"\",\"speaker\":\"\",\"text_entry\":\"ACT I\"}");
+        assertOK(client().performRequest(postDoc));
+
+        QueryBuilder builder = QueryBuilders
+            .queryStringQuery("line_id:foo")
+            .lenient(false);
+        ValidateQueryRequest request = new ValidateQueryRequest(index).query(builder);
+        request.explain(true);
+        ValidateQueryResponse response = execute(request, highLevelClient().indices()::validateQuery,
+            highLevelClient().indices()::validateQueryAsync);
+        assertFalse(response.isValid());
+    }
+
     public void testGetIndexTemplate() throws Exception {
         RestHighLevelClient client = highLevelClient();
 

+ 35 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/RequestConvertersTests.java

@@ -60,6 +60,7 @@ import org.elasticsearch.action.admin.indices.shrink.ResizeRequest;
 import org.elasticsearch.action.admin.indices.shrink.ResizeType;
 import org.elasticsearch.action.admin.indices.template.get.GetIndexTemplatesRequest;
 import org.elasticsearch.action.admin.indices.template.put.PutIndexTemplateRequest;
+import org.elasticsearch.action.admin.indices.validate.query.ValidateQueryRequest;
 import org.elasticsearch.action.bulk.BulkRequest;
 import org.elasticsearch.action.bulk.BulkShardRequest;
 import org.elasticsearch.action.delete.DeleteRequest;
@@ -1895,6 +1896,40 @@ public class RequestConvertersTests extends ESTestCase {
         assertToXContentBody(putTemplateRequest, request.getEntity());
     }
 
+    public void testValidateQuery() throws Exception {
+        String[] indices = randomBoolean() ? null : randomIndicesNames(0, 5);
+        String[] types = randomBoolean() ? generateRandomStringArray(5, 5, false, false) : null;
+        ValidateQueryRequest validateQueryRequest;
+        if (randomBoolean()) {
+            validateQueryRequest = new ValidateQueryRequest(indices);
+        } else {
+            validateQueryRequest = new ValidateQueryRequest();
+            validateQueryRequest.indices(indices);
+        }
+        validateQueryRequest.types(types);
+        Map<String, String> expectedParams = new HashMap<>();
+        setRandomIndicesOptions(validateQueryRequest::indicesOptions, validateQueryRequest::indicesOptions, expectedParams);
+        validateQueryRequest.explain(randomBoolean());
+        validateQueryRequest.rewrite(randomBoolean());
+        validateQueryRequest.allShards(randomBoolean());
+        expectedParams.put("explain", Boolean.toString(validateQueryRequest.explain()));
+        expectedParams.put("rewrite", Boolean.toString(validateQueryRequest.rewrite()));
+        expectedParams.put("all_shards", Boolean.toString(validateQueryRequest.allShards()));
+        Request request = RequestConverters.validateQuery(validateQueryRequest);
+        StringJoiner endpoint = new StringJoiner("/", "/", "");
+        if (indices != null && indices.length > 0) {
+            endpoint.add(String.join(",", indices));
+            if (types != null && types.length > 0) {
+                endpoint.add(String.join(",", types));
+            }
+        }
+        endpoint.add("_validate/query");
+        assertThat(request.getEndpoint(), equalTo(endpoint.toString()));
+        assertThat(request.getParameters(), equalTo(expectedParams));
+        assertToXContentBody(validateQueryRequest, request.getEntity());
+        assertThat(request.getMethod(), equalTo(HttpGet.METHOD_NAME));
+    }
+
     public void testGetTemplateRequest() throws Exception {
         Map<String, String> encodes = new HashMap<>();
         encodes.put("log", "log");

+ 83 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/IndicesClientDocumentationIT.java

@@ -62,6 +62,9 @@ import org.elasticsearch.action.admin.indices.template.get.GetIndexTemplatesRequ
 import org.elasticsearch.action.admin.indices.template.get.GetIndexTemplatesResponse;
 import org.elasticsearch.action.admin.indices.template.put.PutIndexTemplateRequest;
 import org.elasticsearch.action.admin.indices.template.put.PutIndexTemplateResponse;
+import org.elasticsearch.action.admin.indices.validate.query.QueryExplanation;
+import org.elasticsearch.action.admin.indices.validate.query.ValidateQueryRequest;
+import org.elasticsearch.action.admin.indices.validate.query.ValidateQueryResponse;
 import org.elasticsearch.action.support.ActiveShardCount;
 import org.elasticsearch.action.support.DefaultShardOperationFailedException;
 import org.elasticsearch.action.support.IndicesOptions;
@@ -81,6 +84,7 @@ import org.elasticsearch.common.unit.TimeValue;
 import org.elasticsearch.common.xcontent.XContentBuilder;
 import org.elasticsearch.common.xcontent.XContentFactory;
 import org.elasticsearch.common.xcontent.XContentType;
+import org.elasticsearch.index.query.QueryBuilder;
 import org.elasticsearch.index.query.QueryBuilders;
 import org.elasticsearch.rest.RestStatus;
 
@@ -2128,4 +2132,83 @@ public class IndicesClientDocumentationIT extends ESRestHighLevelClientTestCase
 
         assertTrue(latch.await(30L, TimeUnit.SECONDS));
     }
+
+    public void testValidateQuery() throws IOException, InterruptedException {
+        RestHighLevelClient client = highLevelClient();
+
+        String index = "some_index";
+        createIndex(index, Settings.EMPTY);
+
+        // tag::validate-query-request
+        ValidateQueryRequest request = new ValidateQueryRequest(index); // <1>
+        // end::validate-query-request
+
+        // tag::validate-query-request-query
+        QueryBuilder builder = QueryBuilders
+            .boolQuery() // <1>
+            .must(QueryBuilders.queryStringQuery("*:*"))
+            .filter(QueryBuilders.termQuery("user", "kimchy"));
+        request.query(builder); // <2>
+        // end::validate-query-request-query
+
+        // tag::validate-query-request-explain
+        request.explain(true); // <1>
+        // end::validate-query-request-explain
+
+        // tag::validate-query-request-allShards
+        request.allShards(true); // <1>
+        // end::validate-query-request-allShards
+
+        // tag::validate-query-request-rewrite
+        request.rewrite(true); // <1>
+        // end::validate-query-request-rewrite
+
+        // tag::validate-query-execute
+        ValidateQueryResponse response = client.indices().validateQuery(request, RequestOptions.DEFAULT); // <1>
+        // end::validate-query-execute
+
+        // tag::validate-query-response
+        boolean isValid = response.isValid(); // <1>
+        int totalShards = response.getTotalShards(); // <2>
+        int successfulShards = response.getSuccessfulShards(); // <3>
+        int failedShards = response.getFailedShards(); // <4>
+        if (failedShards > 0) {
+            for(DefaultShardOperationFailedException failure: response.getShardFailures()) { // <5>
+                String failedIndex = failure.index(); // <6>
+                int shardId = failure.shardId(); // <7>
+                String reason = failure.reason(); // <8>
+            }
+        }
+        for(QueryExplanation explanation: response.getQueryExplanation()) { // <9>
+            String explanationIndex = explanation.getIndex(); // <10>
+            int shardId = explanation.getShard(); // <11>
+            String explanationString = explanation.getExplanation(); // <12>
+        }
+        // end::validate-query-response
+
+        // tag::validate-query-execute-listener
+        ActionListener<ValidateQueryResponse> listener =
+            new ActionListener<ValidateQueryResponse>() {
+                @Override
+                public void onResponse(ValidateQueryResponse validateQueryResponse) {
+                    // <1>
+                }
+
+                @Override
+                public void onFailure(Exception e) {
+                    // <2>
+                }
+            };
+        // end::validate-query-execute-listener
+
+        // Replace the empty listener by a blocking listener in test
+        final CountDownLatch latch = new CountDownLatch(1);
+        listener = new LatchedActionListener<>(listener, latch);
+
+        // tag::validate-query-execute-async
+        client.indices().validateQueryAsync(request, RequestOptions.DEFAULT, listener); // <1>
+        // end::validate-query-execute-async
+
+        assertTrue(latch.await(30L, TimeUnit.SECONDS));
+    }
 }

+ 113 - 0
docs/java-rest/high-level/indices/validate_query.asciidoc

@@ -0,0 +1,113 @@
+[[java-rest-high-indices-validate-query]]
+=== Validate Query API
+
+[[java-rest-high-indices-validate-query-request]]
+==== Validate Query Request
+
+A `ValidateQueryRequest` requires one or more `indices` on which the query is validated. If no index
+is provided the request is executed on all indices.
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/IndicesClientDocumentationIT.java[validate-query-request]
+--------------------------------------------------
+<1> The index on which to run the request.
+
+In addition it also needs the query that needs to be validated. The query can be built using the `QueryBuilders` utility class.
+The following code snippet builds a sample boolean query.
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/IndicesClientDocumentationIT.java[validate-query-request-query]
+--------------------------------------------------
+<1> Build the desired query.
+<2> Set it to the request.
+
+==== Optional arguments
+The following arguments can optionally be provided:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/IndicesClientDocumentationIT.java[validate-query-request-explain]
+--------------------------------------------------
+<1> The explain parameter can be set to true to get more detailed information about why a query failed
+
+By default, the request is executed on a single shard only, which is randomly selected. The detailed explanation of
+the query may depend on which shard is being hit, and therefore may vary from one request to another. So, in case of
+query rewrite the `allShards` parameter should be used to get response from all available shards.
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/IndicesClientDocumentationIT.java[validate-query-request-allShards]
+--------------------------------------------------
+<1> Set the allShards parameter.
+
+When the query is valid, the explanation defaults to the string representation of that query. With rewrite set to true,
+the explanation is more detailed showing the actual Lucene query that will be executed
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/IndicesClientDocumentationIT.java[validate-query-request-rewrite]
+--------------------------------------------------
+<1> Set the rewrite parameter.
+
+[[java-rest-high-indices-validate-query-sync]]
+==== Synchronous Execution
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/IndicesClientDocumentationIT.java[validate-query-execute]
+--------------------------------------------------
+<1> Execute the request and get back the response in a ValidateQueryResponse object.
+
+[[java-rest-high-indices-validate-query-async]]
+==== Asynchronous Execution
+
+The asynchronous execution of a validate query request requires both the `ValidateQueryRequest`
+instance and an `ActionListener` instance to be passed to the asynchronous
+method:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/IndicesClientDocumentationIT.java[validate-query-execute-async]
+--------------------------------------------------
+<1> The `ValidateQueryRequest` to execute and the `ActionListener` to use when
+the execution completes
+
+The asynchronous method does not block and returns immediately. Once it is
+completed the `ActionListener` is called back using the `onResponse` method
+if the execution successfully completed or using the `onFailure` method if
+it failed.
+
+A typical listener for `ValidateQueryResponse` looks like:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/IndicesClientDocumentationIT.java[validate-query-execute-listener]
+--------------------------------------------------
+<1> Called when the execution is successfully completed. The response is
+provided as an argument
+<2> Called in case of failure. The raised exception is provided as an argument
+
+[[java-rest-high-indices-validate-query-response]]
+==== Validate Query Response
+
+The returned `ValidateQueryResponse` allows to retrieve information about the executed
+ operation as follows:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/IndicesClientDocumentationIT.java[validate-query-response]
+--------------------------------------------------
+<1> Check if the query is valid or not.
+<2> Get total number of shards.
+<3> Get number of shards that were successful.
+<4> Get number of shards that failed.
+<5> Get the shard failures as `DefaultShardOperationFailedException`.
+<6> Get the index of a failed shard.
+<7> Get the shard id of a failed shard.
+<8> Get the reason for shard failure.
+<9> Get the detailed explanation for the shards (if explain was set to `true`).
+<10> Get the index to which a particular explanation belongs.
+<11> Get the shard id to which a particular explanation belongs.
+<12> Get the actual explanation string.

+ 2 - 0
docs/java-rest/high-level/supported-apis.asciidoc

@@ -73,6 +73,7 @@ Index Management::
 * <<java-rest-high-rollover-index>>
 * <<java-rest-high-indices-put-settings>>
 * <<java-rest-high-get-settings>>
+* <<java-rest-high-indices-validate-query>>
 
 Mapping Management::
 * <<java-rest-high-put-mapping>>
@@ -103,6 +104,7 @@ include::indices/get_alias.asciidoc[]
 include::indices/put_settings.asciidoc[]
 include::indices/get_settings.asciidoc[]
 include::indices/put_template.asciidoc[]
+include::indices/validate_query.asciidoc[]
 include::indices/get_templates.asciidoc[]
 
 == Cluster APIs

+ 81 - 1
server/src/main/java/org/elasticsearch/action/admin/indices/validate/query/QueryExplanation.java

@@ -20,16 +20,57 @@
 package org.elasticsearch.action.admin.indices.validate.query;
 
 import org.elasticsearch.Version;
+import org.elasticsearch.common.ParseField;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.common.io.stream.Streamable;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ToXContentFragment;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
 
 import java.io.IOException;
+import java.util.Objects;
 
-public class QueryExplanation  implements Streamable {
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg;
+
+public class QueryExplanation  implements Streamable, ToXContentFragment {
+
+    public static final String INDEX_FIELD = "index";
+    public static final String SHARD_FIELD = "shard";
+    public static final String VALID_FIELD = "valid";
+    public static final String ERROR_FIELD = "error";
+    public static final String EXPLANATION_FIELD = "explanation";
 
     public static final int RANDOM_SHARD = -1;
 
+    @SuppressWarnings("unchecked")
+    static ConstructingObjectParser<QueryExplanation, Void> PARSER = new ConstructingObjectParser<>(
+        "query_explanation",
+        true,
+        a -> {
+            int shard = RANDOM_SHARD;
+            if (a[1] != null) {
+                shard = (int)a[1];
+            }
+            return new QueryExplanation(
+                (String)a[0],
+                shard,
+                (boolean)a[2],
+                (String)a[3],
+                (String)a[4]
+            );
+        }
+    );
+    static {
+        PARSER.declareString(optionalConstructorArg(), new ParseField(INDEX_FIELD));
+        PARSER.declareInt(optionalConstructorArg(), new ParseField(SHARD_FIELD));
+        PARSER.declareBoolean(constructorArg(), new ParseField(VALID_FIELD));
+        PARSER.declareString(optionalConstructorArg(), new ParseField(EXPLANATION_FIELD));
+        PARSER.declareString(optionalConstructorArg(), new ParseField(ERROR_FIELD));
+    }
+
     private String index;
 
     private int shard = RANDOM_SHARD;
@@ -110,4 +151,43 @@ public class QueryExplanation  implements Streamable {
         exp.readFrom(in);
         return exp;
     }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        if (getIndex() != null) {
+            builder.field(INDEX_FIELD, getIndex());
+        }
+        if(getShard() >= 0) {
+            builder.field(SHARD_FIELD, getShard());
+        }
+        builder.field(VALID_FIELD, isValid());
+        if (getError() != null) {
+            builder.field(ERROR_FIELD, getError());
+        }
+        if (getExplanation() != null) {
+            builder.field(EXPLANATION_FIELD, getExplanation());
+        }
+        return builder;
+    }
+
+    public static QueryExplanation fromXContent(XContentParser parser) {
+        return PARSER.apply(parser, null);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        QueryExplanation other = (QueryExplanation) o;
+        return Objects.equals(getIndex(), other.getIndex()) &&
+            Objects.equals(getShard(), other.getShard()) &&
+            Objects.equals(isValid(), other.isValid()) &&
+            Objects.equals(getError(), other.getError()) &&
+            Objects.equals(getExplanation(), other.getExplanation());
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(getIndex(), getShard(), isValid(), getError(), getExplanation());
+    }
 }

+ 11 - 1
server/src/main/java/org/elasticsearch/action/admin/indices/validate/query/ValidateQueryRequest.java

@@ -27,6 +27,8 @@ import org.elasticsearch.action.support.broadcast.BroadcastRequest;
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.xcontent.ToXContentObject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
 import org.elasticsearch.index.query.MatchAllQueryBuilder;
 import org.elasticsearch.index.query.QueryBuilder;
 
@@ -38,7 +40,7 @@ import java.util.Arrays;
  * <p>
  * The request requires the query to be set using {@link #query(QueryBuilder)}
  */
-public class ValidateQueryRequest extends BroadcastRequest<ValidateQueryRequest> {
+public class ValidateQueryRequest extends BroadcastRequest<ValidateQueryRequest> implements ToXContentObject {
 
     private QueryBuilder query = new MatchAllQueryBuilder();
 
@@ -179,4 +181,12 @@ public class ValidateQueryRequest extends BroadcastRequest<ValidateQueryRequest>
         return "[" + Arrays.toString(indices) + "]" + Arrays.toString(types) + ", query[" + query + "], explain:" + explain +
                 ", rewrite:" + rewrite + ", all_shards:" + allShards;
     }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        builder.field("query");
+        query.toXContent(builder, params);
+        return builder.endObject();
+    }
 }

+ 36 - 17
server/src/main/java/org/elasticsearch/action/admin/indices/validate/query/ValidateQueryResponse.java

@@ -21,16 +21,22 @@ package org.elasticsearch.action.admin.indices.validate.query;
 
 import org.elasticsearch.action.support.DefaultShardOperationFailedException;
 import org.elasticsearch.action.support.broadcast.BroadcastResponse;
+import org.elasticsearch.common.ParseField;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
 import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 
 import static org.elasticsearch.action.admin.indices.validate.query.QueryExplanation.readQueryExplanation;
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg;
 
 /**
  * The response of the validate action.
@@ -39,12 +45,33 @@ import static org.elasticsearch.action.admin.indices.validate.query.QueryExplana
  */
 public class ValidateQueryResponse extends BroadcastResponse {
 
-    public static final String INDEX_FIELD = "index";
-    public static final String SHARD_FIELD = "shard";
     public static final String VALID_FIELD = "valid";
     public static final String EXPLANATIONS_FIELD = "explanations";
-    public static final String ERROR_FIELD = "error";
-    public static final String EXPLANATION_FIELD = "explanation";
+
+    @SuppressWarnings("unchecked")
+    static ConstructingObjectParser<ValidateQueryResponse, Void> PARSER = new ConstructingObjectParser<>(
+        "validate_query",
+        true,
+        arg -> {
+            BroadcastResponse response = (BroadcastResponse) arg[0];
+            return
+                new ValidateQueryResponse(
+                    (boolean)arg[1],
+                    (List<QueryExplanation>)arg[2],
+                    response.getTotalShards(),
+                    response.getSuccessfulShards(),
+                    response.getFailedShards(),
+                    Arrays.asList(response.getShardFailures())
+                );
+        }
+    );
+    static {
+        declareBroadcastFields(PARSER);
+        PARSER.declareBoolean(constructorArg(), new ParseField(VALID_FIELD));
+        PARSER.declareObjectArray(
+            optionalConstructorArg(), QueryExplanation.PARSER, new ParseField(EXPLANATIONS_FIELD)
+        );
+    }
 
     private boolean valid;
 
@@ -112,22 +139,14 @@ public class ValidateQueryResponse extends BroadcastResponse {
             builder.startArray(EXPLANATIONS_FIELD);
             for (QueryExplanation explanation : getQueryExplanation()) {
                 builder.startObject();
-                if (explanation.getIndex() != null) {
-                    builder.field(INDEX_FIELD, explanation.getIndex());
-                }
-                if(explanation.getShard() >= 0) {
-                    builder.field(SHARD_FIELD, explanation.getShard());
-                }
-                builder.field(VALID_FIELD, explanation.isValid());
-                if (explanation.getError() != null) {
-                    builder.field(ERROR_FIELD, explanation.getError());
-                }
-                if (explanation.getExplanation() != null) {
-                    builder.field(EXPLANATION_FIELD, explanation.getExplanation());
-                }
+                explanation.toXContent(builder, params);
                 builder.endObject();
             }
             builder.endArray();
         }
     }
+
+    public static ValidateQueryResponse fromXContent(XContentParser parser) {
+        return PARSER.apply(parser, null);
+    }
 }

+ 2 - 1
server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestValidateQueryAction.java

@@ -19,6 +19,7 @@
 
 package org.elasticsearch.rest.action.admin.indices;
 
+import org.elasticsearch.action.admin.indices.validate.query.QueryExplanation;
 import org.elasticsearch.action.admin.indices.validate.query.ValidateQueryRequest;
 import org.elasticsearch.action.admin.indices.validate.query.ValidateQueryResponse;
 import org.elasticsearch.action.support.IndicesOptions;
@@ -101,7 +102,7 @@ public class RestValidateQueryAction extends BaseRestHandler {
         builder.startObject();
         builder.field(ValidateQueryResponse.VALID_FIELD, false);
         if (explain) {
-            builder.field(ValidateQueryResponse.ERROR_FIELD, error);
+            builder.field(QueryExplanation.ERROR_FIELD, error);
         }
         builder.endObject();
         return new BytesRestResponse(OK, builder);

+ 59 - 0
server/src/test/java/org/elasticsearch/action/admin/indices/validate/query/QueryExplanationTests.java

@@ -0,0 +1,59 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.action.admin.indices.validate.query;
+
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.test.AbstractStreamableXContentTestCase;
+
+import java.io.IOException;
+
+public class QueryExplanationTests extends AbstractStreamableXContentTestCase<QueryExplanation> {
+
+    static QueryExplanation createRandomQueryExplanation(boolean isValid) {
+        String index = "index_" + randomInt(1000);
+        int shard = randomInt(100);
+        Boolean valid = isValid;
+        String errorField = null;
+        if (!valid) {
+            errorField = randomAlphaOfLength(randomIntBetween(10, 100));
+        }
+        String explanation = randomAlphaOfLength(randomIntBetween(10, 100));
+        return new QueryExplanation(index, shard, valid, explanation, errorField);
+    }
+
+    static QueryExplanation createRandomQueryExplanation() {
+        return createRandomQueryExplanation(randomBoolean());
+    }
+
+    @Override
+    protected QueryExplanation doParseInstance(XContentParser parser) throws IOException {
+        return QueryExplanation.fromXContent(parser);
+    }
+
+    @Override
+    protected QueryExplanation createBlankInstance() {
+        return new QueryExplanation();
+    }
+
+    @Override
+    protected QueryExplanation createTestInstance() {
+        return createRandomQueryExplanation();
+    }
+}

+ 110 - 0
server/src/test/java/org/elasticsearch/action/admin/indices/validate/query/ValidateQueryResponseTests.java

@@ -0,0 +1,110 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.action.admin.indices.validate.query;
+
+import org.elasticsearch.ElasticsearchException;
+import org.elasticsearch.action.support.DefaultShardOperationFailedException;
+import org.elasticsearch.action.support.broadcast.AbstractBroadcastResponseTestCase;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class ValidateQueryResponseTests extends AbstractBroadcastResponseTestCase<ValidateQueryResponse> {
+
+    private static ValidateQueryResponse createRandomValidateQueryResponse(
+        int totalShards, int successfulShards, int failedShards, List<DefaultShardOperationFailedException> failures) {
+        boolean valid = failedShards == 0;
+        List<QueryExplanation> queryExplanations = new ArrayList<>(totalShards);
+        for(DefaultShardOperationFailedException failure: failures) {
+            queryExplanations.add(
+                new QueryExplanation(
+                    failure.index(), failure.shardId(), false, failure.reason(), null
+                )
+            );
+        }
+        return new ValidateQueryResponse(
+            valid, queryExplanations, totalShards, successfulShards, failedShards, failures
+        );
+    }
+
+    private static ValidateQueryResponse createRandomValidateQueryResponse() {
+        int totalShards = randomIntBetween(1, 10);
+        int successfulShards = randomIntBetween(0, totalShards);
+        int failedShards = totalShards - successfulShards;
+        boolean valid = failedShards == 0;
+        List<QueryExplanation> queryExplanations = new ArrayList<>(totalShards);
+        List<DefaultShardOperationFailedException> shardFailures = new ArrayList<>(failedShards);
+        for (int i=0; i<successfulShards; i++) {
+            QueryExplanation queryExplanation = QueryExplanationTests.createRandomQueryExplanation(true);
+            queryExplanations.add(queryExplanation);
+        }
+        for (int i=0; i<failedShards; i++) {
+            QueryExplanation queryExplanation = QueryExplanationTests.createRandomQueryExplanation(false);
+            ElasticsearchException exc = new ElasticsearchException("some_error_" + randomInt());
+            shardFailures.add(
+                new DefaultShardOperationFailedException(
+                    queryExplanation.getIndex(), queryExplanation.getShard(),
+                    exc
+                )
+            );
+            queryExplanations.add(queryExplanation);
+        }
+        Collections.shuffle(queryExplanations, random());
+        return new ValidateQueryResponse(valid, queryExplanations, totalShards, successfulShards, failedShards, shardFailures);
+    }
+
+    @Override
+    protected ValidateQueryResponse doParseInstance(XContentParser parser) throws IOException {
+        return ValidateQueryResponse.fromXContent(parser);
+    }
+
+    @Override
+    protected ValidateQueryResponse createTestInstance() {
+        return createRandomValidateQueryResponse();
+    }
+
+    @Override
+    protected void assertEqualInstances(ValidateQueryResponse response, ValidateQueryResponse parsedResponse) {
+        super.assertEqualInstances(response, parsedResponse);
+        Set<QueryExplanation> queryExplSet = new HashSet<>(response.getQueryExplanation());
+        assertEquals(response.isValid(), parsedResponse.isValid());
+        assertEquals(response.getQueryExplanation().size(), parsedResponse.getQueryExplanation().size());
+        assertTrue(queryExplSet.containsAll(parsedResponse.getQueryExplanation()));
+    }
+
+    @Override
+    protected ValidateQueryResponse createTestInstance(int totalShards, int successfulShards, int failedShards,
+                                                       List<DefaultShardOperationFailedException> failures) {
+        return createRandomValidateQueryResponse(totalShards, successfulShards, failedShards, failures);
+    }
+
+    @Override
+    public void testToXContent() {
+        ValidateQueryResponse response = createTestInstance(10, 10, 0, new ArrayList<>());
+        String output = Strings.toString(response);
+        assertEquals("{\"_shards\":{\"total\":10,\"successful\":10,\"failed\":0},\"valid\":true}", output);
+    }
+}