OpenAPITools / openapi-generator

OpenAPI Generator allows generation of API client libraries (SDK generation), server stubs, documentation and configuration automatically given an OpenAPI Spec (v2, v3)
https://openapi-generator.tech
Apache License 2.0
22.04k stars 6.61k forks source link

[BUG][KOTLIN][JACKSON] JSON serialization of HTTP body data types yields duplicate ouput of field used as polymorphism discriminator #11347

Open drmoeller opened 2 years ago

drmoeller commented 2 years ago
Description

Currently I'm using OpenAPI generator only to generate Kotlin model types representing exchanged HTTP body entities.

As far as I can tell deserialization of JSON entities from HTTP request bodies works as expected; but I've recognized deviation when serializing received object graphs back to JSON in response bodies: Duplication of JSON-field used as polymorphism discriminator.

openapi-generator version

5.3.1 (currently resolved from wildcard declaration 5.+ within build.gradle file)

OpenAPI declaration file content or url

I've simplified OpenAPI specification test-specification.yml for easier testing:

openapi: 3.0.3
info:
  title: Test Case
  version: 0.0.1
paths:
  /dummy:
    get:
      responses:
        200:
          description: Dummy.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
components:
  schemas:
    Order:
      type: object
      required:
        - businessID
        - product
      properties:
        businessID:
          type: string
        product:
          $ref: '#/components/schemas/Product'
    ProductTypeEnum:
      type: string
      enum:
        - one
        - two
    Product:
      type: object
      required:
        - type
        - name
      properties:
        type:
          $ref: "#/components/schemas/ProductTypeEnum"
        name:
          type: string
      discriminator:
        propertyName: type
        mapping:
          one: "#/components/schemas/ProductOne"
          two: "#/components/schemas/ProductTwo"
    ProductOne:
      allOf:
        - $ref: "#/components/schemas/Product"
        - type: object
          required:
            - attribute
          properties:
            attribute:
              type: string
    ProductTwo:
      allOf:
        - $ref: "#/components/schemas/Product"
        - type: object
          required:
            - capacity
          properties:
            capacity:
              type: number

OpenAPI specification declares schemata of central entity Order having two fields, one of them polymorphic with two possible sub-types of Product: ProductOne and ProductTwo. Field type is used as discriminator.

Generation Details

Corresponding chapter from Gradle build file build.gradle looks like so:

plugins {
  [...]
  id('org.openapi.generator') version '5.+'
}

[...]

  openApiGenerate {
    generatorName = 'kotlin'
    inputSpec = "${rootDir}/test-specification.yml"
    outputDir = "${buildDir}/openapi-generated-test"
    modelPackage = 'test'
    configOptions = [
      dateLibrary         : 'java8',
      enumPropertyNaming  : 'original',
      serializationLibrary: 'jackson'
    ]
    globalProperties = [
      models   : '',
      modelDocs: 'false'
    ]
  }

[...]

As you can see, jackson is configured as serializationLibrary, cause I'm already using this lib for other JSON-related purposes within the project.

Steps to reproduce
package test

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import spock.lang.Specification

import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL
import static groovy.json.JsonOutput.prettyPrint

class TestOrderSpec extends Specification {

  def JSON_MAPPER = new ObjectMapper()
    .registerModules(new KotlinModule())
    .setSerializationInclusion(NON_NULL)

  def 'JSON deserialization/serialization round trip'() {
    when:
    Order deserializedOrder = JSON_MAPPER.readValue(testOrderJSON, Order)

    then:
    with(deserializedOrder) {
      businessID == 'BID_4711'
      with(product) {
        type == TestProductTypeEnum.two
        name == 'Name of connection'
        capacity == BigDecimal.valueOf(42L)
      }
    }

    when: 'Serialization back to JSON yields same result'
    def backSerialization = JSON_MAPPER.writerFor(Order).writeValueAsString(deserializedOrder)

    then:
    prettyPrint(backSerialization) == prettyPrint(testOrderJSON)

    where:
    testOrderJSON = """{
  "businessID":"BID_4711",
  "product":{
    "type": "two",
    "name":"Name of product",
    "capacity":42.0
  }
}"""
  }

}
Related issues/PRs

Unknown

Suggest a fix

Here is generated Kotlin code of one of the types involved (removed comments, @file:Suppress annotation, etc.):

package test

import test.Product
import test.ProductTwoAllOf
import test.ProductTypeEnum

import com.fasterxml.jackson.annotation.JsonProperty

data class ProductTwo (

    @field:JsonProperty("type")
    override val type: ProductTypeEnum,

    @field:JsonProperty("name")
    override val name: kotlin.String,

    @field:JsonProperty("capacity")
    val capacity: java.math.BigDecimal

) : Product

Provided Spock-driven test case uses following pretty-printed JSON to get deserialized to the generated data types (using Jackson framework):

{
  "businessID":"BID_4711",
  "product":{
    "type": "two",
    "name":"Name of product",
    "capacity":42.0
  }
}

Test case shows proper deserialization to the expected graph of instances, all fields contain the expected content!

But, when serializing the received object graph back to JSON, I got the following result:

{
  "businessID":"BID_4711",
  "product":{
    "type": "two",
    "type": "two",
    "name":"Name of product",
    "capacity":42.0
  }
}

As you can see, discriminator field type appears twice!

Playing around with some options, I found a solution to remove this duplicate line by modifying generated code by hand. Within classes ProductOne and ProductTwo, add access = JsonProperty.Access.WRITE_ONLY instruction with JsonProperty annotation on type field like so:

  [...]
  @field:JsonProperty("type", access = JsonProperty.Access.WRITE_ONLY)
  override val type: TestProductTypeEnum,
  [...]

I've not deeply tested this modification for side effects, but it reproducibly solves the problem at hand: Deserialising JSON to an object graph and serializing it back to JSON yields exactly the same pretty-printed JSON document (pretty-printed to remove issues with line breaks, etc.).

So I suggest to modify code generator to generate shown slightly changed annotation to solve this issue.

olegbolden commented 1 year ago

@drmoeller, I faced the same problem. Thank you for your fix! To get rid of manual replacements I automated this work with special task ApiModelPostProcessor in build.gradle.kts. May be someone will find it usefull.