Browse Source

Add explain API to high-level REST client (#31387)

Relates to #27205
Yu 7 years ago
parent
commit
616703b880

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

@@ -65,14 +65,15 @@ import org.elasticsearch.action.admin.indices.template.put.PutIndexTemplateReque
 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.explain.ExplainRequest;
 import org.elasticsearch.action.fieldcaps.FieldCapabilitiesRequest;
 import org.elasticsearch.action.get.GetRequest;
 import org.elasticsearch.action.get.MultiGetRequest;
 import org.elasticsearch.action.index.IndexRequest;
 import org.elasticsearch.action.ingest.DeletePipelineRequest;
-import org.elasticsearch.action.ingest.PutPipelineRequest;
 import org.elasticsearch.action.ingest.GetPipelineRequest;
 import org.elasticsearch.action.ingest.SimulatePipelineRequest;
+import org.elasticsearch.action.ingest.PutPipelineRequest;
 import org.elasticsearch.action.search.ClearScrollRequest;
 import org.elasticsearch.action.search.MultiSearchRequest;
 import org.elasticsearch.action.search.SearchRequest;
@@ -618,6 +619,19 @@ final class RequestConverters {
         return request;
     }
 
+    static Request explain(ExplainRequest explainRequest) throws IOException {
+        Request request = new Request(HttpGet.METHOD_NAME,
+            endpoint(explainRequest.index(), explainRequest.type(), explainRequest.id(), "_explain"));
+
+        Params params = new Params(request);
+        params.withStoredFields(explainRequest.storedFields());
+        params.withFetchSourceContext(explainRequest.fetchSourceContext());
+        params.withRouting(explainRequest.routing());
+        params.withPreference(explainRequest.preference());
+        request.setEntity(createEntity(explainRequest, REQUEST_BODY_CONTENT_TYPE));
+        return request;
+    }
+
     static Request fieldCaps(FieldCapabilitiesRequest fieldCapabilitiesRequest) {
         Request request = new Request(HttpGet.METHOD_NAME, endpoint(fieldCapabilitiesRequest.indices(), "_field_caps"));
 

+ 38 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/RestHighLevelClient.java

@@ -34,6 +34,8 @@ import org.elasticsearch.action.bulk.BulkRequest;
 import org.elasticsearch.action.bulk.BulkResponse;
 import org.elasticsearch.action.delete.DeleteRequest;
 import org.elasticsearch.action.delete.DeleteResponse;
+import org.elasticsearch.action.explain.ExplainRequest;
+import org.elasticsearch.action.explain.ExplainResponse;
 import org.elasticsearch.action.fieldcaps.FieldCapabilitiesRequest;
 import org.elasticsearch.action.fieldcaps.FieldCapabilitiesResponse;
 import org.elasticsearch.action.get.GetRequest;
@@ -614,6 +616,42 @@ public class RestHighLevelClient implements Closeable {
             SearchTemplateResponse::fromXContent, listener, emptySet());
     }
 
+    /**
+     * Executes a request using the Explain API.
+     * See <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-explain.html">Explain API on elastic.co</a>
+     * @param explainRequest 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 final ExplainResponse explain(ExplainRequest explainRequest, RequestOptions options) throws IOException {
+        return performRequest(explainRequest, RequestConverters::explain, options,
+            response -> {
+                CheckedFunction<XContentParser, ExplainResponse, IOException> entityParser =
+                    parser -> ExplainResponse.fromXContent(parser, convertExistsResponse(response));
+                return parseEntity(response.getEntity(), entityParser);
+            },
+            singleton(404));
+    }
+
+    /**
+     * Asynchronously executes a request using the Explain API.
+     *
+     * See <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-explain.html">Explain API on elastic.co</a>
+     * @param explainRequest 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 final void explainAsync(ExplainRequest explainRequest, RequestOptions options, ActionListener<ExplainResponse> listener) {
+        performRequestAsync(explainRequest, RequestConverters::explain, options,
+            response -> {
+                CheckedFunction<XContentParser, ExplainResponse, IOException> entityParser =
+                    parser -> ExplainResponse.fromXContent(parser, convertExistsResponse(response));
+                return parseEntity(response.getEntity(), entityParser);
+            },
+            listener, singleton(404));
+    }
+
     /**
      * Executes a request using the Ranking Evaluation API.
      * See <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html">Ranking Evaluation API

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

@@ -68,6 +68,7 @@ import org.elasticsearch.action.admin.indices.validate.query.ValidateQueryReques
 import org.elasticsearch.action.bulk.BulkRequest;
 import org.elasticsearch.action.bulk.BulkShardRequest;
 import org.elasticsearch.action.delete.DeleteRequest;
+import org.elasticsearch.action.explain.ExplainRequest;
 import org.elasticsearch.action.fieldcaps.FieldCapabilitiesRequest;
 import org.elasticsearch.action.get.GetRequest;
 import org.elasticsearch.action.get.MultiGetRequest;
@@ -111,6 +112,7 @@ import org.elasticsearch.common.xcontent.XContentParser;
 import org.elasticsearch.common.xcontent.XContentType;
 import org.elasticsearch.index.RandomCreateIndexGenerator;
 import org.elasticsearch.index.VersionType;
+import org.elasticsearch.index.query.QueryBuilders;
 import org.elasticsearch.index.query.TermQueryBuilder;
 import org.elasticsearch.index.rankeval.PrecisionAtK;
 import org.elasticsearch.index.rankeval.RankEvalRequest;
@@ -1418,6 +1420,49 @@ public class RequestConvertersTests extends ESTestCase {
         }
     }
 
+    public void testExplain() throws IOException {
+        String index = randomAlphaOfLengthBetween(3, 10);
+        String type = randomAlphaOfLengthBetween(3, 10);
+        String id = randomAlphaOfLengthBetween(3, 10);
+
+        ExplainRequest explainRequest = new ExplainRequest(index, type, id);
+        explainRequest.query(QueryBuilders.termQuery(randomAlphaOfLengthBetween(3, 10), randomAlphaOfLengthBetween(3, 10)));
+
+        Map<String, String> expectedParams = new HashMap<>();
+
+        if (randomBoolean()) {
+            String routing = randomAlphaOfLengthBetween(3, 10);
+            explainRequest.routing(routing);
+            expectedParams.put("routing", routing);
+        }
+        if (randomBoolean()) {
+            String preference = randomAlphaOfLengthBetween(3, 10);
+            explainRequest.preference(preference);
+            expectedParams.put("preference", preference);
+        }
+        if (randomBoolean()) {
+            String[] storedFields = generateRandomStringArray(10, 5, false);
+            String storedFieldsParams = randomFields(storedFields);
+            explainRequest.storedFields(storedFields);
+            expectedParams.put("stored_fields", storedFieldsParams);
+        }
+        if (randomBoolean()) {
+            randomizeFetchSourceContextParams(explainRequest::fetchSourceContext, expectedParams);
+        }
+
+        Request request = RequestConverters.explain(explainRequest);
+        StringJoiner endpoint = new StringJoiner("/", "/", "");
+        endpoint.add(index)
+            .add(type)
+            .add(id)
+            .add("_explain");
+
+        assertEquals(HttpGet.METHOD_NAME, request.getMethod());
+        assertEquals(endpoint.toString(), request.getEndpoint());
+        assertEquals(expectedParams, request.getParameters());
+        assertToXContentBody(explainRequest, request.getEntity());
+    }
+
     public void testFieldCaps() {
         // Create a random request.
         String[] indices = randomIndicesNames(0, 5);

+ 210 - 1
client/rest-high-level/src/test/java/org/elasticsearch/client/SearchIT.java

@@ -27,6 +27,8 @@ import org.apache.http.entity.StringEntity;
 import org.apache.http.nio.entity.NStringEntity;
 import org.elasticsearch.ElasticsearchException;
 import org.elasticsearch.ElasticsearchStatusException;
+import org.elasticsearch.action.explain.ExplainRequest;
+import org.elasticsearch.action.explain.ExplainResponse;
 import org.elasticsearch.action.fieldcaps.FieldCapabilities;
 import org.elasticsearch.action.fieldcaps.FieldCapabilitiesRequest;
 import org.elasticsearch.action.fieldcaps.FieldCapabilitiesResponse;
@@ -44,6 +46,7 @@ import org.elasticsearch.common.xcontent.XContentBuilder;
 import org.elasticsearch.common.xcontent.XContentFactory;
 import org.elasticsearch.common.xcontent.XContentType;
 import org.elasticsearch.index.query.MatchQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
 import org.elasticsearch.index.query.ScriptQueryBuilder;
 import org.elasticsearch.index.query.TermsQueryBuilder;
 import org.elasticsearch.join.aggregations.Children;
@@ -63,6 +66,7 @@ import org.elasticsearch.search.aggregations.matrix.stats.MatrixStats;
 import org.elasticsearch.search.aggregations.matrix.stats.MatrixStatsAggregationBuilder;
 import org.elasticsearch.search.aggregations.support.ValueType;
 import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.elasticsearch.search.fetch.subphase.FetchSourceContext;
 import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
 import org.elasticsearch.search.sort.SortOrder;
 import org.elasticsearch.search.suggest.Suggest;
@@ -135,7 +139,44 @@ public class SearchIT extends ESRestHighLevelClientTestCase {
         client().performRequest(HttpPut.METHOD_NAME, "/index3/doc/5", Collections.emptyMap(), doc);
         doc = new StringEntity("{\"field\":\"value2\"}", ContentType.APPLICATION_JSON);
         client().performRequest(HttpPut.METHOD_NAME, "/index3/doc/6", Collections.emptyMap(), doc);
-        client().performRequest(HttpPost.METHOD_NAME, "/index1,index2,index3/_refresh");
+
+        mappings = new StringEntity(
+            "{" +
+                "  \"mappings\": {" +
+                "    \"doc\": {" +
+                "      \"properties\": {" +
+                "        \"field1\": {" +
+                "          \"type\":  \"keyword\"," +
+                "          \"store\":  true" +
+                "        }," +
+                "        \"field2\": {" +
+                "          \"type\":  \"keyword\"," +
+                "          \"store\":  true" +
+                "        }" +
+                "      }" +
+                "    }" +
+                "  }" +
+                "}}",
+            ContentType.APPLICATION_JSON);
+        client().performRequest(HttpPut.METHOD_NAME, "/index4", Collections.emptyMap(), mappings);
+        doc = new StringEntity("{\"field1\":\"value1\", \"field2\":\"value2\"}", ContentType.APPLICATION_JSON);
+        client().performRequest(HttpPut.METHOD_NAME, "/index4/doc/1", Collections.emptyMap(), doc);
+        StringEntity aliasFilter = new StringEntity(
+            "{" +
+                "    \"actions\" : [" +
+                "        {" +
+                "            \"add\" : {" +
+                "                 \"index\" : \"index4\"," +
+                "                 \"alias\" : \"alias4\"," +
+                "                 \"filter\" : { \"term\" : { \"field2\" : \"value1\" } }" +
+                "            }" +
+                "        }" +
+                "    ]" +
+                "}",
+        ContentType.APPLICATION_JSON);
+        client().performRequest(HttpPost.METHOD_NAME, "/_aliases", Collections.emptyMap(), aliasFilter);
+
+        client().performRequest(HttpPost.METHOD_NAME, "/index1,index2,index3,index4/_refresh");
     }
 
     public void testSearchNoQuery() throws IOException {
@@ -835,6 +876,174 @@ public class SearchIT extends ESRestHighLevelClientTestCase {
         assertToXContentEquivalent(expectedSource, actualSource, XContentType.JSON);
     }
 
+    public void testExplain() throws IOException {
+        {
+            ExplainRequest explainRequest = new ExplainRequest("index1", "doc", "1");
+            explainRequest.query(QueryBuilders.matchAllQuery());
+
+            ExplainResponse explainResponse = execute(explainRequest, highLevelClient()::explain, highLevelClient()::explainAsync);
+
+            assertThat(explainResponse.getIndex(), equalTo("index1"));
+            assertThat(explainResponse.getType(), equalTo("doc"));
+            assertThat(Integer.valueOf(explainResponse.getId()), equalTo(1));
+            assertTrue(explainResponse.isExists());
+            assertTrue(explainResponse.isMatch());
+            assertTrue(explainResponse.hasExplanation());
+            assertThat(explainResponse.getExplanation().getValue(), equalTo(1.0f));
+            assertNull(explainResponse.getGetResult());
+        }
+        {
+            ExplainRequest explainRequest = new ExplainRequest("index1", "doc", "1");
+            explainRequest.query(QueryBuilders.termQuery("field", "value1"));
+
+            ExplainResponse explainResponse = execute(explainRequest, highLevelClient()::explain, highLevelClient()::explainAsync);
+
+            assertThat(explainResponse.getIndex(), equalTo("index1"));
+            assertThat(explainResponse.getType(), equalTo("doc"));
+            assertThat(Integer.valueOf(explainResponse.getId()), equalTo(1));
+            assertTrue(explainResponse.isExists());
+            assertTrue(explainResponse.isMatch());
+            assertTrue(explainResponse.hasExplanation());
+            assertThat(explainResponse.getExplanation().getValue(), greaterThan(0.0f));
+            assertNull(explainResponse.getGetResult());
+        }
+        {
+            ExplainRequest explainRequest = new ExplainRequest("index1", "doc", "1");
+            explainRequest.query(QueryBuilders.termQuery("field", "value2"));
+
+            ExplainResponse explainResponse = execute(explainRequest, highLevelClient()::explain, highLevelClient()::explainAsync);
+
+            assertThat(explainResponse.getIndex(), equalTo("index1"));
+            assertThat(explainResponse.getType(), equalTo("doc"));
+            assertThat(Integer.valueOf(explainResponse.getId()), equalTo(1));
+            assertTrue(explainResponse.isExists());
+            assertFalse(explainResponse.isMatch());
+            assertTrue(explainResponse.hasExplanation());
+            assertNull(explainResponse.getGetResult());
+        }
+        {
+            ExplainRequest explainRequest = new ExplainRequest("index1", "doc", "1");
+            explainRequest.query(QueryBuilders.boolQuery()
+                .must(QueryBuilders.termQuery("field", "value1"))
+                .must(QueryBuilders.termQuery("field", "value2")));
+
+            ExplainResponse explainResponse = execute(explainRequest, highLevelClient()::explain, highLevelClient()::explainAsync);
+
+            assertThat(explainResponse.getIndex(), equalTo("index1"));
+            assertThat(explainResponse.getType(), equalTo("doc"));
+            assertThat(Integer.valueOf(explainResponse.getId()), equalTo(1));
+            assertTrue(explainResponse.isExists());
+            assertFalse(explainResponse.isMatch());
+            assertTrue(explainResponse.hasExplanation());
+            assertThat(explainResponse.getExplanation().getDetails().length, equalTo(2));
+            assertNull(explainResponse.getGetResult());
+        }
+    }
+
+    public void testExplainNonExistent() throws IOException {
+        {
+            ExplainRequest explainRequest = new ExplainRequest("non_existent_index", "doc", "1");
+            explainRequest.query(QueryBuilders.matchQuery("field", "value"));
+            ElasticsearchException exception = expectThrows(ElasticsearchException.class,
+                () -> execute(explainRequest, highLevelClient()::explain, highLevelClient()::explainAsync));
+            assertThat(exception.status(), equalTo(RestStatus.NOT_FOUND));
+            assertThat(exception.getIndex().getName(), equalTo("non_existent_index"));
+            assertThat(exception.getDetailedMessage(),
+                containsString("Elasticsearch exception [type=index_not_found_exception, reason=no such index]"));
+        }
+        {
+            ExplainRequest explainRequest = new ExplainRequest("index1", "doc", "999");
+            explainRequest.query(QueryBuilders.matchQuery("field", "value1"));
+
+            ExplainResponse explainResponse = execute(explainRequest, highLevelClient()::explain, highLevelClient()::explainAsync);
+
+            assertThat(explainResponse.getIndex(), equalTo("index1"));
+            assertThat(explainResponse.getType(), equalTo("doc"));
+            assertThat(explainResponse.getId(), equalTo("999"));
+            assertFalse(explainResponse.isExists());
+            assertFalse(explainResponse.isMatch());
+            assertFalse(explainResponse.hasExplanation());
+            assertNull(explainResponse.getGetResult());
+        }
+    }
+
+    public void testExplainWithStoredFields() throws IOException {
+        {
+            ExplainRequest explainRequest = new ExplainRequest("index4", "doc", "1");
+            explainRequest.query(QueryBuilders.matchAllQuery());
+            explainRequest.storedFields(new String[]{"field1"});
+
+            ExplainResponse explainResponse = execute(explainRequest, highLevelClient()::explain, highLevelClient()::explainAsync);
+
+            assertTrue(explainResponse.isExists());
+            assertTrue(explainResponse.isMatch());
+            assertTrue(explainResponse.hasExplanation());
+            assertThat(explainResponse.getExplanation().getValue(), equalTo(1.0f));
+            assertTrue(explainResponse.getGetResult().isExists());
+            assertThat(explainResponse.getGetResult().getFields().keySet(), equalTo(Collections.singleton("field1")));
+            assertThat(explainResponse.getGetResult().getFields().get("field1").getValue().toString(), equalTo("value1"));
+            assertTrue(explainResponse.getGetResult().isSourceEmpty());
+        }
+        {
+            ExplainRequest explainRequest = new ExplainRequest("index4", "doc", "1");
+            explainRequest.query(QueryBuilders.matchAllQuery());
+            explainRequest.storedFields(new String[]{"field1", "field2"});
+
+            ExplainResponse explainResponse = execute(explainRequest, highLevelClient()::explain, highLevelClient()::explainAsync);
+
+            assertTrue(explainResponse.isExists());
+            assertTrue(explainResponse.isMatch());
+            assertTrue(explainResponse.hasExplanation());
+            assertThat(explainResponse.getExplanation().getValue(), equalTo(1.0f));
+            assertTrue(explainResponse.getGetResult().isExists());
+            assertThat(explainResponse.getGetResult().getFields().keySet().size(), equalTo(2));
+            assertThat(explainResponse.getGetResult().getFields().get("field1").getValue().toString(), equalTo("value1"));
+            assertThat(explainResponse.getGetResult().getFields().get("field2").getValue().toString(), equalTo("value2"));
+            assertTrue(explainResponse.getGetResult().isSourceEmpty());
+        }
+    }
+
+    public void testExplainWithFetchSource() throws IOException {
+        {
+            ExplainRequest explainRequest = new ExplainRequest("index4", "doc", "1");
+            explainRequest.query(QueryBuilders.matchAllQuery());
+            explainRequest.fetchSourceContext(new FetchSourceContext(true, new String[]{"field1"}, null));
+
+            ExplainResponse explainResponse = execute(explainRequest, highLevelClient()::explain, highLevelClient()::explainAsync);
+
+            assertTrue(explainResponse.isExists());
+            assertTrue(explainResponse.isMatch());
+            assertTrue(explainResponse.hasExplanation());
+            assertThat(explainResponse.getExplanation().getValue(), equalTo(1.0f));
+            assertTrue(explainResponse.getGetResult().isExists());
+            assertThat(explainResponse.getGetResult().getSource(), equalTo(Collections.singletonMap("field1", "value1")));
+        }
+        {
+            ExplainRequest explainRequest = new ExplainRequest("index4", "doc", "1");
+            explainRequest.query(QueryBuilders.matchAllQuery());
+            explainRequest.fetchSourceContext(new FetchSourceContext(true, null, new String[] {"field2"}));
+
+            ExplainResponse explainResponse = execute(explainRequest, highLevelClient()::explain, highLevelClient()::explainAsync);
+
+            assertTrue(explainResponse.isExists());
+            assertTrue(explainResponse.isMatch());
+            assertTrue(explainResponse.hasExplanation());
+            assertThat(explainResponse.getExplanation().getValue(), equalTo(1.0f));
+            assertTrue(explainResponse.getGetResult().isExists());
+            assertThat(explainResponse.getGetResult().getSource(), equalTo(Collections.singletonMap("field1", "value1")));
+        }
+    }
+
+    public void testExplainWithAliasFilter() throws IOException {
+        ExplainRequest explainRequest = new ExplainRequest("alias4", "doc", "1");
+        explainRequest.query(QueryBuilders.matchAllQuery());
+
+        ExplainResponse explainResponse = execute(explainRequest, highLevelClient()::explain, highLevelClient()::explainAsync);
+
+        assertTrue(explainResponse.isExists());
+        assertFalse(explainResponse.isMatch());
+    }
+
     public void testFieldCaps() throws IOException {
         FieldCapabilitiesRequest request = new FieldCapabilitiesRequest()
             .indices("index1", "index2")

+ 86 - 1
client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SearchDocumentationIT.java

@@ -19,12 +19,15 @@
 
 package org.elasticsearch.client.documentation;
 
+import org.apache.lucene.search.Explanation;
 import org.elasticsearch.action.ActionListener;
 import org.elasticsearch.action.LatchedActionListener;
 import org.elasticsearch.action.admin.indices.create.CreateIndexRequest;
 import org.elasticsearch.action.admin.indices.create.CreateIndexResponse;
 import org.elasticsearch.action.bulk.BulkRequest;
 import org.elasticsearch.action.bulk.BulkResponse;
+import org.elasticsearch.action.explain.ExplainRequest;
+import org.elasticsearch.action.explain.ExplainResponse;
 import org.elasticsearch.action.fieldcaps.FieldCapabilities;
 import org.elasticsearch.action.fieldcaps.FieldCapabilitiesRequest;
 import org.elasticsearch.action.fieldcaps.FieldCapabilitiesResponse;
@@ -47,10 +50,12 @@ import org.elasticsearch.client.Response;
 import org.elasticsearch.client.RestClient;
 import org.elasticsearch.client.RestHighLevelClient;
 import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.document.DocumentField;
 import org.elasticsearch.common.text.Text;
 import org.elasticsearch.common.unit.Fuzziness;
 import org.elasticsearch.common.unit.TimeValue;
 import org.elasticsearch.common.xcontent.XContentType;
+import org.elasticsearch.index.get.GetResult;
 import org.elasticsearch.index.query.MatchQueryBuilder;
 import org.elasticsearch.index.query.QueryBuilder;
 import org.elasticsearch.index.query.QueryBuilders;
@@ -80,6 +85,7 @@ import org.elasticsearch.search.aggregations.bucket.terms.Terms.Bucket;
 import org.elasticsearch.search.aggregations.bucket.terms.TermsAggregationBuilder;
 import org.elasticsearch.search.aggregations.metrics.avg.Avg;
 import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.elasticsearch.search.fetch.subphase.FetchSourceContext;
 import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
 import org.elasticsearch.search.fetch.subphase.highlight.HighlightField;
 import org.elasticsearch.search.profile.ProfileResult;
@@ -835,6 +841,85 @@ public class SearchDocumentationIT extends ESRestHighLevelClientTestCase {
         assertTrue(latch.await(30L, TimeUnit.SECONDS));
     }
 
+    public void testExplain() throws Exception {
+        indexSearchTestData();
+        RestHighLevelClient client = highLevelClient();
+
+        // tag::explain-request
+        ExplainRequest request = new ExplainRequest("contributors", "doc", "1");
+        request.query(QueryBuilders.termQuery("user", "tanguy"));
+        // end::explain-request
+
+        // tag::explain-request-routing
+        request.routing("routing"); // <1>
+        // end::explain-request-routing
+
+        // tag::explain-request-preference
+        request.preference("_local"); // <1>
+        // end::explain-request-preference
+
+        // tag::explain-request-source
+        request.fetchSourceContext(new FetchSourceContext(true, new String[]{"user"}, null)); // <1>
+        // end::explain-request-source
+
+        // tag::explain-request-stored-field
+        request.storedFields(new String[]{"user"}); // <1>
+        // end::explain-request-stored-field
+
+        // tag::explain-execute
+        ExplainResponse response = client.explain(request, RequestOptions.DEFAULT);
+        // end::explain-execute
+
+        // tag::explain-response
+        String index = response.getIndex(); // <1>
+        String type = response.getType(); // <2>
+        String id = response.getId(); // <3>
+        boolean exists = response.isExists(); // <4>
+        boolean match = response.isMatch(); // <5>
+        boolean hasExplanation = response.hasExplanation(); // <6>
+        Explanation explanation = response.getExplanation(); // <7>
+        GetResult getResult = response.getGetResult(); // <8>
+        // end::explain-response
+        assertThat(index, equalTo("contributors"));
+        assertThat(type, equalTo("doc"));
+        assertThat(id, equalTo("1"));
+        assertTrue(exists);
+        assertTrue(match);
+        assertTrue(hasExplanation);
+        assertNotNull(explanation);
+        assertNotNull(getResult);
+
+        // tag::get-result
+        Map<String, Object> source = getResult.getSource(); // <1>
+        Map<String, DocumentField> fields = getResult.getFields(); // <2>
+        // end::get-result
+        assertThat(source, equalTo(Collections.singletonMap("user", "tanguy")));
+        assertThat(fields.get("user").getValue(), equalTo("tanguy"));
+
+        // tag::explain-execute-listener
+        ActionListener<ExplainResponse> listener = new ActionListener<ExplainResponse>() {
+            @Override
+            public void onResponse(ExplainResponse explainResponse) {
+                // <1>
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                // <2>
+            }
+        };
+        // end::explain-execute-listener
+
+        CountDownLatch latch = new CountDownLatch(1);
+        listener = new LatchedActionListener<>(listener, latch);
+
+        // tag::explain-execute-async
+        client.explainAsync(request, RequestOptions.DEFAULT, listener); // <1>
+        // end::explain-execute-async
+
+        assertTrue(latch.await(30L, TimeUnit.SECONDS));
+    }
+
     public void testFieldCaps() throws Exception {
         indexSearchTestData();
         RestHighLevelClient client = highLevelClient();
@@ -1046,7 +1131,7 @@ public class SearchDocumentationIT extends ESRestHighLevelClientTestCase {
         assertTrue(authorsResponse.isAcknowledged());
 
         CreateIndexRequest reviewersRequest = new CreateIndexRequest("contributors")
-            .mapping("doc", "user", "type=keyword");
+            .mapping("doc", "user", "type=keyword,store=true");
         CreateIndexResponse reviewersResponse = highLevelClient().indices().create(reviewersRequest, RequestOptions.DEFAULT);
         assertTrue(reviewersResponse.isAcknowledged());
 

+ 113 - 0
docs/java-rest/high-level/search/explain.asciidoc

@@ -0,0 +1,113 @@
+[[java-rest-high-explain]]
+=== Explain API
+
+The explain api computes a score explanation for a query and a specific document.
+This can give useful feedback whether a document matches or didn’t match a specific query.
+
+[[java-rest-high-explain-request]]
+==== Explain Request
+
+An `ExplainRequest` expects an `index`, a `type` and an `id` to specify a certain document,
+and a query represented by `QueryBuilder` to run against it (the way of <<java-rest-high-query-builders, building queries>>).
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/SearchDocumentationIT.java[explain-request]
+--------------------------------------------------
+
+===== Optional arguments
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/SearchDocumentationIT.java[explain-request-routing]
+--------------------------------------------------
+<1> Set a routing parameter
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/SearchDocumentationIT.java[explain-request-preference]
+--------------------------------------------------
+<1> Use the preference parameter e.g. to execute the search to prefer local
+shards. The default is to randomize across shards.
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/SearchDocumentationIT.java[explain-request-source]
+--------------------------------------------------
+<1> Set to true to retrieve the _source of the document explained. You can also
+retrieve part of the document by using _source_include & _source_exclude
+(see <<java-rest-high-document-get-request-optional-arguments, Get API>> for more details)
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/SearchDocumentationIT.java[explain-request-stored-field]
+--------------------------------------------------
+<1> Allows to control which stored fields to return as part of the document explained
+(requires the field to be stored separately in the mappings).
+
+[[java-rest-high-explain-sync]]
+==== Synchronous Execution
+
+The `explain` method executes the request synchronously:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/SearchDocumentationIT.java[explain-execute]
+--------------------------------------------------
+
+[[java-rest-high-explain-async]]
+==== Asynchronous Execution
+
+The `explainAsync` method executes the request asynchronously,
+calling the provided `ActionListener` when the response is ready:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/SearchDocumentationIT.java[explain-execute-async]
+--------------------------------------------------
+<1> The `ExplainRequest` to execute and the `ActionListener` to use when
+the execution completes.
+
+The asynchronous method does not block and returns immediately. Once the request
+completes, 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 `ExplainResponse` is constructed as follows:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/SearchDocumentationIT.java[explain-execute-listener]
+--------------------------------------------------
+<1> Called when the execution is successfully completed.
+<2> Called when the whole `FieldCapabilitiesRequest` fails.
+
+[[java-rest-high-explain-response]]
+==== ExplainResponse
+
+The `ExplainResponse` contains the following information:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/SearchDocumentationIT.java[explain-response]
+--------------------------------------------------
+<1> The index name of the explained document.
+<2> The type name of the explained document.
+<3> The id of the explained document.
+<4> Indicates whether or not the explained document exists.
+<5> Indicates whether or not there is a match between the explained document and
+the provided query (the `match` is retrieved from the lucene `Explanation` behind the scenes
+if the lucene `Explanation` models a match, it returns `true`, otherwise it returns `false`).
+<6> Indicates whether or not there exists a lucene `Explanation` for this request.
+<7> Get the lucene `Explanation` object if there exists.
+<8> Get the `GetResult` object if the `_source` or the stored fields are retrieved.
+
+The `GetResult` contains two maps internally to store the fetched `_source` and stored fields.
+You can use the following methods to get them:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests}/SearchDocumentationIT.java[get-result]
+--------------------------------------------------
+<1> Retrieve the `_source` as a map.
+<2> Retrieve the specified stored fields as a map.

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

@@ -35,6 +35,7 @@ The Java High Level REST Client supports the following Search APIs:
 * <<java-rest-high-multi-search>>
 * <<java-rest-high-field-caps>>
 * <<java-rest-high-rank-eval>>
+* <<java-rest-high-explain>>
 
 include::search/search.asciidoc[]
 include::search/scroll.asciidoc[]
@@ -42,6 +43,7 @@ include::search/multi-search.asciidoc[]
 include::search/search-template.asciidoc[]
 include::search/field-caps.asciidoc[]
 include::search/rank-eval.asciidoc[]
+include::search/explain.asciidoc[]
 
 == Miscellaneous APIs
 

+ 14 - 1
server/src/main/java/org/elasticsearch/action/explain/ExplainRequest.java

@@ -22,9 +22,12 @@ package org.elasticsearch.action.explain;
 import org.elasticsearch.action.ActionRequestValidationException;
 import org.elasticsearch.action.ValidateActions;
 import org.elasticsearch.action.support.single.shard.SingleShardRequest;
+import org.elasticsearch.common.ParseField;
 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.QueryBuilder;
 import org.elasticsearch.search.fetch.subphase.FetchSourceContext;
 import org.elasticsearch.search.internal.AliasFilter;
@@ -34,7 +37,9 @@ import java.io.IOException;
 /**
  * Explain request encapsulating the explain query and document identifier to get an explanation for.
  */
-public class ExplainRequest extends SingleShardRequest<ExplainRequest> {
+public class ExplainRequest extends SingleShardRequest<ExplainRequest> implements ToXContentObject {
+
+    private static final ParseField QUERY_FIELD = new ParseField("query");
 
     private String type = "_all";
     private String id;
@@ -186,4 +191,12 @@ public class ExplainRequest extends SingleShardRequest<ExplainRequest> {
         out.writeOptionalWriteable(fetchSourceContext);
         out.writeVLong(nowInMillis);
     }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        builder.field(QUERY_FIELD.getPreferredName(), query);
+        builder.endObject();
+        return builder;
+    }
 }

+ 110 - 1
server/src/main/java/org/elasticsearch/action/explain/ExplainResponse.java

@@ -21,11 +21,19 @@ package org.elasticsearch.action.explain;
 
 import org.apache.lucene.search.Explanation;
 import org.elasticsearch.action.ActionResponse;
+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.StatusToXContentObject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
 import org.elasticsearch.index.get.GetResult;
+import org.elasticsearch.rest.RestStatus;
 
 import java.io.IOException;
+import java.util.Collection;
+import java.util.Objects;
 
 import static org.elasticsearch.common.lucene.Lucene.readExplanation;
 import static org.elasticsearch.common.lucene.Lucene.writeExplanation;
@@ -33,7 +41,17 @@ import static org.elasticsearch.common.lucene.Lucene.writeExplanation;
 /**
  * Response containing the score explanation.
  */
-public class ExplainResponse extends ActionResponse {
+public class ExplainResponse extends ActionResponse implements StatusToXContentObject {
+
+    private static final ParseField _INDEX = new ParseField("_index");
+    private static final ParseField _TYPE = new ParseField("_type");
+    private static final ParseField _ID = new ParseField("_id");
+    private static final ParseField MATCHED = new ParseField("matched");
+    private static final ParseField EXPLANATION = new ParseField("explanation");
+    private static final ParseField VALUE = new ParseField("value");
+    private static final ParseField DESCRIPTION = new ParseField("description");
+    private static final ParseField DETAILS = new ParseField("details");
+    private static final ParseField GET = new ParseField("get");
 
     private String index;
     private String type;
@@ -94,6 +112,11 @@ public class ExplainResponse extends ActionResponse {
         return getResult;
     }
 
+    @Override
+    public RestStatus status() {
+        return exists ? RestStatus.OK : RestStatus.NOT_FOUND;
+    }
+
     @Override
     public void readFrom(StreamInput in) throws IOException {
         super.readFrom(in);
@@ -129,4 +152,90 @@ public class ExplainResponse extends ActionResponse {
             getResult.writeTo(out);
         }
     }
+
+    private static final ConstructingObjectParser<ExplainResponse, Boolean> PARSER = new ConstructingObjectParser<>("explain", true,
+        (arg, exists) -> new ExplainResponse((String) arg[0], (String) arg[1], (String) arg[2], exists, (Explanation) arg[3],
+            (GetResult) arg[4]));
+
+    static {
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), _INDEX);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), _TYPE);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), _ID);
+        final ConstructingObjectParser<Explanation, Boolean> explanationParser = new ConstructingObjectParser<>("explanation", true,
+            arg -> {
+                if ((float) arg[0] > 0) {
+                    return Explanation.match((float) arg[0], (String) arg[1], (Collection<Explanation>) arg[2]);
+                } else {
+                    return Explanation.noMatch((String) arg[1], (Collection<Explanation>) arg[2]);
+                }
+            });
+        explanationParser.declareFloat(ConstructingObjectParser.constructorArg(), VALUE);
+        explanationParser.declareString(ConstructingObjectParser.constructorArg(), DESCRIPTION);
+        explanationParser.declareObjectArray(ConstructingObjectParser.constructorArg(), explanationParser, DETAILS);
+        PARSER.declareObject(ConstructingObjectParser.optionalConstructorArg(), explanationParser, EXPLANATION);
+        PARSER.declareObject(ConstructingObjectParser.optionalConstructorArg(), (p, c) -> GetResult.fromXContentEmbedded(p), GET);
+    }
+
+    public static ExplainResponse fromXContent(XContentParser parser, boolean exists) {
+        return PARSER.apply(parser, exists);
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        builder.field(_INDEX.getPreferredName(), index);
+        builder.field(_TYPE.getPreferredName(), type);
+        builder.field(_ID.getPreferredName(), id);
+        builder.field(MATCHED.getPreferredName(), isMatch());
+        if (hasExplanation()) {
+            builder.startObject(EXPLANATION.getPreferredName());
+            buildExplanation(builder, explanation);
+            builder.endObject();
+        }
+        if (getResult != null) {
+            builder.startObject(GET.getPreferredName());
+            getResult.toXContentEmbedded(builder, params);
+            builder.endObject();
+        }
+        builder.endObject();
+        return builder;
+    }
+
+    private void buildExplanation(XContentBuilder builder, Explanation explanation) throws IOException {
+        builder.field(VALUE.getPreferredName(), explanation.getValue());
+        builder.field(DESCRIPTION.getPreferredName(), explanation.getDescription());
+        Explanation[] innerExps = explanation.getDetails();
+        if (innerExps != null) {
+            builder.startArray(DETAILS.getPreferredName());
+            for (Explanation exp : innerExps) {
+                builder.startObject();
+                buildExplanation(builder, exp);
+                builder.endObject();
+            }
+            builder.endArray();
+        }
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null || getClass() != obj.getClass()) {
+            return false;
+        }
+        ExplainResponse other = (ExplainResponse) obj;
+        return index.equals(other.index)
+            && type.equals(other.type)
+            && id.equals(other.id)
+            && Objects.equals(explanation, other.explanation)
+            && getResult.isExists() == other.getResult.isExists()
+            && Objects.equals(getResult.sourceAsMap(), other.getResult.sourceAsMap())
+            && Objects.equals(getResult.getFields(), other.getResult.getFields());
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(index, type, id, explanation, getResult.isExists(), getResult.sourceAsMap(), getResult.getFields());
+    }
 }

+ 2 - 61
server/src/main/java/org/elasticsearch/rest/action/search/RestExplainAction.java

@@ -19,30 +19,22 @@
 
 package org.elasticsearch.rest.action.search;
 
-import org.apache.lucene.search.Explanation;
 import org.elasticsearch.action.explain.ExplainRequest;
-import org.elasticsearch.action.explain.ExplainResponse;
 import org.elasticsearch.client.node.NodeClient;
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.settings.Settings;
-import org.elasticsearch.common.xcontent.XContentBuilder;
-import org.elasticsearch.index.get.GetResult;
 import org.elasticsearch.index.query.QueryBuilder;
 import org.elasticsearch.rest.BaseRestHandler;
-import org.elasticsearch.rest.BytesRestResponse;
 import org.elasticsearch.rest.RestController;
 import org.elasticsearch.rest.RestRequest;
-import org.elasticsearch.rest.RestResponse;
 import org.elasticsearch.rest.action.RestActions;
-import org.elasticsearch.rest.action.RestBuilderListener;
+import org.elasticsearch.rest.action.RestStatusToXContentListener;
 import org.elasticsearch.search.fetch.subphase.FetchSourceContext;
 
 import java.io.IOException;
 
 import static org.elasticsearch.rest.RestRequest.Method.GET;
 import static org.elasticsearch.rest.RestRequest.Method.POST;
-import static org.elasticsearch.rest.RestStatus.NOT_FOUND;
-import static org.elasticsearch.rest.RestStatus.OK;
 
 /**
  * Rest action for computing a score explanation for specific documents.
@@ -89,57 +81,6 @@ public class RestExplainAction extends BaseRestHandler {
 
         explainRequest.fetchSourceContext(FetchSourceContext.parseFromRestRequest(request));
 
-        return channel -> client.explain(explainRequest, new RestBuilderListener<ExplainResponse>(channel) {
-            @Override
-            public RestResponse buildResponse(ExplainResponse response, XContentBuilder builder) throws Exception {
-                builder.startObject();
-                builder.field(Fields._INDEX, response.getIndex())
-                        .field(Fields._TYPE, response.getType())
-                        .field(Fields._ID, response.getId())
-                        .field(Fields.MATCHED, response.isMatch());
-
-                if (response.hasExplanation()) {
-                    builder.startObject(Fields.EXPLANATION);
-                    buildExplanation(builder, response.getExplanation());
-                    builder.endObject();
-                }
-                GetResult getResult = response.getGetResult();
-                if (getResult != null) {
-                    builder.startObject(Fields.GET);
-                    response.getGetResult().toXContentEmbedded(builder, request);
-                    builder.endObject();
-                }
-                builder.endObject();
-                return new BytesRestResponse(response.isExists() ? OK : NOT_FOUND, builder);
-            }
-
-            private void buildExplanation(XContentBuilder builder, Explanation explanation) throws IOException {
-                builder.field(Fields.VALUE, explanation.getValue());
-                builder.field(Fields.DESCRIPTION, explanation.getDescription());
-                Explanation[] innerExps = explanation.getDetails();
-                if (innerExps != null) {
-                    builder.startArray(Fields.DETAILS);
-                    for (Explanation exp : innerExps) {
-                        builder.startObject();
-                        buildExplanation(builder, exp);
-                        builder.endObject();
-                    }
-                    builder.endArray();
-                }
-            }
-        });
-    }
-
-    static class Fields {
-        static final String _INDEX = "_index";
-        static final String _TYPE = "_type";
-        static final String _ID = "_id";
-        static final String MATCHED = "matched";
-        static final String EXPLANATION = "explanation";
-        static final String VALUE = "value";
-        static final String DESCRIPTION = "description";
-        static final String DETAILS = "details";
-        static final String GET = "get";
-
+        return channel -> client.explain(explainRequest, new RestStatusToXContentListener<>(channel));
     }
 }

+ 1 - 2
server/src/test/java/org/elasticsearch/action/ExplainRequestTests.java → server/src/test/java/org/elasticsearch/action/explain/ExplainRequestTests.java

@@ -16,9 +16,8 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.elasticsearch.action;
+package org.elasticsearch.action.explain;
 
-import org.elasticsearch.action.explain.ExplainRequest;
 import org.elasticsearch.common.io.stream.BytesStreamOutput;
 import org.elasticsearch.common.io.stream.NamedWriteableAwareStreamInput;
 import org.elasticsearch.common.io.stream.NamedWriteableRegistry;

+ 127 - 0
server/src/test/java/org/elasticsearch/action/explain/ExplainResponseTests.java

@@ -0,0 +1,127 @@
+/*
+ * 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.explain;
+
+import org.apache.lucene.search.Explanation;
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.document.DocumentField;
+import org.elasticsearch.common.xcontent.ToXContent;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentFactory;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.common.xcontent.XContentType;
+import org.elasticsearch.index.get.GetResult;
+import org.elasticsearch.test.AbstractStreamableXContentTestCase;
+import org.elasticsearch.test.RandomObjects;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Predicate;
+
+import static java.util.Collections.singletonList;
+import static java.util.Collections.singletonMap;
+import static org.hamcrest.Matchers.equalTo;
+
+public class ExplainResponseTests extends AbstractStreamableXContentTestCase<ExplainResponse> {
+    @Override
+    protected ExplainResponse doParseInstance(XContentParser parser) throws IOException {
+        return ExplainResponse.fromXContent(parser, randomBoolean());
+    }
+
+    @Override
+    protected ExplainResponse createBlankInstance() {
+        return new ExplainResponse();
+    }
+
+    @Override
+    protected ExplainResponse createTestInstance() {
+        String index = randomAlphaOfLength(5);
+        String type = randomAlphaOfLength(5);
+        String id = String.valueOf(randomIntBetween(1,100));
+        boolean exist = randomBoolean();
+        Explanation explanation = randomExplanation(randomExplanation(randomExplanation()), randomExplanation());
+        String fieldName = randomAlphaOfLength(10);
+        List<Object> values = Arrays.asList(randomAlphaOfLengthBetween(3, 10), randomInt(), randomLong(), randomDouble(), randomBoolean());
+        GetResult getResult = new GetResult(randomAlphaOfLengthBetween(3, 10),
+            randomAlphaOfLengthBetween(3, 10),
+            randomAlphaOfLengthBetween(3, 10),
+            randomNonNegativeLong(),
+            true,
+            RandomObjects.randomSource(random()),
+            singletonMap(fieldName, new DocumentField(fieldName, values)));
+        return new ExplainResponse(index, type, id, exist, explanation, getResult);
+    }
+
+    @Override
+    protected Predicate<String> getRandomFieldsExcludeFilter() {
+        return field -> field.equals("get") || field.startsWith("get.fields") || field.startsWith("get._source");
+    }
+
+    public void testToXContent() throws IOException {
+        String index = "index";
+        String type = "type";
+        String id = "1";
+        boolean exist = true;
+        Explanation explanation = Explanation.match(1.0f, "description", Collections.emptySet());
+        GetResult getResult = new GetResult(null, null, null, -1, true, new BytesArray("{ \"field1\" : " +
+            "\"value1\", \"field2\":\"value2\"}"), singletonMap("field1", new DocumentField("field1",
+            singletonList("value1"))));
+        ExplainResponse response = new ExplainResponse(index, type, id, exist, explanation, getResult);
+
+        XContentBuilder builder = XContentFactory.contentBuilder(XContentType.JSON);
+        response.toXContent(builder, ToXContent.EMPTY_PARAMS);
+
+        String generatedResponse = BytesReference.bytes(builder).utf8ToString().replaceAll("\\s+", "");
+
+        String expectedResponse =
+            ("{\n" +
+            "    \"_index\":\"index\",\n" +
+            "    \"_type\":\"type\",\n" +
+            "    \"_id\":\"1\",\n" +
+            "    \"matched\":true,\n" +
+            "    \"explanation\":{\n" +
+            "        \"value\":1.0,\n" +
+            "        \"description\":\"description\",\n" +
+            "        \"details\":[]\n" +
+            "    },\n" +
+            "    \"get\":{\n" +
+            "        \"found\":true,\n" +
+            "        \"_source\":{\n" +
+            "            \"field1\":\"value1\",\n" +
+            "            \"field2\":\"value2\"\n" +
+            "        },\n" +
+            "        \"fields\":{\n" +
+            "            \"field1\":[\n" +
+            "                \"value1\"\n" +
+            "            ]\n" +
+            "        }\n" +
+            "    }\n" +
+            "}").replaceAll("\\s+", "");
+        assertThat(expectedResponse, equalTo(generatedResponse));
+    }
+
+    private static Explanation randomExplanation(Explanation... explanations) {
+        return Explanation.match(randomFloat(), randomAlphaOfLengthBetween(1, 10),
+            explanations.length > 0 ? explanations : new Explanation[0]);
+    }
+}