Search code examples
javajacksondeserialization

Deserialization of generated sub-class results in the parent object


Versions: Swagger-codegen (v3): 3.0.11 | Jackson: 2.9.8

I currently generate my classes from a swagger.yaml file (indentation may differ due to double copy-paste):

---
swagger: "2.0"
info:
  description: "servicename"
  version: "1.0"
  title: "servicename"
schemes:
  - "http"
  - "https"
paths:

definitions:
  Notification:
    type: "object"
    discriminator: "typeOfNotification"
    properties:
      typeOfNotification:
        description: "Type of notification"
        enum:
          - "EMAIL"
          - "SMS"
        default: "EMAIL"
  EmailNotification:
    allOf:
    - $ref: "#/definitions/Notification"
    - type: "object"
      properties:
        name:
          type: "string"
          example: "Name of reciever"
          description: "Name of the receiver"
        emailAddress:
          type: "string"
          example: "info@stackoverflow.com"
          description: "Email address of the receiver"

Which generates two classes:

Notification.java

package nl.test;

import java.util.Objects;
import java.util.Arrays;
import com.google.gson.TypeAdapter;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.IOException;
/**
 * Notification
 */

@javax.annotation.Generated(value = "io.swagger.codegen.v3.generators.java.JavaClientCodegen", date = "2019-09-26T16:01:12.401+02:00[Europe/Amsterdam]")
public abstract class Notification {
  /**
   * Type of notification
   */
  @JsonAdapter(TypeOfNotificationEnum.Adapter.class)
  public enum TypeOfNotificationEnum {
    EMAIL("EMAIL"),
    SMS("SMS");

    private String value;

    TypeOfNotificationEnum(String value) {
      this.value = value;
    }
    public String getValue() {
      return value;
    }

    @Override
    public String toString() {
      return String.valueOf(value);
    }
    public static TypeOfNotificationEnum fromValue(String text) {
      for (TypeOfNotificationEnum b : TypeOfNotificationEnum.values()) {
        if (String.valueOf(b.value).equals(text)) {
          return b;
        }
      }
      return null;
    }
    public static class Adapter extends TypeAdapter<TypeOfNotificationEnum> {
      @Override
      public void write(final JsonWriter jsonWriter, final TypeOfNotificationEnum enumeration) throws IOException {
        jsonWriter.value(enumeration.getValue());
      }

      @Override
      public TypeOfNotificationEnum read(final JsonReader jsonReader) throws IOException {
        String value = jsonReader.nextString();
        return TypeOfNotificationEnum.fromValue(String.valueOf(value));
      }
    }
  }  @SerializedName("typeOfNotification")
  private TypeOfNotificationEnum typeOfNotification = null;

  public Notification typeOfNotification(TypeOfNotificationEnum typeOfNotification) {
    this.typeOfNotification = typeOfNotification;
    return this;
  }


  @Schema(description = "Type of notification")
  public TypeOfNotificationEnum getTypeOfNotification() {
    return typeOfNotification;
  }

  public void setTypeOfNotification(TypeOfNotificationEnum typeOfNotification) {
    this.typeOfNotification = typeOfNotification;
  }


  @Override
  public boolean equals(java.lang.Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    Notification notification = (Notification) o;
    return Objects.equals(this.typeOfNotification, notification.typeOfNotification);
  }

  @Override
  public int hashCode() {
    return Objects.hash(typeOfNotification);
  }
}

And the EmailNotification:

package nl.test;

import java.util.Objects;
import java.util.Arrays;
import com.google.gson.TypeAdapter;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.IOException;
import nl.test.Notification;
/**
 * EmailNotification
 */

@javax.annotation.Generated(value = "io.swagger.codegen.v3.generators.java.JavaClientCodegen", date = "2019-09-26T16:01:12.401+02:00[Europe/Amsterdam]")
public class EmailNotification extends Notification {
  @SerializedName("name")
  private String name = null;

  @SerializedName("emailAddress")
  private String emailAddress = null;

  public EmailNotification name(String name) {
    this.name = name;
    return this;
  }

   /**
   * Name of the receiver
   * @return name
  **/
  @Schema(example = "Name of receiver", description = "Name of the receiver")
  public String getName() {
    return name;
  }

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

  public EmailNotification emailAddress(String emailAddress) {
    this.emailAddress = emailAddress;
    return this;
  }

   /**
   * Email address of the receiver
   * @return emailAddress
  **/
  @Schema(example = "test@stackoverflow.com", description = "Email address of the receiver")
  public String getEmailAddress() {
    return emailAddress;
  }

  public void setEmailAddress(String emailAddress) {
    this.emailAddress = emailAddress;
  }


  @Override
  public boolean equals(java.lang.Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
     EmailNotification = (EmailNotification) o;
    return Objects.equals(this.name, emailNotification.name) &&
        Objects.equals(this.emailAddress, emailNotification.emailAddress) &&
        super.equals(o);
  }

  @Override
  public int hashCode() {
    return Objects.hash(name, emailAddress, super.hashCode());
  }

}

Which is nice. But now the problem occurs during the deserialization of the JSON. When an ObjectMapper is passed the JSON corresponding to a User class, which has one property of type Notification, and the JSON is actually of type EmailNotification, then for some reason the deserializer doesn't care and deserializes it into a Notification, thus ignoring the emailAddress property.

Corresponding test class:

package nl.test;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.junit.MockitoJUnitRunner;
import org.threeten.bp.LocalDate;

import static com.fasterxml.jackson.databind.MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS;
import static org.junit.Assert.*;


@RunWith(MockitoJUnitRunner.class)
public class ObjectMappersConfigurationTest {

    @Test
    public void test() throws Exception {
        String json = "{\n" +
                "  \"user\": {\n" +
                "    \"notification\": {\n" +
                "      \"name\": \"somename\",\n" +
                "      \"emailAddress\": \"test@stackoverflow.com\",\n" +
                "      \"typeOfNotification\": \"EMAIL\"\n" +
                "    },\n" +
                "  }\n" +
                "}\n";

        ObjectMapper mapper = createJsonObjectMapper();
        User order = mapper.readValue(json, User.class);

        assertEquals(1, 1);
    }

    public ObjectMapper createJsonObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        setDefaultRelaxObjectMappings(objectMapper);
        return objectMapper;
    }

    public static void setDefaultRelaxObjectMappings(ObjectMapper objectMapper) {
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        objectMapper.configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false);

        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        objectMapper.enable(ACCEPT_CASE_INSENSITIVE_ENUMS);
    }
}

And the output will be:

User {
  notification {
     notificationType: EMAIL
  }
}

Where I expected:

User {
  notification {
     notificationType: EMAIL,
     emailAddress: "...",
     name: "..."
  }
}

Now I know that I can use a custom deserializer for this purpose, but this seems like overkill, deserializing objects with inheritance should be a simple setting I suppose. I just can't seem to find it.

Enabling default typing would be an alternative but I'd rather refrain from that since our YAML files come from an external party, forcing them to default type their YAML isn't the best solution IMO.


Solution

  • Turns out that the @JsonSubType annotation was missing. And since the ObjectMapper does not communicate with swagger, it couldn't ever know that the class should be mapped to a EmailNotification and thus would always create a Notification object.

    Adding:

    <plugin>
       ....
       <configuration>
         ...
         <library>jersey2</library>
       </configuration>
    </plugin>
    

    Fixed it, the class annotations for the Notification class are now generated as:

    @JsonSubTypes({
      @JsonSubTypes.Type(value = SMSNotification.class, name = "SMSNotification"),
      @JsonSubTypes.Type(value = EmailNotification.class, name = "EmailNotification"),
    })
    public class Notification {
        ....
    

    This adds the mapping which the ObjectMapper understands while deserializing.

    Special thanks to the answer provided here swagger-codegen client: How to include jackson annotations on models