2

I am using Jackson JSON to convert some JSON objects to POJO classes. This deserialization should be case insensitive, and it should not allow attributes with case insensitive duplicate names.

Configuring the ObjectMapper as shown below enables case insensitive deserialization and failing upon attributes which have strictly the same name:

final ObjectMapper objectMapper;
objectMapper.configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true);
objectMapper.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION);

However it does not fail when the input contains two attributes with same names and different case, such as the following:

{
   "Name": "name01",
   "NAME": "name02"
}

Is there any way to configureObjectMapper to fail under such circumstances?

Serchu
  • 119
  • 7

1 Answers1

1

From STRICT_DUPLICATE_DETECTION documentation:

Feature that determines whether JsonParser will explicitly check that no duplicate JSON Object field names are encountered. If enabled, parser will check all names within context and report duplicates by throwing a JsonParseException; if disabled, parser will not do such checking. Assumption in latter case is that caller takes care of handling duplicates at a higher level: data-binding, for example, has features to specify detection to be done there. Note that enabling this feature will incur performance overhead due to having to store and check additional information: this typically adds 20-30% to execution time for basic parsing.

JSON by default is case sensitive and this one of main reason why making it insensitive is not enabled by default in Jackson. But we can extend basic implementation and add validation. We need to extend com.fasterxml.jackson.databind.deser.BeanDeserializerModifier and com.fasterxml.jackson.databind.deser.BeanDeserializer which deserialises POJO classes. Below solution depends from version you are using because I copied some code from base class which is not ready for intercepting extra functionality. If you do not have any extra configuration for your POJO classes vanillaDeserialize method will be invoked and this one we will try to improve. Let's implement it:

class InsensitiveBeanDeserializerModifier extends BeanDeserializerModifier {

    @Override
    public JsonDeserializer<?> modifyDeserializer(DeserializationConfig config, BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
        JsonDeserializer<?> base = super.modifyDeserializer(config, beanDesc, deserializer);
        if (base instanceof BeanDeserializer) {
            return new InsensitiveBeanDeserializer((BeanDeserializer) base);
        }

        return base;
    }
}

class InsensitiveBeanDeserializer extends BeanDeserializer {

    public InsensitiveBeanDeserializer(BeanDeserializerBase src) {
        super(src);
    }

    public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        // common case first
        if (p.isExpectedStartObjectToken()) {
            if (_vanillaProcessing) {
                return vanillaDeserialize(p, ctxt, p.nextToken());
            }
            // 23-Sep-2015, tatu: This is wrong at some many levels, but for now... it is
            //    what it is, including "expected behavior".
            p.nextToken();
            if (_objectIdReader != null) {
                return deserializeWithObjectId(p, ctxt);
            }
            return deserializeFromObject(p, ctxt);
        }
        return _deserializeOther(p, ctxt, p.getCurrentToken());
    }

    protected Object vanillaDeserialize(JsonParser p, DeserializationContext ctxt, JsonToken t) throws IOException {
        final Object bean = _valueInstantiator.createUsingDefault(ctxt);
        // [databind#631]: Assign current value, to be accessible by custom serializers
        p.setCurrentValue(bean);
        Map<String, String> names = new HashMap<>();
        if (p.hasTokenId(JsonTokenId.ID_FIELD_NAME)) {
            String propName = p.getCurrentName();
            do {
                String oldName = names.put(propName.toLowerCase(), propName);
                if (oldName != null) {
                    String msg = "Properties '" + propName + "' and '" + oldName + "' are the same!";
                    throw new DuplicateInsensitiveKeysException(p, msg);
                }

                defaultImplementation(p, ctxt, bean, propName);
            } while ((propName = p.nextFieldName()) != null);
        }
        return bean;
    }

    private void defaultImplementation(JsonParser p, DeserializationContext ctxt, Object bean, String propName) throws IOException {
        p.nextToken();
        SettableBeanProperty prop = _beanProperties.find(propName);

        if (prop != null) { // normal case
            try {
                prop.deserializeAndSet(p, ctxt, bean);
            } catch (Exception e) {
                wrapAndThrow(e, bean, propName, ctxt);
            }
            return;
        }
        handleUnknownVanilla(p, ctxt, bean, propName);
    }

    public static class DuplicateInsensitiveKeysException extends JsonMappingException {

        public DuplicateInsensitiveKeysException(Closeable processor, String msg) {
            super(processor, msg);
        }
    }
}

Example usage:

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.core.JsonTokenId;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.deser.BeanDeserializer;
import com.fasterxml.jackson.databind.deser.BeanDeserializerBase;
import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
import com.fasterxml.jackson.databind.deser.SettableBeanProperty;
import com.fasterxml.jackson.databind.module.SimpleModule;

import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class JsonApp {

    public static void main(String[] args) throws Exception {
        File jsonFile = new File("./resource/test.json").getAbsoluteFile();

        SimpleModule module = new SimpleModule();
        module.setDeserializerModifier(new InsensitiveBeanDeserializerModifier());

        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(module);

        mapper.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES);

        System.out.println(mapper.readValue(jsonFile, User.class));
    }
}

For above JSON payloads prints:

Exception in thread "main" InsensitiveBeanDeserializer$DuplicateInsensitiveKeysException: Properties 'NAME' and 'Name' are the same!
Michał Ziober
  • 37,175
  • 18
  • 99
  • 146
  • 1
    Thanks a lot for the detail answer, it was really nice of you! I will try you proposal right away and let you know how it went. – Serchu Mar 29 '19 at 11:27
  • It did work, the only issue is that the proposed solution is not compatible with additional configuration, such as `ACCEPT_CASE_INSENSITIVE_PROPERTIES`, but still I think I can figure out something. Thanks again for taking the time to provide such a complete answer! – Serchu Apr 02 '19 at 10:03
  • @Serchu, I am glad to hear that. I tested this solution with `ACCEPT_CASE_INSENSITIVE_PROPERTIES` and it worked for me without any problems. Example `InsensitiveBeanDeserializer` uses implementation from `BeanDeserializer` which comes from `Jackson` and depends from `Jackson`'s version. My example works properly with latest version [2.9.8](https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core/2.9.8). In case you use different version you need to copy code from current version. Could you try to create simple app and use above example with `2.9.8` version? – Michał Ziober Apr 02 '19 at 10:15