Browse Source

Adding fromXContent to Suggest and Suggestion class (#23226)

A follow up to #23202, this adds parsing from xContent and tests to the four Suggestion implementations
and the top level suggest element to be used later when parsing the entire SearchResponse.
Christoph Büscher 8 years ago
parent
commit
ea7deace5d

+ 9 - 1
core/src/main/java/org/elasticsearch/common/xcontent/XContentHelper.java

@@ -448,12 +448,20 @@ public class XContentHelper {
      * {@link XContentType}. Wraps the output into a new anonymous object.
      */
     public static BytesReference toXContent(ToXContent toXContent, XContentType xContentType, boolean humanReadable) throws IOException {
+        return toXContent(toXContent, xContentType, ToXContent.EMPTY_PARAMS, humanReadable);
+    }
+
+    /**
+     * Returns the bytes that represent the XContent output of the provided {@link ToXContent} object, using the provided
+     * {@link XContentType}. Wraps the output into a new anonymous object.
+     */
+    public static BytesReference toXContent(ToXContent toXContent, XContentType xContentType, Params params, boolean humanReadable) throws IOException {
         try (XContentBuilder builder = XContentBuilder.builder(xContentType.xContent())) {
             builder.humanReadable(humanReadable);
             if (toXContent.isFragment()) {
                 builder.startObject();
             }
-            toXContent.toXContent(builder, ToXContent.EMPTY_PARAMS);
+            toXContent.toXContent(builder, params);
             if (toXContent.isFragment()) {
                 builder.endObject();
             }

+ 64 - 3
core/src/main/java/org/elasticsearch/search/suggest/Suggest.java

@@ -20,6 +20,7 @@ package org.elasticsearch.search.suggest;
 
 import org.apache.lucene.util.CollectionUtil;
 import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.ParsingException;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.common.io.stream.Streamable;
@@ -47,17 +48,19 @@ import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 
 import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;
 import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg;
+import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken;
 
 /**
  * Top level suggest result, containing the result for each suggestion.
  */
 public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? extends Option>>>, Streamable, ToXContent {
 
-    private static final String NAME = "suggest";
+    static final String NAME = "suggest";
 
     public static final Comparator<Option> COMPARATOR = (first, second) -> {
         int cmp = Float.compare(second.getScore(), first.getScore());
@@ -72,7 +75,7 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
 
     private Map<String, Suggestion<? extends Entry<? extends Option>>> suggestMap;
 
-    public Suggest() {
+    private Suggest() {
         this(Collections.emptyList());
     }
 
@@ -167,6 +170,18 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
         return builder;
     }
 
+    /**
+     * this parsing method assumes that the leading "suggest" field name has already been parsed by the caller
+     */
+    public static Suggest fromXContent(XContentParser parser) throws IOException {
+        ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.currentToken(), parser::getTokenLocation);
+        List<Suggestion<? extends Entry<? extends Option>>> suggestions = new ArrayList<>();
+        while ((parser.nextToken()) != XContentParser.Token.END_OBJECT) {
+            suggestions.add(Suggestion.fromXContent(parser));
+        }
+        return new Suggest(suggestions);
+    }
+
     public static Suggest readSuggest(StreamInput in) throws IOException {
         Suggest result = new Suggest();
         result.readFrom(in);
@@ -216,7 +231,7 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
         protected int size;
         protected final List<T> entries = new ArrayList<>(5);
 
-        public Suggestion() {
+        protected Suggestion() {
         }
 
         public Suggestion(String name, int size) {
@@ -369,6 +384,52 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
             return builder;
         }
 
+        public static Suggestion<? extends Entry<? extends Option>> fromXContent(XContentParser parser) throws IOException {
+            ensureExpectedToken(XContentParser.Token.FIELD_NAME, parser.currentToken(), parser::getTokenLocation);
+            String typeAndName = parser.currentName();
+            // we need to extract the type prefix from the name and throw error if it is not present
+            int delimiterPos = typeAndName.indexOf(InternalAggregation.TYPED_KEYS_DELIMITER);
+            String type = null;
+            String name = null;
+            if (delimiterPos > 0) {
+                type = typeAndName.substring(0, delimiterPos);
+                name = typeAndName.substring(delimiterPos + 1);
+            } else {
+                throw new ParsingException(parser.getTokenLocation(),
+                        "Cannot parse suggestion response without type information. Set [" + RestSearchAction.TYPED_KEYS_PARAM
+                                + "] parameter on the request to ensure the type information is added to the response output");
+            }
+            Suggestion suggestion = null;
+            Function<XContentParser, Entry> entryParser = null;
+            // the "size" parameter and the SortBy for TermSuggestion cannot be parsed from the response, use default values
+            // TODO investigate if we can use NamedXContentRegistry instead of this switch
+            switch (type) {
+                case Suggestion.NAME:
+                    suggestion = new Suggestion(name, -1);
+                    entryParser = Suggestion.Entry::fromXContent;
+                    break;
+                case PhraseSuggestion.NAME:
+                    suggestion = new PhraseSuggestion(name, -1);
+                    entryParser = PhraseSuggestion.Entry::fromXContent;
+                    break;
+                case TermSuggestion.NAME:
+                    suggestion = new TermSuggestion(name, -1, SortBy.SCORE);
+                    entryParser = TermSuggestion.Entry::fromXContent;
+                    break;
+                case CompletionSuggestion.NAME:
+                    suggestion = new CompletionSuggestion(name, -1);
+                    entryParser = CompletionSuggestion.Entry::fromXContent;
+                    break;
+                default:
+                    throw new ParsingException(parser.getTokenLocation(), "Unknown suggestion type [{}]", type);
+            }
+            ensureExpectedToken(XContentParser.Token.START_ARRAY, parser.nextToken(), parser::getTokenLocation);
+            while ((parser.nextToken()) != XContentParser.Token.END_ARRAY) {
+                suggestion.addTerm(entryParser.apply(parser));
+            }
+            return suggestion;
+        }
+
         /**
          * Represents a part from the suggest text with suggested options.
          */

+ 1 - 1
core/src/main/java/org/elasticsearch/search/suggest/completion/CompletionSuggestion.java

@@ -64,7 +64,7 @@ import static org.elasticsearch.search.suggest.Suggest.COMPARATOR;
  */
 public final class CompletionSuggestion extends Suggest.Suggestion<CompletionSuggestion.Entry> {
 
-    private static final String NAME = "completion";
+    public static final String NAME = "completion";
 
     public static final int TYPE = 4;
 

+ 1 - 1
core/src/main/java/org/elasticsearch/search/suggest/phrase/PhraseSuggestion.java

@@ -35,7 +35,7 @@ import java.io.IOException;
  */
 public class PhraseSuggestion extends Suggest.Suggestion<PhraseSuggestion.Entry> {
 
-    private static final String NAME = "phrase";
+    public static final String NAME = "phrase";
     public static final int TYPE = 3;
 
     public PhraseSuggestion() {

+ 1 - 1
core/src/main/java/org/elasticsearch/search/suggest/term/TermSuggestion.java

@@ -41,7 +41,7 @@ import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constru
  */
 public class TermSuggestion extends Suggestion<TermSuggestion.Entry> {
 
-    private static final String NAME = "term";
+    public static final String NAME = "term";
 
     public static final Comparator<Suggestion.Entry.Option> SCORE = new Score();
     public static final Comparator<Suggestion.Entry.Option> FREQUENCY = new Frequency();

+ 72 - 0
core/src/test/java/org/elasticsearch/search/suggest/SuggestTests.java

@@ -19,20 +19,92 @@
 
 package org.elasticsearch.search.suggest;
 
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.text.Text;
+import org.elasticsearch.common.xcontent.ToXContent;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.common.xcontent.XContentType;
+import org.elasticsearch.rest.action.search.RestSearchAction;
+import org.elasticsearch.search.suggest.Suggest.Suggestion;
+import org.elasticsearch.search.suggest.Suggest.Suggestion.Entry;
+import org.elasticsearch.search.suggest.Suggest.Suggestion.Entry.Option;
 import org.elasticsearch.search.suggest.completion.CompletionSuggestion;
 import org.elasticsearch.search.suggest.phrase.PhraseSuggestion;
 import org.elasticsearch.search.suggest.term.TermSuggestion;
 import org.elasticsearch.test.ESTestCase;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 
+import static org.elasticsearch.common.xcontent.XContentHelper.toXContent;
+import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken;
+import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureFieldName;
+import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertToXContentEquivalent;
 import static org.hamcrest.Matchers.equalTo;
 
 public class SuggestTests extends ESTestCase {
 
+    public static Suggest createTestItem() {
+        int numEntries = randomIntBetween(0, 5);
+        List<Suggestion<? extends Entry<? extends Option>>> suggestions = new ArrayList<>();
+        for (int i = 0; i < numEntries; i++) {
+            suggestions.add(SuggestionTests.createTestItem());
+        }
+        return new Suggest(suggestions);
+    }
+
+    public void testFromXContent() throws IOException {
+        ToXContent.Params params = new ToXContent.MapParams(Collections.singletonMap(RestSearchAction.TYPED_KEYS_PARAM, "true"));
+        Suggest suggest = createTestItem();
+        XContentType xContentType = randomFrom(XContentType.values());
+        boolean humanReadable = randomBoolean();
+        BytesReference originalBytes = toXContent(suggest, xContentType, params, humanReadable);
+        Suggest parsed;
+        try (XContentParser parser = createParser(xContentType.xContent(), originalBytes)) {
+            ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.nextToken(), parser::getTokenLocation);
+            ensureFieldName(parser, parser.nextToken(), Suggest.NAME);
+            ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.nextToken(), parser::getTokenLocation);
+            parsed = Suggest.fromXContent(parser);
+            assertEquals(XContentParser.Token.END_OBJECT, parser.currentToken());
+            assertEquals(XContentParser.Token.END_OBJECT, parser.nextToken());
+            assertNull(parser.nextToken());
+        }
+        assertEquals(suggest.size(), parsed.size());
+        for (Suggestion suggestion : suggest) {
+            Suggestion<? extends Entry<? extends Option>> parsedSuggestion = parsed.getSuggestion(suggestion.getName());
+            assertNotNull(parsedSuggestion);
+            assertEquals(suggestion.getClass(), parsedSuggestion.getClass());
+        }
+        assertToXContentEquivalent(originalBytes, toXContent(parsed, xContentType, params, humanReadable), xContentType);
+    }
+
+    public void testToXContent() throws IOException {
+        Option option = new Option(new Text("someText"), new Text("somethingHighlighted"), 1.3f, true);
+        Entry<Option> entry = new Entry<>(new Text("entryText"), 42, 313);
+        entry.addOption(option);
+        Suggestion<Entry<Option>> suggestion = new Suggestion<>("suggestionName", 5);
+        suggestion.addTerm(entry);
+        Suggest suggest = new Suggest(Collections.singletonList(suggestion));
+        BytesReference xContent = toXContent(suggest, XContentType.JSON, randomBoolean());
+        assertEquals(
+                "{\"suggest\":"
+                        + "{\"suggestionName\":"
+                            + "[{\"text\":\"entryText\","
+                            + "\"offset\":42,"
+                            + "\"length\":313,"
+                            + "\"options\":[{\"text\":\"someText\","
+                                        + "\"highlighted\":\"somethingHighlighted\","
+                                        + "\"score\":1.3,"
+                                        + "\"collate_match\":true}]"
+                            + "}]"
+                        + "}"
+                +"}",
+                xContent.utf8ToString());
+    }
+
     public void testFilter() throws Exception {
         List<Suggest.Suggestion<? extends Suggest.Suggestion.Entry<? extends Suggest.Suggestion.Entry.Option>>> suggestions;
         CompletionSuggestion completionSuggestion = new CompletionSuggestion(randomAsciiOfLength(10), 2);

+ 240 - 0
core/src/test/java/org/elasticsearch/search/suggest/SuggestionTests.java

@@ -0,0 +1,240 @@
+/*
+ * 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.search.suggest;
+
+import org.elasticsearch.common.ParsingException;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.text.Text;
+import org.elasticsearch.common.xcontent.ToXContent;
+import org.elasticsearch.common.xcontent.XContent;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.common.xcontent.XContentType;
+import org.elasticsearch.common.xcontent.json.JsonXContent;
+import org.elasticsearch.rest.action.search.RestSearchAction;
+import org.elasticsearch.search.suggest.Suggest.Suggestion;
+import org.elasticsearch.search.suggest.Suggest.Suggestion.Entry;
+import org.elasticsearch.search.suggest.Suggest.Suggestion.Entry.Option;
+import org.elasticsearch.search.suggest.completion.CompletionSuggestion;
+import org.elasticsearch.search.suggest.phrase.PhraseSuggestion;
+import org.elasticsearch.search.suggest.term.TermSuggestion;
+import org.elasticsearch.test.ESTestCase;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Supplier;
+
+import static org.elasticsearch.common.xcontent.XContentHelper.toXContent;
+import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken;
+import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertToXContentEquivalent;
+
+public class SuggestionTests extends ESTestCase {
+
+    @SuppressWarnings({ "unchecked" })
+    private static final Class<Suggestion<? extends Entry<? extends Option>>>[] SUGGESTION_TYPES = new Class[] {
+        Suggestion.class, TermSuggestion.class, PhraseSuggestion.class, CompletionSuggestion.class
+    };
+
+    public static Suggestion<? extends Entry<? extends Option>> createTestItem() {
+        return createTestItem(randomFrom(SUGGESTION_TYPES));
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    public static Suggestion<? extends Entry<? extends Option>> createTestItem(Class<? extends Suggestion> type) {
+        String name = randomAsciiOfLengthBetween(5, 10);
+        // note: size will not be rendered via "toXContent", only passed on internally on transport layer
+        int size = randomInt();
+        Supplier<Entry> entrySupplier = null;
+        Suggestion suggestion = null;
+        if (type == Suggestion.class) {
+            suggestion = new Suggestion(name, size);
+            entrySupplier = () -> SuggestionEntryTests.createTestItem(Entry.class);
+        } else if (type == TermSuggestion.class) {
+            suggestion = new TermSuggestion(name, size, randomFrom(SortBy.values()));
+            entrySupplier = () -> SuggestionEntryTests.createTestItem(TermSuggestion.Entry.class);
+        } else if (type == PhraseSuggestion.class) {
+            suggestion = new PhraseSuggestion(name, size);
+            entrySupplier = () -> SuggestionEntryTests.createTestItem(PhraseSuggestion.Entry.class);
+        } else if (type == CompletionSuggestion.class) {
+            suggestion = new CompletionSuggestion(name, size);
+            entrySupplier = () -> SuggestionEntryTests.createTestItem(CompletionSuggestion.Entry.class);
+        }
+        int numEntries;
+        if (frequently()) {
+            if (type == CompletionSuggestion.class) {
+                numEntries = 1; // CompletionSuggestion can have max. one entry
+            } else {
+                numEntries = randomIntBetween(1, 5);
+            }
+        } else {
+            numEntries = 0; // also occasionally test zero entries
+        }
+        for (int i = 0; i < numEntries; i++) {
+            suggestion.addTerm(entrySupplier.get());
+        }
+        return suggestion;
+    }
+
+    @SuppressWarnings({ "rawtypes" })
+    public void testFromXContent() throws IOException {
+        ToXContent.Params params = new ToXContent.MapParams(Collections.singletonMap(RestSearchAction.TYPED_KEYS_PARAM, "true"));
+        for (Class<Suggestion<? extends Entry<? extends Option>>> type : SUGGESTION_TYPES) {
+            Suggestion suggestion = createTestItem(type);
+            XContentType xContentType = randomFrom(XContentType.values());
+            boolean humanReadable = randomBoolean();
+            BytesReference originalBytes = toXContent(suggestion, xContentType, params, humanReadable);
+            Suggestion parsed;
+            try (XContentParser parser = createParser(xContentType.xContent(), originalBytes)) {
+                ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.nextToken(), parser::getTokenLocation);
+                ensureExpectedToken(XContentParser.Token.FIELD_NAME, parser.nextToken(), parser::getTokenLocation);
+                parsed = Suggestion.fromXContent(parser);
+                assertEquals(XContentParser.Token.END_OBJECT, parser.nextToken());
+                assertNull(parser.nextToken());
+            }
+            assertEquals(suggestion.getName(), parsed.getName());
+            assertEquals(suggestion.getEntries().size(), parsed.getEntries().size());
+            // We don't parse size via xContent, instead we set it to -1 on the client side
+            assertEquals(-1, parsed.getSize());
+            assertToXContentEquivalent(originalBytes, toXContent(parsed, xContentType, params, humanReadable), xContentType);
+        }
+    }
+
+    /**
+     * test that we throw error if RestSearchAction.TYPED_KEYS_PARAM isn't set while rendering xContent
+     */
+    public void testFromXContentFailsWithoutTypeParam() throws IOException {
+        XContentType xContentType = randomFrom(XContentType.values());
+        BytesReference originalBytes = toXContent(createTestItem(), xContentType, ToXContent.EMPTY_PARAMS, randomBoolean());
+        try (XContentParser parser = createParser(xContentType.xContent(), originalBytes)) {
+            ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.nextToken(), parser::getTokenLocation);
+            ensureExpectedToken(XContentParser.Token.FIELD_NAME, parser.nextToken(), parser::getTokenLocation);
+            ParsingException e = expectThrows(ParsingException.class, () -> Suggestion.fromXContent(parser));
+            assertEquals(
+                    "Cannot parse suggestion response without type information. "
+                    + "Set [typed_keys] parameter on the request to ensure the type information "
+                    + "is added to the response output", e.getMessage());
+        }
+    }
+
+    public void testUnknownSuggestionTypeThrows() throws IOException {
+        XContent xContent = JsonXContent.jsonXContent;
+        String suggestionString =
+                 "{\"unknownType#suggestionName\":"
+                    + "[{\"text\":\"entryText\","
+                    + "\"offset\":42,"
+                    + "\"length\":313,"
+                    + "\"options\":[{\"text\":\"someText\","
+                                + "\"highlighted\":\"somethingHighlighted\","
+                                + "\"score\":1.3,"
+                                + "\"collate_match\":true}]"
+                            + "}]"
+                + "}";
+        try (XContentParser parser = xContent.createParser(xContentRegistry(), suggestionString)) {
+            ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.nextToken(), parser::getTokenLocation);
+            ensureExpectedToken(XContentParser.Token.FIELD_NAME, parser.nextToken(), parser::getTokenLocation);
+            ParsingException e = expectThrows(ParsingException.class, () -> Suggestion.fromXContent(parser));
+            assertEquals("Unknown suggestion type [unknownType]", e.getMessage());
+        }
+    }
+
+    public void testToXContent() throws IOException {
+        ToXContent.Params params = new ToXContent.MapParams(Collections.singletonMap(RestSearchAction.TYPED_KEYS_PARAM, "true"));
+        {
+            Option option = new Option(new Text("someText"), new Text("somethingHighlighted"), 1.3f, true);
+            Entry<Option> entry = new Entry<>(new Text("entryText"), 42, 313);
+            entry.addOption(option);
+            Suggestion<Entry<Option>> suggestion = new Suggestion<>("suggestionName", 5);
+            suggestion.addTerm(entry);
+            BytesReference xContent = toXContent(suggestion, XContentType.JSON, params, randomBoolean());
+            assertEquals(
+                    "{\"suggestion#suggestionName\":[{"
+                            + "\"text\":\"entryText\","
+                            + "\"offset\":42,"
+                            + "\"length\":313,"
+                            + "\"options\":[{"
+                                + "\"text\":\"someText\","
+                                + "\"highlighted\":\"somethingHighlighted\","
+                                + "\"score\":1.3,"
+                                + "\"collate_match\":true}]"
+                            + "}]"
+                    + "}", xContent.utf8ToString());
+        }
+        {
+            Option option = new Option(new Text("someText"), new Text("somethingHighlighted"), 1.3f, true);
+            PhraseSuggestion.Entry entry = new PhraseSuggestion.Entry(new Text("entryText"), 42, 313, 1.0);
+            entry.addOption(option);
+            PhraseSuggestion suggestion = new PhraseSuggestion("suggestionName", 5);
+            suggestion.addTerm(entry);
+            BytesReference xContent = toXContent(suggestion, XContentType.JSON, params, randomBoolean());
+            assertEquals(
+                    "{\"phrase#suggestionName\":[{"
+                            + "\"text\":\"entryText\","
+                            + "\"offset\":42,"
+                            + "\"length\":313,"
+                            + "\"options\":[{"
+                                + "\"text\":\"someText\","
+                                + "\"highlighted\":\"somethingHighlighted\","
+                                + "\"score\":1.3,"
+                                + "\"collate_match\":true}]"
+                            + "}]"
+                    + "}", xContent.utf8ToString());
+        }
+        {
+            TermSuggestion.Entry.Option option = new TermSuggestion.Entry.Option(new Text("someText"), 10, 1.3f);
+            TermSuggestion.Entry entry = new TermSuggestion.Entry(new Text("entryText"), 42, 313);
+            entry.addOption(option);
+            TermSuggestion suggestion = new TermSuggestion("suggestionName", 5, SortBy.SCORE);
+            suggestion.addTerm(entry);
+            BytesReference xContent = toXContent(suggestion, XContentType.JSON, params, randomBoolean());
+            assertEquals(
+                    "{\"term#suggestionName\":[{"
+                        + "\"text\":\"entryText\","
+                        + "\"offset\":42,"
+                        + "\"length\":313,"
+                        + "\"options\":[{"
+                            + "\"text\":\"someText\","
+                            + "\"score\":1.3,"
+                            + "\"freq\":10}]"
+                        + "}]"
+                    + "}", xContent.utf8ToString());
+        }
+        {
+            Map<String, Set<CharSequence>> contexts = Collections.singletonMap("key", Collections.singleton("value"));
+            CompletionSuggestion.Entry.Option option = new CompletionSuggestion.Entry.Option(1, new Text("someText"), 1.3f, contexts);
+            CompletionSuggestion.Entry entry = new CompletionSuggestion.Entry(new Text("entryText"), 42, 313);
+            entry.addOption(option);
+            CompletionSuggestion suggestion = new CompletionSuggestion("suggestionName", 5);
+            suggestion.addTerm(entry);
+            BytesReference xContent = toXContent(suggestion, XContentType.JSON, params, randomBoolean());
+            assertEquals(
+                    "{\"completion#suggestionName\":[{"
+                        + "\"text\":\"entryText\","
+                        + "\"offset\":42,"
+                        + "\"length\":313,"
+                        + "\"options\":[{"
+                            + "\"text\":\"someText\","
+                            + "\"score\":1.3,"
+                            + "\"contexts\":{\"key\":[\"value\"]}"
+                        + "}]"
+                    + "}]}", xContent.utf8ToString());
+        }
+    }
+}