10

Given the following POJOs ..

public class City {

    private String title;
    private List<Person> people;
}

...

public class Person {

    private String name;
    private int age;
}

I would like to let Jackson serialize instances of the classes to the following example JSON:

{
    "title" : "New York",
    "personName_1" : "Jane Doe",
    "personAge_1" : 42,
    "personName_2" : "John Doe",
    "personAge_2" : 23 
}

The JSON format is defined by an external API which I cannot change.

I already found that I can annotate the list field with a custom serializer such as:

@JsonSerialize(using = PeopleSerializer.class)
private List<Person> people;

... and here is a basic implementation I tried:

public class PeopleSerializer extends JsonSerializer<List<Person>> {

    private static final int START_INDEX = 1;

    @Override
    public void serialize(List<Person> people, 
                          JsonGenerator generator, 
                          SerializerProvider provider) throws IOException {
        for (int i = 0; i < people.size(); ++i) {
            Person person = people.get(i);
            int index = i + START_INDEX;
            serialize(person, index, generator);
        }
    }

    private void serialize(Person person, int index, JsonGenerator generator) throws IOException {
        generator.writeStringField(getIndexedFieldName("personName", index), 
                                   person.getName());
        generator.writeNumberField(getIndexedFieldName("personAge", index), 
                                   person.getAge());
    }

    private String getIndexedFieldName(String fieldName, int index) {
        return fieldName + "_" + index;
    }

}

However, this fails with an:

JsonGenerationException: Can not write a field name, expecting a value

I also looked into using Jackson's Converter interface but that's not suitable for unwrapping the nested list objects.

I am also aware of @JsonUnwrapped but it is not designed to be used with lists.

Related posts

Related posts (deserialization)

Related library

JJD
  • 50,076
  • 60
  • 203
  • 339
  • I think you have to write your `JsonSerializer` for the `City` class, this is because the serializer for `City` writes the field name `people` then expects your custom `JsonSerializer` to write the value. – ug_ Aug 15 '17 at 18:30
  • @ug_ I tried that and annotated the `City` class with `@JsonSerialize(using = CitySerializer.class)`. Within the custom serializer I only serialized the `people` field similar to what I did in the `PeopleSerializer` above. The serialization fails with the same error message. – JJD Aug 16 '17 at 08:28
  • @JJD - On the off-chance there's a simpler approach, what is the root problem you're trying to solve? I can't imagine a case where your target JSON structure makes it easier to parse... – charles-allen Aug 16 '17 at 09:27
  • 2
    @CodeConfident I am not sure if I understand your question correctly. But if you are refering to the expected JSON structure (flattening the nested `Person` list) then I have to tell that **I am not in control of the remote API** and cannot change the format. Therefore, I need to compensate this design flaw in the client. Does this answer your question? – JJD Aug 16 '17 at 09:33
  • @JJD - I stupidly only just read your previous discussion. I think your CitySerializer might have been missing the `writeStartObject()` and `writeEndObject()` lines (i.e. the root node is a value once it's wrapped in braces). – charles-allen Aug 16 '17 at 10:07
  • Faced with same JSON format. How did you deserialize them to list? – Waka Waka Apr 11 '20 at 20:32

2 Answers2

4

You can use the BeanSerializerModifier to directly modify how a property name and value are written. Using this you could detect if a custom annotation is present, in this case I made one called @FlattenCollection. When the annotation is present the array or collection is not written using the normal method but instead written by a custom property writer (FlattenCollectionPropertyWriter).

This annotation will likely break on 2d arrays or other edge cases, I havent tested those but you could probably code for them without to much trouble, at least throw a meaningful error.

Heres the full working code. Notable points are

  • FlattenCollectionSerializerModifier.changeProperties
  • FlattenCollectionPropertyWriter.serializeAsField
  • The couple TODOs i put in there for you.

Output:

{
  "titleCity" : "New York",
  "personName_1" : "Foo",
  "personAge_1" : 123,
  "personName_2" : "Baz",
  "personAge_2" : 22
}

Code:

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.ser.*;
import com.fasterxml.jackson.databind.util.NameTransformer;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.*;

public class SO45698499 {


    public static void main(String [] args) throws Exception {
        ObjectWriter writer = createMapper().writerWithDefaultPrettyPrinter();
        String val = writer.writeValueAsString(new City("New York",
                Arrays.asList(new Person("Foo", 123), new Person("Baz", 22))));

        System.out.println(val);
    }


    /**
     * Constructs our mapper with the serializer modifier in mind
     * @return
     */
    public static ObjectMapper createMapper() {
        FlattenCollectionSerializerModifier modifier = new FlattenCollectionSerializerModifier();
        SerializerFactory sf = BeanSerializerFactory.instance.withSerializerModifier(modifier);
        ObjectMapper mapper = new ObjectMapper();
        mapper.setSerializerFactory(sf);

        return mapper;
    }

    @Target({ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface FlattenCollection {
    }

    /**
     * Looks for the FlattenCollection annotation and modifies the bean writer
     */
    public static class FlattenCollectionSerializerModifier extends BeanSerializerModifier {

        @Override
        public List<BeanPropertyWriter> changeProperties(SerializationConfig config, BeanDescription beanDesc, List<BeanPropertyWriter> beanProperties) {
            for (int i = 0; i < beanProperties.size(); i++) {
                BeanPropertyWriter writer = beanProperties.get(i);
                FlattenCollection annotation = writer.getAnnotation(FlattenCollection.class);
                if (annotation != null) {
                    beanProperties.set(i, new FlattenCollectionPropertyWriter(writer));
                }
            }
            return beanProperties;
        }
    }

    /**
     * Instead of writing a collection as an array, flatten the objects down into values.
     */
    public static class FlattenCollectionPropertyWriter extends BeanPropertyWriter {
        private final BeanPropertyWriter writer;

        public FlattenCollectionPropertyWriter(BeanPropertyWriter writer) {
            super(writer);
            this.writer = writer;
        }

        @Override
        public void serializeAsField(Object bean,
                                     JsonGenerator gen,
                                     SerializerProvider prov) throws Exception {
            Object arrayValue = writer.get(bean);

            // lets try and look for array and collection values
            final Iterator iterator;
            if(arrayValue != null && arrayValue.getClass().isArray()) {
                // deal with array value
                iterator = Arrays.stream((Object[])arrayValue).iterator();
            } else if(arrayValue != null && Collection.class.isAssignableFrom(arrayValue.getClass())) {
                iterator = ((Collection)arrayValue).iterator();
            } else {
                iterator = null;
            }

            if(iterator == null) {
                // TODO: write null? skip? dunno, you gonna figure this one out
            } else {
                int index=0;
                while(iterator.hasNext()) {
                    index++;
                    Object value = iterator.next();
                    if(value == null) {
                        // TODO: skip null values and still increment or maybe dont increment? You decide
                    } else {
                        // TODO: OP - update your prefix/suffix here, its kinda weird way of making a prefix
                        final String prefix = value.getClass().getSimpleName().toLowerCase();
                        final String suffix = "_"+index;
                        prov.findValueSerializer(value.getClass())
                                .unwrappingSerializer(new FlattenNameTransformer(prefix, suffix))
                                .serialize(value, gen, prov);
                    }
                }
            }
        }
    }

    public static class FlattenNameTransformer extends NameTransformer {

        private final String prefix;
        private final String suffix;

        public FlattenNameTransformer(String prefix, String suffix) {
            this.prefix = prefix;
            this.suffix = suffix;
        }

        @Override
        public String transform(String name) {
            // captial case the first letter, to prepend the suffix
            String transformedName = Character.toUpperCase(name.charAt(0)) + name.substring(1);
            return prefix + transformedName + suffix;
        }
        @Override
        public String reverse(String transformed) {
            if (transformed.startsWith(prefix)) {
                String str = transformed.substring(prefix.length());
                if (str.endsWith(suffix)) {
                    return str.substring(0, str.length() - suffix.length());
                }
            }
            return null;
        }
        @Override
        public String toString() { return "[FlattenNameTransformer('"+prefix+"','"+suffix+"')]"; }
    }


    /*===============================
     * POJOS
     ===============================*/
    public static class Person {
        private String name;
        private int age;

        public Person(String name, int age) {
            this.name = name;
            this.age = age;
        }

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public int getAge() {
            return age;
        }

        public void setAge(int age) {
            this.age = age;
        }
    }

    public static class City {
        private String titleCity;
        private List<Person> people;

        public City(String title, List<Person> people) {
            this.titleCity = title;
            this.people = people;
        }

        public String getTitleCity() {
            return titleCity;
        }

        public void setTitleCity(String titleCity) {
            this.titleCity = titleCity;
        }

        @FlattenCollection
        public List<Person> getPeople() {
            return people;
        }

        public void setPeople(List<Person> people) {
            this.people = people;
        }
    }
}
ug_
  • 11,267
  • 2
  • 35
  • 52
  • WOW! Impressing! Where did you find out about this option to customize the serialization? - I successfully integrated the classes as far as `FlattenCollectionPropertyWriter` is instantiated however `serializeAsField()` is **not** called. – JJD Aug 22 '17 at 16:59
  • @JJD I forget where I found it originally, I refreshed my memory with a fair amount of java doc searching in my IDE. If the `serializeAsField()` is not being called you might check the other `seralizeAs***` methods on `BeanPropertyWriter`. Also make sure that the `FlattenCollectionPropertyWriter` is being created. Those methods would be called in specific scenarios described by their javadoc, however Im having trouble imagining why they would be called for this property but that would be a good place to put a breakpoint. – ug_ Aug 22 '17 at 17:23
  • Somehow the method is invoked today. I can't really tell what went wrong yesterday. Maybe, it was just too late. - Thank you for your answer. Great help! – JJD Aug 23 '17 at 11:50
1

Based on this link I suspect the field-level annotation only delegates writing the value not entire properties.

A (rather kludgey) workaround might be to have a custom serializer for the entire City class:

@JsonSerialize(using = CitySerializer.class)
public class City {
    private String title;
    @JsonIgnore
    private List<Person> people;
}

...and then

public class CitySerializer extends JsonSerializer<City> {

    private static final int START_INDEX = 1;

    @Override
    public void serialize(City city, 
                          JsonGenerator generator, 
                          SerializerProvider provider) throws IOException {
        generator.writeStartObject();

        // Write all properties (except ignored) 
        JavaType javaType = provider.constructType(City.class);
        BeanDescription beanDesc = provider.getConfig().introspect(javaType);
        JsonSerializer<Object> serializer = BeanSerializerFactory.instance.findBeanSerializer(provider,
                javaType,
                beanDesc);
        serializer.unwrappingSerializer(null).serialize(value, jgen, provider);`

        // Custom serialization of people
        List<Person> people = city.getPeople();
        for (int i = 0; i < people.size(); ++i) {
            Person person = people.get(i);
            int index = i + START_INDEX;
            serialize(person, index, generator);
        }

        generator.writeEndObject();
    }

    private void serialize(Person person, int index, JsonGenerator generator) throws IOException {
        generator.writeStringField(getIndexedFieldName("personName", index), 
                                   person.getName());
        generator.writeNumberField(getIndexedFieldName("personAge", index), 
                                   person.getAge());
    }

    private String getIndexedFieldName(String fieldName, int index) {
        return fieldName + "_" + index;
    }

}
charles-allen
  • 3,891
  • 2
  • 23
  • 35
  • Thank you, that looks promising. One big drawback however is that I cannot **just** define the special handling for the `people` field. I also need to manually write **all other** fields (there are more in reality) of the `City` class. Whenever the `City` class changes I would have to update the serializer, too. That makes it hard to maintain. Also discussed [here](https://stackoverflow.com/questions/14714328/jackson-how-to-add-custom-property-to-the-json-without-modifying-the-pojo). – JJD Aug 16 '17 at 11:02
  • It would be great if I could just **intercept** the serialization of `City` and manually write the custom JSON representation of the `people` field. This might also be called a *delegation serializer* for a specific field. Maybe, I am just search by the wrong terms. – JJD Aug 16 '17 at 13:14
  • @JJD - I totally agree with you. I've had a stab at it, but I can't easily test right now. – charles-allen Aug 17 '17 at 06:27