openapi-generator icon indicating copy to clipboard operation
openapi-generator copied to clipboard

[BUG][SPRING] oneOf type: Jackson trying to instantiate interface instead of implementation

Open navaneeth-spotnana opened this issue 1 year ago • 13 comments

Description

I'm using the oneOf feature to define several possible schemas that can go into a response body of my service. In the generated Java client code, the Java implementations of these schemas implement an interface, but when I send a request through, Jackson is trying to create an instance of the interface, instead of the concrete class.

The same issue was reported and fixed in swagger-codegen project: https://github.com/swagger-api/swagger-codegen/issues/10011

openapi-generator version

6.4.0

OpenAPI declaration file content or url
openapi: 3.0.1
info:
  version: '2.0'
  title: ABC API documentation
  ...
  responses:
    ...
    ...
    CancellationDuration:
      type: object
      oneOf:
        - $ref: '#/components/schemas/DeadlineAbsolute'
        - $ref: '#/components/schemas/DeadlineDurationBeforeArrival'
Generation Details

openapi-generator-maven-plugin is used, full configuration:

      <plugin>
        <groupId>org.openapitools</groupId>
        <artifactId>openapi-generator-maven-plugin</artifactId>
        <version>6.4.0</version>
        <configuration>
          <generateSupportingFiles>true</generateSupportingFiles>
          <generatorName>spring</generatorName>
          <generateApiDocumentation>true</generateApiDocumentation>
          <generateModelDocumentation>true</generateModelDocumentation>
          <configOptions>
            <oas3>true</oas3>
            <useTags>true</useTags>
            <withSeparateModelsAndApi>true</withSeparateModelsAndApi>
            <delegatePattern>true</delegatePattern>
            <withInterfaces>true</withInterfaces>
            <library>spring-boot</library>
            <dateLibrary>java8</dateLibrary>
            <useSpringfox>false</useSpringfox>
            <useSpringController>true</useSpringController>
            <modelPropertyNaming>camelCase</modelPropertyNaming>
          </configOptions>
          <!-- Option to prevent openapi generator to strip off common prefix from enum values -->
          <additionalProperties>removeEnumValuePrefix=false</additionalProperties>
          <!-- <skipIfSpecIsUnchanged>true</skipIfSpecIsUnchanged>-->
        </configuration>
        <executions>
          <execution>
            <id>spring-execution</id>
            <goals>
              <goal>generate</goal>
            </goals>
            <configuration>
              <ignoreFileOverride>${project.basedir}/.openapi-generator-ignore</ignoreFileOverride>
              <inputSpec>${project.basedir}/src/main/resources/yaml/apis/api/Api.yaml</inputSpec>
              <generatorName>spring</generatorName>
              <apiPackage>com.mycompany.obt.master.apis</apiPackage>
              <modelPackage>com.mycompany.obt.master.models</modelPackage>
            </configuration>
          </execution>
          <execution>
            <id>typescript-execution</id>
            <goals>
              <goal>generate</goal>
            </goals>
            <configuration>
              <inputSpec>${project.basedir}/src/main/resources/yaml/apis/api/Api.yaml</inputSpec>
              <generatorName>typescript-axios</generatorName>
              <apiPackage>com.mycompany.obt.master.apis</apiPackage>
              <modelPackage>com.mycompany.obt.master.models</modelPackage>
            </configuration>
          </execution>
        </executions>
      </plugin>

This results in an empty interface

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2023-03-31T09:08:52.189665+05:30[Asia/Kolkata]")
public interface CancellationDuration {}

And two concrete classes:

@Schema(name = "DeadlineAbsolute", description = "Absolute Date and time before which cancellation policy is applicable")
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2023-03-31T09:13:38.604952+05:30[Asia/Kolkata]")
public class DeadlineAbsolute implements CancellationDuration { ... }


@Schema(name = "DeadlineDurationBeforeArrival", description = "Time before arrival when cancellation policy is applicable")
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2023-03-31T09:13:38.604952+05:30[Asia/Kolkata]")
public class DeadlineDurationBeforeArrival implements CancellationDuration { ... }

This error occurs on deserialization:

Caused by: org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class com.mycompany.obt.master.models.CancellationDuration]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.mycompany.obt.master.models.CancellationDuration` (no Creators, like default constructor, exist): abstract types either need to be mapped to concrete types, have custom deserializer, or contain additional type information
 at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1762, column: 32]
	at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.readJavaType(AbstractJackson2HttpMessageConverter.java:388)
	at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.read(AbstractJackson2HttpMessageConverter.java:343)
	at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters(AbstractMessageConverterMethodArgumentResolver.java:185)
	at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.readWithMessageConverters(RequestResponseBodyMethodProcessor.java:160)
	at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.resolveArgument(RequestResponseBodyMethodProcessor.java:133)
	at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:122)
	at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:179)

navaneeth-spotnana avatar Mar 31 '23 03:03 navaneeth-spotnana

same issue

Yash-Kudesia avatar Apr 07 '23 08:04 Yash-Kudesia

same problem here

kgignatyev avatar May 17 '23 16:05 kgignatyev

Same on 6.6.0

fastluca avatar May 22 '23 14:05 fastluca

In order to fix this issue you need to define the discriminator object for the oneOf relation:

    CancellationDuration:
      type: object
      oneOf:
        - $ref: '#/components/schemas/DeadlineAbsolute'
        - $ref: '#/components/schemas/DeadlineDurationBeforeArrival'
      discriminator:
        propertyName: refType

All possible schemas (in this case DeadlineAbsolute and DeadlineDurationBeforeArrival) need a property with the discriminator property name of type string.

After that the generated class (CancellationDuration.class) should contain the class annotations @JsonTypeInfo and @JsonSubTypes which enables Jackson to perform the instantiation correctly.

I do not know if ithis way of implementing the oneOf type is expected but it was the only working solution I found.

(Tested with openapi-generator version 6.6.0)

Dimibe avatar Jun 09 '23 12:06 Dimibe

same issue

ArtHardziy avatar Jul 14 '23 13:07 ArtHardziy

If is useful in your case (one implementation is an "extension" of the others), you can define implementation which will be used instead of interface. https://stackoverflow.com/questions/12688503/jackson-how-to-specify-a-single-implementation-for-interface-referenced-deseri

prodejna avatar Jul 19 '23 11:07 prodejna

In order to fix this issue you need to define the discriminator object for the oneOf relation:

    CancellationDuration:
      type: object
      oneOf:
        - $ref: '#/components/schemas/DeadlineAbsolute'
        - $ref: '#/components/schemas/DeadlineDurationBeforeArrival'
      discriminator:
        propertyName: refType

All possible schemas (in this case DeadlineAbsolute and DeadlineDurationBeforeArrival) need a property with the discriminator property name of type string.

After that the generated class (CancellationDuration.class) should contain the class annotations @JsonTypeInfo and @JsonSubTypes which enables Jackson to perform the instantiation correctly.

I do not know if ithis way of implementing the oneOf type is expected but it was the only working solution I found.

(Tested with openapi-generator version 6.6.0)

this immediately worked for me! and we dont even need to send the value of refType - it's just ignored thank you @Dimibe

fabiofranco85 avatar Jul 21 '23 09:07 fabiofranco85

This bug is still problematic for objects without discriminators. As far as I can see there is no reason preventing from generating @JsonSubTypes annotation on the interface...

issneff avatar Nov 22 '23 18:11 issneff

We have the same issue here, would be great if this could be fixed.

lfvJonas avatar Dec 15 '23 08:12 lfvJonas

Sam Issue and has been persisting for a while now. Clean workaround does not exist.

digantvsingh avatar Feb 10 '24 15:02 digantvsingh

We are screwed with almost same situation, our problem is that our discriminator is in outer scope of the dynamic object:

{
  "itemType": "CommunicationEvent", # this is discriminator
  "itemDate": "2019-08-24T14:15:22Z",
  "itemId": "DBF4732B-B8EB-44C5-9988-2C07EC765CB2",
  "itemDetail": { # this object changes based on discriminator
    "communicationEventId": "DBF4732B-B8EB-44C5-9988-2C07EC765CB2",
    "communicationEventCreationDate": "2019-08-24T14:15:22Z",
    "eventName": "string",
    "communicationEventStatusCode": "string",
    "validTo": "2019-08-24T14:15:22Z"
  }
}

So far, I have not found any better solution than using 3 gson instances:

        GsonBuilder gsonBuilder = new GsonBuilder().setPrettyPrinting()
                .registerTypeAdapter(OffsetDateTime.class, (JsonDeserializer<OffsetDateTime>)
                        (json, type, context) -> OffsetDateTime.parse(json.getAsString()));
        Gson gson4Com = gsonBuilder.registerTypeAdapter(
                ItemDetailForSearch.class,
                InterfaceSerializer.interfaceSerializer(CommunicationForSearch.class)
        ).create();
        Gson gson4ComEvent = gsonBuilder.registerTypeAdapter(
                ItemDetailForSearch.class,
                InterfaceSerializer.interfaceSerializer(CommunicationEventForSearch.class)
        ).create();
        Gson gson4Docu = gsonBuilder.registerTypeAdapter(
                ItemDetailForSearch.class,
                InterfaceSerializer.interfaceSerializer(DocumentForSearch.class)
        ).create();
        Gson gson = gsonBuilder
                .registerTypeAdapter(SearchResult.class, (JsonDeserializer<SearchResult>) (json, typeOfT, context) -> {
                    ItemType itemType = ItemType.fromValue(json.getAsJsonObject().get("itemType").getAsString());
                    return switch (itemType) {
                        case COMMUNICATION -> gson4Com.fromJson(json, SearchResult.class);
                        case COMMUNICATIONEVENT -> gson4ComEvent.fromJson(json, SearchResult.class);
                        case DOCUMENT -> gson4Docu.fromJson(json, SearchResult.class);
                    };
                })
                .create();

Drezir avatar Apr 09 '24 12:04 Drezir

Same issue. Any news?

grindnoise avatar Apr 27 '24 08:04 grindnoise

I have the same issue also.

I tried to use my own jackson deserializer of the parent interface, and tell the generator to put in on the parent interface at generation time by using x-class-extra-annotation, but unfortunately this does not work. I don't know if it's intended or if it's a bug.

openapi contract:

    Tax:
      x-class-extra-annotation: "@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = my.package.TaxDeserializer.class)"
      description: A common object to describe a tax.
      oneOf:
        - $ref: "#/components/schemas/TaxWithFixedAmount"
        - $ref: "#/components/schemas/TaxWithPercentage"
        - $ref: "#/components/schemas/TaxWithCode"

custom deserializer:

public class TaxDeserializer extends StdDeserializer<Tax> {

    public TaxDeserializer() {
        this(null);
    }

    public TaxDeserializer(Class<?> vc) {
        super(vc);
    }

    @Override
    public Tax deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
        var node = (JsonNode) jsonParser.getCodec().readTree(jsonParser);
        ...
    }
}

generated Tax interface (without the wanted @JsonDeserialize annotation):

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen")
public interface Tax {
}

I don't want to use a discriminator property because I don't want to break my api contract (already in production), and also because I don't want to force the consumers of my api to populate this field.

With the existing properties of the 3 different implementations of Tax, I can dynamically compute which one it is, no need for an extra property...

othtenic1 avatar May 17 '24 07:05 othtenic1

Same issue. Any news?

VolodymyrKl avatar May 30 '24 12:05 VolodymyrKl

Run into this kind of issue where my specification for property type to be either number or spring produces empty interface and an error when trying to use the API:

    schemas: 
      MetricBody:
        type: object
        properties:
          key:
            type: string
            example: visit_counter_total
          value:
            oneOf:
              - type: integer
              - type: string
            example: 32

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2024-06-11T14:58:06.997624700+03:00[Europe/Moscow]", comments = "Generator version: 7.5.0") public interface MetricBodyValue { }

Wolkenkind avatar Jun 11 '24 12:06 Wolkenkind