Search before asking

  • [x] I searched in the issues and found nothing similar.

Describe the bug

I opened a Spring Boot issue, but it seems the issue is Jackson related. See https://github.com/spring-projects/spring-boot/issues/46994#issuecomment-3236302850 for the full details and a reproducer.

Version Information

2.19.2

Reproduction

package com.example.jsontesterbug;

import static org.assertj.core.api.Assertions.assertThat;

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.util.Arrays;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;

class MessageWrapperTest {

  private MessageWrapper<?> wildcardWrapper;

  private MessageWrapper<EmailSettings> specificWrapper;

  private ObjectMapper objectMapper = new ObjectMapper();

  @Test
  void wildcardWrapper() throws NoSuchFieldException, SecurityException, JsonProcessingException {
      serializeWithTypeFromField("wildcardWrapper");
  }

  @Test
  void specificWrapper() throws NoSuchFieldException, SecurityException, JsonProcessingException {
      serializeWithTypeFromField("specificWrapper");
  }

  private void serializeWithTypeFromField(String field) throws NoSuchFieldException, SecurityException, JsonProcessingException {
      MessageWrapper<EmailSettings> wrapper = new MessageWrapper<>(new EmailSettings("me@me.com"),
              "Sample Message");
      Type genericType = MessageWrapperTest.class.getDeclaredField(field).getGenericType();
      TypeVariable<?>[] typeParameters = ((Class<?>)((ParameterizedType)genericType).getRawType()).getTypeParameters();
      Type[] bounds = typeParameters[0].getBounds();
      System.out.println(field);
      System.out.println("  Generic type: " + genericType);
      System.out.println("    Bounds: " + Arrays.toString(bounds));
      JavaType jacksonType = this.objectMapper.constructType(genericType);
      System.out.println("  Jackson type: " + jacksonType);
      String json = this.objectMapper.writerFor(jacksonType).writeValueAsString(wrapper);
      System.out.println("  JSON: " + json);
      assertThat(json).contains("\"type\":\"EMAIL\"");
      assertThat(json).contains("\"email\":\"me@me.com\"");
  }

}

Running this gives this output:

wildcardWrapper
  Generic type: com.example.jsontesterbug.MessageWrapper<?>
    Bounds: [interface com.example.jsontesterbug.Settings]
  Jackson type: [simple type, class com.example.jsontesterbug.MessageWrapper<java.lang.Object>]
  JSON: {"settings":{"email":"me@me.com"},"message":"Sample Message"}
specificWrapper
  Generic type: com.example.jsontesterbug.MessageWrapper<com.example.jsontesterbug.EmailSettings>
    Bounds: [interface com.example.jsontesterbug.Settings]
  Jackson type: [simple type, class com.example.jsontesterbug.MessageWrapper<com.example.jsontesterbug.EmailSettings>]
  JSON: {"settings":{"type":"EMAIL","email":"me@me.com"},"message":"Sample Message"}

Expected behavior

The JSON output should be the same in both cases.

Additional context

No response

Comment From: pjfanning

Can you provide the definition of the MessageWrapper class?

Comment From: wimdeblauwe

You can view it in the zip file of the Spring Boot issue as well, but here it is:

public record MessageWrapper<T extends Settings>(T settings, String message) {
}

This is the Settings interface:

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;

@JsonTypeInfo(use = Id.NAME, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(
        value = EmailSettings.class,
        name = EmailSettings.TYPE),
    @JsonSubTypes.Type(
        value = PhoneSettings.class,
        name = PhoneSettings.TYPE),
})
public interface Settings {

}

With EmailSettings:

public record EmailSettings(String email) implements Settings {

  public static final String TYPE = "EMAIL";
}

and PhoneSettings:

public record PhoneSettings(String phoneNumber) implements Settings {

  public static final String TYPE = "PHONE";
}

Comment From: cowtowncoder

Probably makes no difference, but there is now 2.20.0 release to try out.

Comment From: cowtowncoder

This:

Jackson type: [simple type, class com.example.jsontesterbug.MessageWrapper<java.lang.Object>]

shows underlying problem: type is resolved as MessageWrapper<?> and that's why @JsonTypeInfo does not take effect. This is from getting

private MessageWrapper<?> wildcardWrapper;

method signature, which for some reason does not handle bounds for MessageWrapper type parameter.

Test could probably be simplified to just that type resolution problem.

@yawkat might be familiar with this problem actually.