James Baker
James Baker

Reputation: 1256

Jackson with ParameterNamesModule failing to deserialize class

I have the following classes (which I've simplified from what I'm actually trying to do, but the Exception is the same):

ParameterNameTest.java

package foo;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import org.junit.Test;

import java.lang.reflect.Constructor;
import java.lang.reflect.Parameter;

public class ParameterNameTest {

  @Test
  public void test() throws Exception {
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(new ParameterNamesModule(JsonCreator.Mode.PROPERTIES));

    Settings settings = objectMapper.readValue("{\"ignoreDate\": true}", DateSettings.class);
  }

  @Test
  public void testConstructorParameters() {
    Constructor[] constructors = DateSettings.class.getConstructors();
    for (Constructor constructor : constructors) {
      System.out.print(constructor.getName() + "( ");
      Parameter[] parameters = constructor.getParameters();
      for (Parameter parameter : parameters) {
        System.out.print(parameter.getType().getName() + " " + parameter.getName() + " ");
      }
      System.out.println(")");
    }
  }
}

DateSettings.java

package foo;

public class DateSettings implements Settings {
  private final boolean ignoreDate;

  public DateSettings(boolean ignoreDate) {
    this.ignoreDate = ignoreDate;
  }

  public boolean isIgnoreDate() {
    return ignoreDate;
  }
}

Settings.java

package foo;

public interface Settings {}

My understanding from reading the Jackson documentation and other posts on StackOverflow, is that this should allow me to deserialize my JSON object into a DateSettings class without annotating that class (which I can't do, as in my real case it is a third party library). However, I get the following Exception:

com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `foo.DateSettings` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: (String)"{"ignoreDates": true}"; line: 1, column: 2]

    at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:63)
    at com.fasterxml.jackson.databind.DeserializationContext.reportInputMismatch(DeserializationContext.java:1343)
    at com.fasterxml.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1032)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1297)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:326)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:159)
    at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4013)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3004)
    at foo.ParameterNameTest.test(ParameterNameTest.java:18)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:566)
    at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
    at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
    at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
    at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
    at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
    at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
    at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
    at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
    at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
    at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
    at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
    at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
    at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
    at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
    at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
    at com.intellij.rt.execution.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:47)
    at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:242)
    at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:70)

I'm aware that I need -parameters enabled when compiling, and using the testConstructorParameters test above I've convinced myself that this isn't the problem.

Where else could I be going wrong?

Update: Looks like this is being caused by this issue, https://github.com/FasterXML/jackson-databind/issues/1498, which has been open for quite a while now. If anyone has a workaround, I'd be interested to see it.

Upvotes: 1

Views: 4067

Answers (1)

pandaadb
pandaadb

Reputation: 6456

so I had a slow day and this was an interesting problem. I found the same issues on the github jackson page and it does seem like this is a regression. There is a way around it that I found that is a bit hacky but preserves backwards compatibility (I hope) and solves your direct issue. I needed to copy a few classes over due to package-privacy. If you were to put all your classes into the same jackson package, you might be able to get around it. Here is my solution in full (with the right dependencies, you should be able to paste and run this):

package com.paandadb.test;

import java.io.IOException;
import java.lang.reflect.Executable;
import java.lang.reflect.MalformedParametersException;
import java.lang.reflect.Parameter;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonCreator.Mode;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.cfg.MapperConfig;
import com.fasterxml.jackson.databind.introspect.Annotated;
import com.fasterxml.jackson.databind.introspect.AnnotatedConstructor;
import com.fasterxml.jackson.databind.introspect.AnnotatedMember;
import com.fasterxml.jackson.databind.introspect.AnnotatedMethod;
import com.fasterxml.jackson.databind.introspect.AnnotatedParameter;
import com.fasterxml.jackson.databind.introspect.AnnotatedWithParams;
import com.fasterxml.jackson.databind.introspect.NopAnnotationIntrospector;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;

public class Test {

    public static void main(String[] args) throws JsonParseException, JsonMappingException, IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new HackedParameterModule(JsonCreator.Mode.PROPERTIES));

        DateSettings settings = objectMapper.readValue("{\"ignoreDate\": false}", DateSettings.class);
        System.out.println(settings.ignoreDate);
    }

    public static class DateSettings {
        private final boolean ignoreDate;

        public DateSettings(boolean ignoreDate) {
          this.ignoreDate = ignoreDate;
        }

        public boolean isIgnoreDate() {
          return ignoreDate;
        }
      }

    // we need to hack this because `ParameterExtractor` is package private, so we can't extend this class
    public static class HackedAnnotationIntrospector extends ParameterNamesAnnotationIntrospector {
        private static final long serialVersionUID = 1L; 

        HackedAnnotationIntrospector(Mode creatorBinding, ParameterExtractor parameterExtractor) {
            super(creatorBinding, parameterExtractor);
        }
    }

    // we need to register a different introspector for the annotations
    public static class HackedParameterModule extends ParameterNamesModule {
        private static final long serialVersionUID = 1L;
        public HackedParameterModule(Mode properties) {
            super(properties);
        }

        @Override
        public void setupModule(SetupContext context) {
            super.setupModule(context);
            context.insertAnnotationIntrospector(new ParameterNamesAnnotationIntrospector(JsonCreator.Mode.DEFAULT, new ParameterExtractor()));
        }
    }

    // This is the hacked introspector that simply returns default instead of null 
    // you may want to make more checks here to make sure it works the way you want to and has no 
    // side effects. Same thing here - need to extend because of package private `ParameterExtractor`
    public static class ParameterNamesAnnotationIntrospector extends NopAnnotationIntrospector {
        private static final long serialVersionUID = 1L;

        private final JsonCreator.Mode creatorBinding;
        private final ParameterExtractor parameterExtractor;

        ParameterNamesAnnotationIntrospector(JsonCreator.Mode creatorBinding, ParameterExtractor parameterExtractor)
        {
            this.creatorBinding = creatorBinding;
            this.parameterExtractor = parameterExtractor;
        }

        @Override
        public String findImplicitPropertyName(AnnotatedMember m) {
            if (m instanceof AnnotatedParameter) {
                return findParameterName((AnnotatedParameter) m);
            }
            return null;
        }

        private String findParameterName(AnnotatedParameter annotatedParameter) {
            Parameter[] params;
            try {
                params = getParameters(annotatedParameter.getOwner());
            } catch (MalformedParametersException e) {
                return null;
            }

            Parameter p = params[annotatedParameter.getIndex()];
            return p.isNamePresent() ? p.getName() : null;
        }

        private Parameter[] getParameters(AnnotatedWithParams owner) {
            if (owner instanceof AnnotatedConstructor) {
                return parameterExtractor.getParameters(((AnnotatedConstructor) owner).getAnnotated());
            }
            if (owner instanceof AnnotatedMethod) {
                return parameterExtractor.getParameters(((AnnotatedMethod) owner).getAnnotated());
            }

            return null;
        }

        /*
        /**********************************************************
        /* Creator information handling
        /**********************************************************
         */

        @Override
        public JsonCreator.Mode findCreatorAnnotation(MapperConfig<?> config, Annotated a) {
            JsonCreator ann = _findAnnotation(a, JsonCreator.class);
            // THIS IS THE FIXING BIT
            // Note: I only enable this for your specific class, all other cases are handled in default manner 
            Class<?> rawType = a.getRawType();
            if(ann == null && rawType.isAssignableFrom(DateSettings.class)) { 
                return JsonCreator.Mode.DEFAULT;
            }
            if (ann != null) {
                JsonCreator.Mode mode = ann.mode();
                // but keep in mind that there may be explicit default for this module
                if ((creatorBinding != null)
                        && (mode == JsonCreator.Mode.DEFAULT)) {
                    mode = creatorBinding;
                }
                return mode;
            }
            return null;
        }

        // I left the other functions from the original code in to prevent breakage 
        @Override
        @Deprecated // remove AFTER 2.9
        public JsonCreator.Mode findCreatorBinding(Annotated a) {
            JsonCreator ann = _findAnnotation(a, JsonCreator.class);
            if (ann != null) {
                JsonCreator.Mode mode = ann.mode();
                if ((creatorBinding != null)
                        && (mode == JsonCreator.Mode.DEFAULT)) {
                    mode = creatorBinding;
                }
                return mode;
            }
            return creatorBinding;
        }

        @Override
        @Deprecated // since 2.9
        public boolean hasCreatorAnnotation(Annotated a)
        {
            // 02-Mar-2017, tatu: Copied from base AnnotationIntrospector
            JsonCreator ann = _findAnnotation(a, JsonCreator.class);
            if (ann != null) {
                return (ann.mode() != JsonCreator.Mode.DISABLED);
            }
            return false;
        }
    }

    // This is the package private class that does not allow for proper extending
    // which is why we had to copy a bunch of code 
    public static class ParameterExtractor {

        public Parameter[] getParameters(Executable executable) {
            return executable.getParameters();
        }
    }
}

I left a number of comments in the code but will go into a bit of detail here:

Firstly, your problem is located in ParameterNamesAnnotationIntrospector#findCreatorAnnotation. This class is not enabled to simply find a non annotated constructor. Debugging the code with an annotation reveals that annotating the constructor simply results in a default JsonCreator.Mode function. This means that if we want our code to work, we need it to recognise a default where there isn't. Firstly though, we need to get our own module in. So we do this:

public static class HackedParameterModule extends ParameterNamesModule {
        private static final long serialVersionUID = 1L;
        public HackedParameterModule(Mode properties) {
            super(properties);
        }

        @Override
        public void setupModule(SetupContext context) {
            super.setupModule(context);
            context.insertAnnotationIntrospector(new ParameterNamesAnnotationIntrospector(JsonCreator.Mode.DEFAULT, new ParameterExtractor()));
        }
    }

This registers a custom module that extends the ParameterNamesModule. It is only needed because we need to register a custom ParameterNamesAnnotationIntrospector as well. And that one uses a class ParameterExtractor which is package private, therefore we need to step through the hierarchy.

Clicking through and registering we get to the introspector. Here I added this code:

@Override
public JsonCreator.Mode findCreatorAnnotation(MapperConfig<?> config, Annotated a) {
            JsonCreator ann = _findAnnotation(a, JsonCreator.class);
            // THIS IS THE FIXING BIT
            // Note: I only enable this for your specific class, all other cases are handled in default manner 
            Class<?> rawType = a.getRawType();
            if(ann == null && rawType.isAssignableFrom(DateSettings.class)) { 
                return JsonCreator.Mode.DEFAULT;
            }
            if (ann != null) {
                JsonCreator.Mode mode = ann.mode();
                // but keep in mind that there may be explicit default for this module
                if ((creatorBinding != null)
                        && (mode == JsonCreator.Mode.DEFAULT)) {
                    mode = creatorBinding;
                }
                return mode;
            }
            return null;
        }

This code simply adds a new default behaviour. Iff the annotation isn't found, and we would end up in an exception, and the class raw type we try to create is DateSettings, then we simply return a default mode, which is the mode we need to enable jackson to use the 1 constructor that is available. Note: this will very likely break if you had a class with multiple constructors, I have not tried this

With all of this registered, I can run my main function and get no errors and print out the right values:

DateSettings settings = objectMapper.readValue("{\"ignoreDate\": false}", DateSettings.class);
System.out.println(settings.ignoreDate);
settings = objectMapper.readValue("{\"ignoreDate\": true}", DateSettings.class);
System.out.println(settings.ignoreDate);

Prints:

false
true

I am not sure whether this is a decent solution. But if you are really stuck and there are no other changes that you can make, this is a way to customise jackson to your advantage.

I hope that helps!

Note: I left the names the same which may cause import problems for you. It might be worth renaming all the classes into something more distinguishable :)

Artur

Upvotes: 4

Related Questions