From c475ee664f9533660692c9a6419769501f402ec1 Mon Sep 17 00:00:00 2001
From: Sylvain Wallez <sylvain@elastic.co>
Date: Wed, 22 Jun 2022 16:46:06 +0200
Subject: [PATCH] Allow serializing aggregations without typed keys (#316)

---
 docs/troubleshooting/index.asciidoc           |   2 +
 .../serialize-without-typed-keys.asciidoc     |  24 +++
 .../clients/json/ExternallyTaggedUnion.java   |  64 +++++--
 .../co/elastic/clients/json/JsonpMapper.java  |  11 +-
 .../elastic/clients/json/JsonpMapperBase.java |  29 +++
 ...npMapper.java => JsonpMapperFeatures.java} |  26 +--
 .../clients/json/SimpleJsonpMapper.java       |   5 +
 .../json/jackson/JacksonJsonpMapper.java      |  22 ++-
 .../clients/json/jsonb/JsonbJsonpMapper.java  |   5 +
 .../util/WithJsonObjectBuilderBase.java       |  37 ++--
 .../troubleshooting/TroubleShootingTests.java |  93 +++++++++
 .../elasticsearch/model/TypedKeysTest.java    | 179 +++++++++++++++++-
 12 files changed, 436 insertions(+), 61 deletions(-)
 create mode 100644 docs/troubleshooting/serialize-without-typed-keys.asciidoc
 rename java-client/src/main/java/co/elastic/clients/json/{AttributedJsonpMapper.java => JsonpMapperFeatures.java} (59%)
 create mode 100644 java-client/src/test/java/co/elastic/clients/documentation/troubleshooting/TroubleShootingTests.java

diff --git a/docs/troubleshooting/index.asciidoc b/docs/troubleshooting/index.asciidoc
index 6be99e1b1..25435df68 100644
--- a/docs/troubleshooting/index.asciidoc
+++ b/docs/troubleshooting/index.asciidoc
@@ -7,6 +7,7 @@
 .Exceptions
 
 * <<missing-required-property>>
+* <<serialize-without-typed-keys>>
 
 
 // [[debugging]]
@@ -16,3 +17,4 @@
 // === Elasticsearch deprecation warnings
 
 include::missing-required-property.asciidoc[]
+include::serialize-without-typed-keys.asciidoc[]
diff --git a/docs/troubleshooting/serialize-without-typed-keys.asciidoc b/docs/troubleshooting/serialize-without-typed-keys.asciidoc
new file mode 100644
index 000000000..0d39c178d
--- /dev/null
+++ b/docs/troubleshooting/serialize-without-typed-keys.asciidoc
@@ -0,0 +1,24 @@
+[[serialize-without-typed-keys]]
+=== Serializing aggregations and suggestions without typed keys
+
+{es} search requests accept a `typed_key` parameter that allow returning type information along with the name in aggregation and suggestion results (see the {es-docs}/search-aggregations.html#return-agg-type[aggregations documentation] for additional details).
+
+The {java-client} always adds this parameter to search requests, as type information is needed to know the concrete class that should be used to deserialize aggregation and suggestion results.
+
+Symmetrically, the {java-client} also serializes aggregation and suggestion results using this `typed_keys` format, so that it can correctly deserialize the results of its own serialization.
+
+["source","java"]
+--------------------------------------------------
+ElasticsearchClient esClient = ...
+include-tagged::{doc-tests-src}/troubleshooting/TroubleShootingTests.java[aggregation-typed-keys]
+--------------------------------------------------
+
+However, in some use cases serializing objects in the `typed_keys` format may not be desirable, for example when the {java-client} is used in an application that acts as a front-end to other services that expect the default format for aggregations and suggestions.
+
+You can disable `typed_keys` serialization by setting the `JsonpMapperFeatures.SERIALIZE_TYPED_KEYS` attribute to `false` on your mapper object:
+
+["source","java"]
+--------------------------------------------------
+ElasticsearchClient esClient = ...
+include-tagged::{doc-tests-src}/troubleshooting/TroubleShootingTests.java[aggregation-no-typed-keys]
+--------------------------------------------------
diff --git a/java-client/src/main/java/co/elastic/clients/json/ExternallyTaggedUnion.java b/java-client/src/main/java/co/elastic/clients/json/ExternallyTaggedUnion.java
index ec5d52111..5dde8e5e8 100644
--- a/java-client/src/main/java/co/elastic/clients/json/ExternallyTaggedUnion.java
+++ b/java-client/src/main/java/co/elastic/clients/json/ExternallyTaggedUnion.java
@@ -153,7 +153,10 @@ public void deserializeEntry(String key, JsonParser parser, JsonpMapper mapper,
     }
 
     /**
-     * Serialize an externally tagged union using the typed keys encoding.
+     * Serialize a map of externally tagged union objects.
+     * <p>
+     * If {@link JsonpMapperFeatures#SERIALIZE_TYPED_KEYS} is <code>true</code> (the default), the typed keys encoding
+     * (<code>type#name</code>) is used.
      */
     static <T extends JsonpSerializable & TaggedUnion<? extends JsonEnum, ?>> void serializeTypedKeys(
         Map<String, T> map, JsonGenerator generator, JsonpMapper mapper
@@ -163,36 +166,65 @@ public void deserializeEntry(String key, JsonParser parser, JsonpMapper mapper,
         generator.writeEnd();
     }
 
+    /**
+     * Serialize a map of externally tagged union object arrays.
+     * <p>
+     * If {@link JsonpMapperFeatures#SERIALIZE_TYPED_KEYS} is <code>true</code> (the default), the typed keys encoding
+     * (<code>type#name</code>) is used.
+     */
     static <T extends JsonpSerializable & TaggedUnion<? extends JsonEnum, ?>> void serializeTypedKeysArray(
         Map<String, List<T>> map, JsonGenerator generator, JsonpMapper mapper
     ) {
         generator.writeStartObject();
-        for (Map.Entry<String, List<T>> entry: map.entrySet()) {
-            List<T> list = entry.getValue();
-            if (list.isEmpty()) {
-                continue; // We can't know the kind, skip this entry
-            }
 
-            generator.writeKey(list.get(0)._kind().jsonValue() + "#" + entry.getKey());
-            generator.writeStartArray();
-            for (T value: list) {
-                value.serialize(generator, mapper);
+        if (mapper.attribute(JsonpMapperFeatures.SERIALIZE_TYPED_KEYS, true)) {
+            for (Map.Entry<String, List<T>> entry: map.entrySet()) {
+                List<T> list = entry.getValue();
+                if (list.isEmpty()) {
+                    continue; // We can't know the kind, skip this entry
+                }
+
+                generator.writeKey(list.get(0)._kind().jsonValue() + "#" + entry.getKey());
+                generator.writeStartArray();
+                for (T value: list) {
+                    value.serialize(generator, mapper);
+                }
+                generator.writeEnd();
+            }
+        } else {
+            for (Map.Entry<String, List<T>> entry: map.entrySet()) {
+                generator.writeKey(entry.getKey());
+                generator.writeStartArray();
+                for (T value: entry.getValue()) {
+                    value.serialize(generator, mapper);
+                }
+                generator.writeEnd();
             }
-            generator.writeEnd();
         }
+
         generator.writeEnd();
     }
 
     /**
-     * Serialize an externally tagged union using the typed keys encoding, without the enclosing start/end object.
+     * Serialize a map of externally tagged union objects, without the enclosing start/end object.
+     * <p>
+     * If {@link JsonpMapperFeatures#SERIALIZE_TYPED_KEYS} is <code>true</code> (the default), the typed keys encoding
+     * (<code>type#name</code>) is used.
      */
     static <T extends JsonpSerializable & TaggedUnion<? extends JsonEnum, ?>> void serializeTypedKeysInner(
         Map<String, T> map, JsonGenerator generator, JsonpMapper mapper
     ) {
-        for (Map.Entry<String, T> entry: map.entrySet()) {
-            T value = entry.getValue();
-            generator.writeKey(value._kind().jsonValue() + "#" + entry.getKey());
-            value.serialize(generator, mapper);
+        if (mapper.attribute(JsonpMapperFeatures.SERIALIZE_TYPED_KEYS, true)) {
+            for (Map.Entry<String, T> entry: map.entrySet()) {
+                T value = entry.getValue();
+                generator.writeKey(value._kind().jsonValue() + "#" + entry.getKey());
+                value.serialize(generator, mapper);
+            }
+        } else {
+            for (Map.Entry<String, T> entry: map.entrySet()) {
+                generator.writeKey(entry.getKey());
+                entry.getValue().serialize(generator, mapper);
+            }
         }
     }
 }
diff --git a/java-client/src/main/java/co/elastic/clients/json/JsonpMapper.java b/java-client/src/main/java/co/elastic/clients/json/JsonpMapper.java
index a47992b9b..a4a259875 100644
--- a/java-client/src/main/java/co/elastic/clients/json/JsonpMapper.java
+++ b/java-client/src/main/java/co/elastic/clients/json/JsonpMapper.java
@@ -77,9 +77,12 @@ default <T> T attribute(String name, T defaultValue) {
     }
 
     /**
-     * Create a new mapper with a named attribute that delegates to this one.
+     * Create a new mapper with an additional attribute.
+     * <p>
+     * The {@link JsonpMapperFeatures} class contains the names of attributes that all implementations of
+     * <code>JsonpMapper</code> must implement.
+     *
+     * @see JsonpMapperFeatures
      */
-    default <T> JsonpMapper withAttribute(String name, T value) {
-        return new AttributedJsonpMapper(this, name, value);
-    }
+    <T> JsonpMapper withAttribute(String name, T value);
 }
diff --git a/java-client/src/main/java/co/elastic/clients/json/JsonpMapperBase.java b/java-client/src/main/java/co/elastic/clients/json/JsonpMapperBase.java
index b3204170d..f24b3daf5 100644
--- a/java-client/src/main/java/co/elastic/clients/json/JsonpMapperBase.java
+++ b/java-client/src/main/java/co/elastic/clients/json/JsonpMapperBase.java
@@ -25,12 +25,41 @@
 
 import javax.annotation.Nullable;
 import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
 
 public abstract class JsonpMapperBase implements JsonpMapper {
 
     /** Get a serializer when none of the builtin ones are applicable */
     protected abstract <T> JsonpDeserializer<T> getDefaultDeserializer(Class<T> clazz);
 
+    private Map<String, Object> attributes;
+
+    @Nullable
+    @Override
+    @SuppressWarnings("unchecked")
+    public <T> T attribute(String name) {
+        return attributes == null ? null : (T)attributes.get(name);
+    }
+
+    /**
+     * Updates attributes to a copy of the current ones with an additional key/value pair.
+     *
+     * Mutates the current mapper, intended to be used in implementations of {@link #withAttribute(String, Object)}
+     */
+    protected JsonpMapperBase addAttribute(String name, Object value) {
+        if (attributes == null) {
+            this.attributes = Collections.singletonMap(name, value);
+        } else {
+            Map<String, Object> newAttrs = new HashMap<>(attributes.size() + 1);
+            newAttrs.putAll(attributes);
+            newAttrs.put(name, value);
+            this.attributes = newAttrs;
+        }
+        return this;
+    }
+
     @Override
     public <T> T deserialize(JsonParser parser, Class<T> clazz) {
         JsonpDeserializer<T> deserializer = findDeserializer(clazz);
diff --git a/java-client/src/main/java/co/elastic/clients/json/AttributedJsonpMapper.java b/java-client/src/main/java/co/elastic/clients/json/JsonpMapperFeatures.java
similarity index 59%
rename from java-client/src/main/java/co/elastic/clients/json/AttributedJsonpMapper.java
rename to java-client/src/main/java/co/elastic/clients/json/JsonpMapperFeatures.java
index c3dec38d8..fad745c9d 100644
--- a/java-client/src/main/java/co/elastic/clients/json/AttributedJsonpMapper.java
+++ b/java-client/src/main/java/co/elastic/clients/json/JsonpMapperFeatures.java
@@ -19,27 +19,11 @@
 
 package co.elastic.clients.json;
 
-import javax.annotation.Nullable;
-
-class AttributedJsonpMapper extends DelegatingJsonpMapper {
-
-    private final String name;
-    private final Object value;
+/**
+ * Defines attribute names for {@link JsonpMapper} features.
+ */
+public class JsonpMapperFeatures {
 
-    AttributedJsonpMapper(JsonpMapper mapper, String name, Object value) {
-        super(mapper);
-        this.name = name;
-        this.value = value;
-    }
+    public static final String SERIALIZE_TYPED_KEYS = JsonpMapperFeatures.class.getName() + ":SERIALIZE_TYPED_KEYS";
 
-    @Override
-    @Nullable
-    @SuppressWarnings("unchecked")
-    public <T> T attribute(String name) {
-        if (this.name.equals(name)) {
-            return (T)this.value;
-        } else {
-            return mapper.attribute(name);
-        }
-    }
 }
diff --git a/java-client/src/main/java/co/elastic/clients/json/SimpleJsonpMapper.java b/java-client/src/main/java/co/elastic/clients/json/SimpleJsonpMapper.java
index 7faf59a0b..98591b3d7 100644
--- a/java-client/src/main/java/co/elastic/clients/json/SimpleJsonpMapper.java
+++ b/java-client/src/main/java/co/elastic/clients/json/SimpleJsonpMapper.java
@@ -77,6 +77,11 @@ public SimpleJsonpMapper() {
         this(true);
     }
 
+    @Override
+    public <T> JsonpMapper withAttribute(String name, T value) {
+        return new SimpleJsonpMapper(this.ignoreUnknownFields).addAttribute(name, value);
+    }
+
     @Override
     public boolean ignoreUnknownFields() {
         return ignoreUnknownFields;
diff --git a/java-client/src/main/java/co/elastic/clients/json/jackson/JacksonJsonpMapper.java b/java-client/src/main/java/co/elastic/clients/json/jackson/JacksonJsonpMapper.java
index 4a8410c70..3a6e827ff 100644
--- a/java-client/src/main/java/co/elastic/clients/json/jackson/JacksonJsonpMapper.java
+++ b/java-client/src/main/java/co/elastic/clients/json/jackson/JacksonJsonpMapper.java
@@ -39,18 +39,30 @@ public class JacksonJsonpMapper extends JsonpMapperBase {
     private final JacksonJsonProvider provider;
     private final ObjectMapper objectMapper;
 
+    private JacksonJsonpMapper(ObjectMapper objectMapper, JacksonJsonProvider provider) {
+        this.objectMapper = objectMapper;
+        this.provider = provider;
+    }
+
     public JacksonJsonpMapper(ObjectMapper objectMapper) {
-        this.objectMapper = objectMapper
-            .configure(SerializationFeature.INDENT_OUTPUT, false)
-            .setSerializationInclusion(JsonInclude.Include.NON_NULL);
-        // Creating the json factory from the mapper ensures it will be returned by JsonParser.getCodec()
-        this.provider = new JacksonJsonProvider(this.objectMapper.getFactory());
+        this(
+            objectMapper
+                .configure(SerializationFeature.INDENT_OUTPUT, false)
+                .setSerializationInclusion(JsonInclude.Include.NON_NULL),
+            // Creating the json factory from the mapper ensures it will be returned by JsonParser.getCodec()
+            new JacksonJsonProvider(objectMapper.getFactory())
+        );
     }
 
     public JacksonJsonpMapper() {
         this(new ObjectMapper());
     }
 
+    @Override
+    public <T> JsonpMapper withAttribute(String name, T value) {
+        return new JacksonJsonpMapper(this.objectMapper, this.provider).addAttribute(name, value);
+    }
+
     /**
      * Returns the underlying Jackson mapper.
      */
diff --git a/java-client/src/main/java/co/elastic/clients/json/jsonb/JsonbJsonpMapper.java b/java-client/src/main/java/co/elastic/clients/json/jsonb/JsonbJsonpMapper.java
index 823d3616a..d5f198fdd 100644
--- a/java-client/src/main/java/co/elastic/clients/json/jsonb/JsonbJsonpMapper.java
+++ b/java-client/src/main/java/co/elastic/clients/json/jsonb/JsonbJsonpMapper.java
@@ -54,6 +54,11 @@ public JsonbJsonpMapper() {
         this(JsonpUtils.provider(), JsonbProvider.provider());
     }
 
+    @Override
+    public <T> JsonpMapper withAttribute(String name, T value) {
+        return new JsonbJsonpMapper(this.jsonProvider, this.jsonb).addAttribute(name, value);
+    }
+
     @Override
     protected <T> JsonpDeserializer<T> getDefaultDeserializer(Class<T> clazz) {
         return new Deserializer<>(clazz);
diff --git a/java-client/src/main/java/co/elastic/clients/util/WithJsonObjectBuilderBase.java b/java-client/src/main/java/co/elastic/clients/util/WithJsonObjectBuilderBase.java
index 224407961..fc40dc3dc 100644
--- a/java-client/src/main/java/co/elastic/clients/util/WithJsonObjectBuilderBase.java
+++ b/java-client/src/main/java/co/elastic/clients/util/WithJsonObjectBuilderBase.java
@@ -47,22 +47,33 @@ public B withJson(JsonParser parser, JsonpMapper mapper) {
         }
 
         // Generic parameters are always deserialized to JsonData unless the parent mapper can provide a deserializer
-        mapper = new DelegatingJsonpMapper(mapper) {
-            @Override
-            public <T> T attribute(String name) {
-                T attr = mapper.attribute(name);
-                if (attr == null && name.startsWith("co.elastic.clients:Deserializer")) {
-                    @SuppressWarnings("unchecked")
-                    T result = (T)JsonData._DESERIALIZER;
-                    return result;
-                } else {
-                    return attr;
-                }
-            }
-        };
+        mapper = new WithJsonMapper(mapper);
 
         @SuppressWarnings("unchecked")
         ObjectDeserializer<B> builderDeser = (ObjectDeserializer<B>) DelegatingDeserializer.unwrap(classDeser);
         return builderDeser.deserialize(self(), parser, mapper, parser.next());
     }
+
+    private static class WithJsonMapper extends DelegatingJsonpMapper {
+        WithJsonMapper(JsonpMapper parent) {
+            super(parent);
+        }
+
+        @Override
+        public <T> T attribute(String name) {
+            T attr = mapper.attribute(name);
+            if (attr == null && name.startsWith("co.elastic.clients:Deserializer")) {
+                @SuppressWarnings("unchecked")
+                T result = (T)JsonData._DESERIALIZER;
+                return result;
+            } else {
+                return attr;
+            }
+        }
+
+        @Override
+        public <T> JsonpMapper withAttribute(String name, T value) {
+            return new WithJsonMapper(this.mapper.withAttribute(name, value));
+        }
+    }
 }
diff --git a/java-client/src/test/java/co/elastic/clients/documentation/troubleshooting/TroubleShootingTests.java b/java-client/src/test/java/co/elastic/clients/documentation/troubleshooting/TroubleShootingTests.java
new file mode 100644
index 000000000..7c1478244
--- /dev/null
+++ b/java-client/src/test/java/co/elastic/clients/documentation/troubleshooting/TroubleShootingTests.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. 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 co.elastic.clients.documentation.troubleshooting;
+
+import co.elastic.clients.documentation.DocTestsTransport;
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.elasticsearch.core.SearchResponse;
+import co.elastic.clients.elasticsearch.core.search.TotalHitsRelation;
+import co.elastic.clients.json.JsonpMapper;
+import co.elastic.clients.json.JsonpMapperFeatures;
+import jakarta.json.stream.JsonGenerator;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.StringWriter;
+import java.util.Collections;
+
+public class TroubleShootingTests extends Assertions {
+
+    @Test
+    public void testMapProperty() {
+
+        SearchResponse<Void> searchResponse = new SearchResponse.Builder<Void>()
+            .aggregations(
+                "price", _2 -> _2
+                    .avg(_3 -> _3.value(3.14))
+            )
+            // Required properties on a SearchResponse
+            .took(1)
+            .shards(_1 -> _1.successful(1).failed(0).total(1))
+            .hits(_1 -> _1
+                .total(_2 -> _2.value(0).relation(TotalHitsRelation.Eq))
+                .hits(Collections.emptyList())
+            )
+            .timedOut(false)
+            .build();
+
+        String json = "{\"took\":1,\"timed_out\":false,\"_shards\":{\"failed\":0.0,\"successful\":1.0,\"total\":1.0}," +
+            "\"hits\":{\"total\":{\"relation\":\"eq\",\"value\":0},\"hits\":[]},\"aggregations\":{\"avg#price\":{\"value\":3.14}}}";
+
+        DocTestsTransport transport = new DocTestsTransport();
+        ElasticsearchClient esClient = new ElasticsearchClient(transport);
+
+        {
+            //tag::aggregation-typed-keys
+            JsonpMapper mapper = esClient._jsonpMapper();
+
+            StringWriter writer = new StringWriter();
+            try (JsonGenerator generator = mapper.jsonProvider().createGenerator(writer)) {
+                mapper.serialize(searchResponse, generator);
+            }
+            String result = writer.toString();
+
+            // The aggregation property provides the "avg" type and "price" name
+            assertTrue(result.contains("\"aggregations\":{\"avg#price\":{\"value\":3.14}}}"));
+            //end::aggregation-typed-keys
+        }
+
+        {
+            //tag::aggregation-no-typed-keys
+            // Create a new mapper with the typed_keys feature disabled
+            JsonpMapper mapper = esClient._jsonpMapper()
+                .withAttribute(JsonpMapperFeatures.SERIALIZE_TYPED_KEYS, false);
+
+            StringWriter writer = new StringWriter();
+            try (JsonGenerator generator = mapper.jsonProvider().createGenerator(writer)) {
+                mapper.serialize(searchResponse, generator);
+            }
+            String result = writer.toString();
+
+            // The aggregation only provides the "price" name
+            assertTrue(result.contains("\"aggregations\":{\"price\":{\"value\":3.14}}}"));
+            //end::aggregation-no-typed-keys
+        }
+    }
+}
diff --git a/java-client/src/test/java/co/elastic/clients/elasticsearch/model/TypedKeysTest.java b/java-client/src/test/java/co/elastic/clients/elasticsearch/model/TypedKeysTest.java
index 6af52daba..463917025 100644
--- a/java-client/src/test/java/co/elastic/clients/elasticsearch/model/TypedKeysTest.java
+++ b/java-client/src/test/java/co/elastic/clients/elasticsearch/model/TypedKeysTest.java
@@ -26,10 +26,15 @@
 import co.elastic.clients.elasticsearch.core.SearchResponse;
 import co.elastic.clients.elasticsearch.core.search.TotalHitsRelation;
 import co.elastic.clients.json.JsonpDeserializer;
+import co.elastic.clients.json.JsonpMapper;
+import co.elastic.clients.json.JsonpMapperFeatures;
 import co.elastic.clients.util.ListBuilder;
 import co.elastic.clients.util.MapBuilder;
+import jakarta.json.spi.JsonProvider;
+import jakarta.json.stream.JsonGenerator;
 import org.junit.jupiter.api.Test;
 
+import java.io.StringWriter;
 import java.util.Collections;
 
 public class TypedKeysTest extends ModelTestCase {
@@ -64,6 +69,34 @@ public void testMapProperty() {
 
     }
 
+    @Test
+    public void testMapPropertyWithoutTypedKeys() {
+
+        SearchResponse<Void> resp = new SearchResponse.Builder<Void>()
+            .aggregations(
+                "foo", _2 -> _2
+                    .avg(_3 -> _3.value(3.14))
+            )
+            // Required properties on a SearchResponse
+            .took(1)
+            .shards(_1 -> _1.successful(1).failed(0).total(1))
+            .hits(_1 -> _1
+                .total(_2 -> _2.value(0).relation(TotalHitsRelation.Eq))
+                .hits(Collections.emptyList())
+            )
+            .timedOut(false)
+            .build();
+
+        // Note "foo" and not "avg#foo" below
+        String json = "{\"took\":1,\"timed_out\":false,\"_shards\":{\"failed\":0.0,\"successful\":1.0,\"total\":1.0}," +
+            "\"hits\":{\"total\":{\"relation\":\"eq\",\"value\":0},\"hits\":[]},\"aggregations\":{\"foo\":{\"value\":3.14}}}";
+
+        JsonpMapper newMapper = mapper.withAttribute(JsonpMapperFeatures.SERIALIZE_TYPED_KEYS, false);
+
+        assertEquals(json, toJson(resp, newMapper));
+    }
+
+
     @Test
     public void testAdditionalProperties() {
 
@@ -105,8 +138,8 @@ public void testAdditionalProperties() {
         String json = "{\"took\":1,\"timed_out\":false,\"_shards\":{\"failed\":0.0,\"successful\":1.0,\"total\":1.0}," +
             "\"hits\":{\"total\":{\"relation\":\"eq\",\"value\":0},\"hits\":[]}," +
             "\"aggregations\":{\"sterms#foo\":{\"buckets\":[" +
-                "{\"avg#bar\":{\"value\":1.0},\"doc_count\":1,\"key\":\"key_1\"}," +
-                "{\"avg#bar\":{\"value\":2.0},\"doc_count\":2,\"key\":\"key_2\"}" +
+            "{\"avg#bar\":{\"value\":1.0},\"doc_count\":1,\"key\":\"key_1\"}," +
+            "{\"avg#bar\":{\"value\":2.0},\"doc_count\":2,\"key\":\"key_2\"}" +
             "],\"sum_other_doc_count\":1}}}";
 
         assertEquals(json, toJson(resp));
@@ -120,4 +153,146 @@ public void testAdditionalProperties() {
         assertEquals("key_2", foo.buckets().array().get(1).key());
         assertEquals(2.0, foo.buckets().array().get(1).aggregations().get("bar").avg().value(), 0.01);
     }
+
+    // Example taken from
+    // https://www.elastic.co/guide/en/elasticsearch/reference/8.2/search-aggregations-bucket-reverse-nested-aggregation.html
+    private static final String nestedJsonWithTypedKeys = "{\n" +
+        "  \"took\": 0," +
+        "  \"timed_out\": false," +
+        "  \"_shards\": {\n" +
+        "    \"successful\": 1,\n" +
+        "    \"failed\": 0,\n" +
+        "    \"skipped\": 0,\n" +
+        "    \"total\": 1\n" +
+        "  },\n" +
+        "  \"hits\": {\n" +
+        "    \"hits\": [],\n" +
+        "    \"total\": {\n" +
+        "      \"relation\": \"eq\",\n" +
+        "      \"value\": 5\n" +
+        "    },\n" +
+        "    \"max_score\": null\n" +
+        "  }," +
+        "  \"aggregations\" : {\n" +
+        "    \"nested#comments\" : {\n" +
+        "      \"doc_count\" : 3,\n" +
+        "      \"sterms#top_usernames\" : {\n" +
+        "        \"doc_count_error_upper_bound\" : 0,\n" +
+        "        \"sum_other_doc_count\" : 0,\n" +
+        "        \"buckets\" : [\n" +
+        "          {\n" +
+        "            \"key\" : \"dan\",\n" +
+        "            \"doc_count\" : 3,\n" +
+        "            \"reverse_nested#comment_to_issue\" : {\n" +
+        "              \"doc_count\" : 1,\n" +
+        "              \"sterms#top_tags_per_comment\" : {\n" +
+        "                \"doc_count_error_upper_bound\" : 0,\n" +
+        "                \"sum_other_doc_count\" : 0,\n" +
+        "                \"buckets\" : [\n" +
+        "                  {\n" +
+        "                    \"key\" : \"tag1\",\n" +
+        "                    \"doc_count\" : 1\n" +
+        "                  },\n" +
+        "                  {\n" +
+        "                    \"key\" : \"tag2\",\n" +
+        "                    \"doc_count\" : 1\n" +
+        "                  }\n" +
+        "                ]\n" +
+        "              }\n" +
+        "            }\n" +
+        "          }\n" +
+        "        ]\n" +
+        "      }\n" +
+        "    }\n" +
+        "  }\n" +
+        "}";
+
+    @Test
+    public void testSerializeNested() {
+
+        SearchResponse<?> response = fromJson(nestedJsonWithTypedKeys, SearchResponse.class);
+
+        // Check some deeply nested properties
+        StringTermsBucket bucket = response
+            .aggregations().get("comments").nested()
+            .aggregations().get("top_usernames").sterms()
+            .buckets().array().get(0)
+            .aggregations().get("comment_to_issue").reverseNested()
+            .aggregations().get("top_tags_per_comment").sterms()
+            .buckets().array().get(0);
+
+        assertEquals("tag1", bucket.key());
+        assertEquals(1, bucket.docCount());
+
+        // Check that it's typed_keys encoded
+        String serialized = toJson(response);
+        assertTrue(serialized.contains("nested#comments"));
+        assertTrue(serialized.contains("sterms#top_usernames"));
+        assertTrue(serialized.contains("reverse_nested#comment_to_issue"));
+        assertTrue(serialized.contains("sterms#top_tags_per_comment"));
+
+        {
+            // Test direct serialization
+            JsonProvider jsonProvider = mapper.jsonProvider();
+            StringWriter stringWriter = new StringWriter();
+            JsonGenerator generator = jsonProvider.createGenerator(stringWriter);
+            response.serialize(generator, mapper);
+            generator.close();
+
+            String directSerialized = stringWriter.toString();
+            assertTrue(directSerialized.contains("nested#comments"));
+            assertTrue(directSerialized.contains("sterms#top_usernames"));
+            assertTrue(directSerialized.contains("reverse_nested#comment_to_issue"));
+            assertTrue(directSerialized.contains("sterms#top_tags_per_comment"));
+
+        }
+
+        // Re-parse and re-check
+        response = fromJson(serialized, SearchResponse.class);
+
+        bucket = response
+            .aggregations().get("comments").nested()
+            .aggregations().get("top_usernames").sterms()
+            .buckets().array().get(0)
+            .aggregations().get("comment_to_issue").reverseNested()
+            .aggregations().get("top_tags_per_comment").sterms()
+            .buckets().array().get(0);
+
+        assertEquals("tag1", bucket.key());
+        assertEquals(1, bucket.docCount());
+
+
+        JsonProvider jsonProvider = mapper.jsonProvider();
+        StringWriter stringWriter = new StringWriter();
+        JsonGenerator generator = jsonProvider.createGenerator(stringWriter);
+        response.serialize(generator, mapper);
+        generator.close();
+
+        System.out.println(stringWriter.toString());
+    }
+
+    @Test
+    public void testSerializeNestedWithoutTypedKeys() {
+
+        SearchResponse<?> response = fromJson(nestedJsonWithTypedKeys, SearchResponse.class);
+
+        // Check that it's typed_keys encoded
+        String serialized = toJson(response);
+        assertTrue(serialized.contains("nested#comments"));
+        assertTrue(serialized.contains("sterms#top_usernames"));
+        assertTrue(serialized.contains("reverse_nested#comment_to_issue"));
+        assertTrue(serialized.contains("sterms#top_tags_per_comment"));
+
+        // Build the non-typed_keys version (replace 'type#' with 'type#name')
+        serialized = serialized.replaceAll("\"[^\"]*#", "\"");
+        assertFalse(serialized.contains("nested#comments"));
+        assertFalse(serialized.contains("sterms#top_usernames"));
+        assertFalse(serialized.contains("reverse_nested#comment_to_issue"));
+        assertFalse(serialized.contains("sterms#top_tags_per_comment"));
+
+        // Serialize without typed keys
+        JsonpMapper newMapper = mapper.withAttribute(JsonpMapperFeatures.SERIALIZE_TYPED_KEYS, false);
+        assertEquals(serialized, toJson(response, newMapper));
+
+    }
 }