Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to serialize a List content to a flat JSON object with Jackson?

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.

  • See: https://stackoverflow.com/a/41651324/356895

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

Related posts

  • Serialize List content in a flat structure in jackson json (Java)
  • Jackson: How to add custom property to the JSON without modifying the POJO
  • How to serialize only the ID of a child with Jackson

Related posts (deserialization)

  • Jackson list deserialization. nested Lists

Related library

  • Jackson JSON Interceptor Module
like image 219
JJD Avatar asked Aug 15 '17 17:08

JJD


2 Answers

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;
        }
    }
}
like image 91
ug_ Avatar answered Oct 22 '22 20:10

ug_


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;
    }

}
like image 33
charles-allen Avatar answered Oct 22 '22 18:10

charles-allen