Skip to content

@JsonValue on enum with generic interface causes non-deterministic schema generation in OpenAPI 3.1 #5127

@olsavmic

Description

@olsavmic

Description

When an enum implements a generic interface (e.g. PersistableEnum<String>) and has @JsonValue on its getValue() method, the generated OpenAPI 3.1 schema non-deterministically omits "type": "string" for the enum property. The same code produces different output across JVM invocations (e.g. Maven Surefire vs IntelliJ test runner).

Root Cause

The Java compiler generates a bridge method Object getValue() alongside the real String getValue() when a class implements a generic interface. Crucially, the @JsonValue annotation is copied to the bridge method by the compiler:

public java.lang.String getValue();        // has @JsonValue ✓
public java.lang.Object getValue();        // ACC_BRIDGE, ACC_SYNTHETIC — also has @JsonValue ✓

In ModelResolver._createSchemaForEnum(), the @JsonValue method is found via:

clazz.getDeclaredMethods()
    .filter(m -> m.isAnnotationPresent(JsonValue.class))
    .filter(m -> m.getAnnotation(JsonValue.class).value())
    .findFirst()

Both methods pass the filters. getDeclaredMethods() returns methods in unspecified order per JVM spec. findFirst() picks whichever comes first:

  • If String getValue() wins → PrimitiveType.fromType(String.class)PrimitiveType.STRING → schema with correct type
  • If Object getValue() (bridge) wins → PrimitiveType.fromType(Object.class)null → falls through to default schema creation

Additionally, in ModelResolver.resolve(), findJsonValueType(beanDescription) (which uses Jackson's BeanDescription.findJsonValueAccessor()) may or may not detect the @JsonValue and return early — before _createSchemaForEnum() is ever called. This creates a second source of non-determinism in how the enum schema is constructed.

Reproducer

// Generic interface
public interface PersistableEnum<T> {
    T getValue();
}

// Enum with @JsonValue on getValue()
public enum PaymentState implements PersistableEnum<String> {
    CREATED("created"),
    IN_PROGRESS("in_progress"),
    CONFIRMED("confirmed");

    private final String value;

    PaymentState(String value) { this.value = value; }

    @JsonValue
    @Override
    public String getValue() { return value; }
}

// DTO using the enum
public class PaymentStatus {
    private PaymentState state;
    // getters/setters
}

Expected output (every time):

{
  "state": {
    "type": "string",
    "enum": ["created", "in_progress", "confirmed"]
  }
}

Actual output (non-deterministic — sometimes):

{
  "state": {
    "enum": ["created", "in_progress", "confirmed"]
  }
}

The "type": "string" is missing because the bridge method was picked first by getDeclaredMethods().

Suggested Fix

In _createSchemaForEnum(), filter out bridge/synthetic methods before searching for @JsonValue:

Arrays.stream(clazz.getDeclaredMethods())
    .filter(m -> !m.isBridge())                              // ← add this
    .filter(m -> m.isAnnotationPresent(JsonValue.class))
    .filter(m -> m.getAnnotation(JsonValue.class).value())
    .findFirst()

Environment

  • swagger-core: 2.2.41
  • springdoc-openapi: 2.8.15
  • Jackson: 2.21.2
  • Java: 25 (OpenJDK)
  • OpenAPI mode: 3.1 (openapi31 = true)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions