Repository: cjbooms/fabrikt
Pull request #90 (2021-12-02 09:16:34)

Multiple response content types
Currently if there are multiple response schemas, the client just generates the response class for the first one. This changes it so that it generates JsonNode when there are multiple possible response schemas.


Patch:
diff --git a/README.md b/README.md
index 660d56f..ef0bb5e 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ This library was built to take advantage of the complex modeling features availa
 
 More than just bootstrapping, this library can be permanently integrated into a gradle or maven build and will ensure contract and code always match, even as APIs evolve in complexity. 
 
-The team that built this tool initially contributed to the Kotlin code generation ability in [OpenApiTools](https://github.com/OpenAPITools/openapi-generator), but reached the limits of what could be achieved with template-based generation. This library levarages the rich OpenApi3 model provided by [KaiZen-OpenApi-Parser](https://github.com/RepreZen/KaiZen-OpenApi-Parser) and uses [Kotlin Poet](https://square.github.io/kotlinpoet/) to programatically construct Kotlin classes for maximum flexibility. 
+The team that built this tool initially contributed to the Kotlin code generation ability in [OpenApiTools](https://github.com/OpenAPITools/openapi-generator), but reached the limits of what could be achieved with template-based generation. This library leverages the rich OpenApi3 model provided by [KaiZen-OpenApi-Parser](https://github.com/RepreZen/KaiZen-OpenApi-Parser) and uses [Kotlin Poet](https://square.github.io/kotlinpoet/) to programmatically construct Kotlin classes for maximum flexibility. 
 
 It was built at [Zalando Tech](https://opensource.zalando.com/) and is battle-tested in production there. It is particulary well suited to API's built according to Zalando's [REST API guidelines](https://opensource.zalando.com/restful-api-guidelines/). It is [available on Maven Central](https://search.maven.org/artifact/com.cjbooms/fabrikt) at the following coordinates:
 
@@ -199,6 +199,10 @@ dependencies {
 }
 ```
 
+## Maven
+
+The [exec-maven-plugin](http://www.mojohaus.org/exec-maven-plugin/examples/example-exec-using-plugin-dependencies.html) is capable of downloading the Fabrikt library from Maven Central and executing its main method with defined arguments.
+
 ## Publishing
 This library is published to [Sonatype's OSS](https://oss.sonatype.org/#welcome) staging repository using Github actions when a release is drafted. It can be manually promoted from there to the release repository which is indexed by Maven Central.
 
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/GeneratorUtils.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/GeneratorUtils.kt
index b69848c..a431639 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/generators/GeneratorUtils.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/GeneratorUtils.kt
@@ -120,6 +120,9 @@ object GeneratorUtils {
 
     fun Operation.hasMultipleContentMediaTypes(): Boolean? = this.firstResponse()?.hasMultipleContentMediaTypes()
 
+    fun Operation.hasMultipleResponseSchemas(): Boolean =
+            getBodyResponses().flatMap { it.contentMediaTypes.values }.map { it.schema.name }.distinct().size > 1
+
     fun Operation.getPathParams(): List<Parameter> = this.filterParams("path")
 
     fun Operation.getQueryParams(): List<Parameter> = this.filterParams("query")
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/client/ClientGeneratorUtils.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/ClientGeneratorUtils.kt
index 58e9780..6922089 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/generators/client/ClientGeneratorUtils.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/ClientGeneratorUtils.kt
@@ -2,10 +2,12 @@ package com.cjbooms.fabrikt.generators.client
 
 import com.cjbooms.fabrikt.configurations.Packages
 import com.cjbooms.fabrikt.generators.GeneratorUtils.getPrimaryContentMediaType
+import com.cjbooms.fabrikt.generators.GeneratorUtils.hasMultipleResponseSchemas
 import com.cjbooms.fabrikt.generators.GeneratorUtils.toClassName
 import com.cjbooms.fabrikt.generators.model.JacksonModelGenerator.Companion.toModelType
 import com.cjbooms.fabrikt.model.ClientType
 import com.cjbooms.fabrikt.model.KotlinTypeInfo
+import com.fasterxml.jackson.databind.JsonNode
 import com.reprezen.kaizen.oasparser.model3.Operation
 import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
 import com.squareup.kotlinpoet.TypeName
@@ -16,18 +18,24 @@ object ClientGeneratorUtils {
     const val ADDITIONAL_HEADERS_PARAMETER_NAME = "additionalHeaders"
 
     /**
-     * It resolves the API operation response to its body type. It iterates over all non-default responses
-     * by filtering those ones with content media. If multiple medias are found, then it will resolve to the schema
-     * reference of the first media type found, otherwise it'll resolve to Unit by assuming no response body
-     * for the given operation.
+     * Gives the Kotlin return type for an API call based on the Content-Types specified in the Operation.
+     * If multiple media types are found, but their response schema is the same, then the first media type is used and kotlin model for the schema is returned.
+     * If there are several possible response schemas, then the return type is JsonNode, so they are all covered.
+     * If no response body is found, Unit is returned.
      */
     fun Operation.toClientReturnType(packages: Packages): TypeName {
-        val returnType = this.getPrimaryContentMediaType()?.let {
-            toModelType(
-                packages.base,
-                KotlinTypeInfo.from(it.value.schema)
-            )
-        } ?: Unit::class.asTypeName()
+        val returnType =
+                if (hasMultipleResponseSchemas()) {
+                    JsonNode::class.asTypeName()
+                } else {
+                    this.getPrimaryContentMediaType()?.let {
+                        toModelType(
+                                packages.base,
+                                KotlinTypeInfo.from(it.value.schema)
+                        )
+                    } ?: Unit::class.asTypeName()
+                }
+
         return "ApiResponse".toClassName(packages.client)
             .parameterizedBy(returnType.copy(nullable = true))
     }


Test patch:
diff --git a/src/test/resources/examples/okHttpClientMultiMediaType/api.yaml b/src/test/resources/examples/okHttpClientMultiMediaType/api.yaml
index 3ab3781..3f775e0 100644
--- a/src/test/resources/examples/okHttpClientMultiMediaType/api.yaml
+++ b/src/test/resources/examples/okHttpClientMultiMediaType/api.yaml
@@ -21,7 +21,15 @@ paths:
                 $ref: "#/components/schemas/QueryResult"
             application/vnd.custom.media+json:
               schema:
-                $ref: "#/components/schemas/QueryResult"  
+                $ref: "#/components/schemas/QueryResult"
+        default:
+          description: >-
+            error occurred - see status code and problem object for more information.
+          content:
+            application/problem+json:
+              schema:
+                $ref: 'https://opensource.zalando.com/problem/schema.yaml#/Problem'
+
   /example-path-2:
     get:
       summary: "GET example path 1"
@@ -43,6 +51,25 @@ paths:
               schema:
                 $ref: "#/components/schemas/QueryResult"
 
+  /multiple-response-schemas:
+    get:
+      summary: "GET with multiple response content schemas"
+      parameters:
+        - $ref: "#/components/parameters/Accept"
+      responses:
+        200:
+          description: "successful operation"
+          headers:
+            Cache-Control:
+              $ref: "#/components/headers/CacheControl"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/QueryResult'
+            application/vnd.custom.media+json:
+              schema:
+                $ref: '#/components/schemas/OtherQueryResult'
+
 components:
   parameters:
     ListQueryParamExploded:
@@ -83,6 +110,7 @@ components:
       x-extensible-enum:
         - application/json
         - application/vnd.custom.media+json
+
     QueryResult:
       type: "object"
       required:
@@ -96,6 +124,16 @@ components:
               - $ref: "#/components/schemas/FirstModel"
               - $ref: "#/components/schemas/SecondModel"
               - $ref: "#/components/schemas/ThirdModel"
+    OtherQueryResult:
+      type: "object"
+      required:
+        - "items"
+      properties:
+        items:
+          type: "array"
+          minItems: 0
+          items:
+            $ref: "#/components/schemas/AlternateResponseModel"
     Content:
       type: "object"
       required:
@@ -188,3 +226,17 @@ components:
               description: "The attribute 2 for model 3"
               type: integer
               readOnly: true
+
+    AlternateResponseModel:
+      type: "object"
+      properties:
+        extra_first_attr:
+          description: "The attribute 1 for model 3"
+          type: "string"
+          format: "date-time"
+          example: "2016-01-27T10:52:46.406Z"
+          readOnly: true
+        extra_second_attr:
+          description: "The attribute 2 for model 3"
+          type: integer
+          readOnly: true
\ No newline at end of file
diff --git a/src/test/resources/examples/okHttpClientMultiMediaType/client/ApiClient.kt b/src/test/resources/examples/okHttpClientMultiMediaType/client/ApiClient.kt
index 16b9931..051ee54 100644
--- a/src/test/resources/examples/okHttpClientMultiMediaType/client/ApiClient.kt
+++ b/src/test/resources/examples/okHttpClientMultiMediaType/client/ApiClient.kt
@@ -1,5 +1,6 @@
 package examples.okHttpClientMultiMediaType.client
 
+import com.fasterxml.jackson.databind.JsonNode
 import com.fasterxml.jackson.databind.ObjectMapper
 import com.fasterxml.jackson.module.kotlin.jacksonTypeRef
 import examples.okHttpClientMultiMediaType.models.ContentType
@@ -98,3 +99,40 @@ class ExamplePath2Client(
         return request.execute(client, objectMapper, jacksonTypeRef())
     }
 }
+
+@Suppress("unused")
+class MultipleResponseSchemasClient(
+    private val objectMapper: ObjectMapper,
+    private val baseUrl: String,
+    private val client: OkHttpClient
+) {
+    /**
+     * GET with multiple response content schemas
+     *
+     * @param accept the content type accepted by the client
+     */
+    @Throws(ApiException::class)
+    fun getMultipleResponseSchemas(
+        accept: ContentType?,
+        additionalHeaders: Map<String, String> =
+            emptyMap()
+    ): ApiResponse<JsonNode?> {
+        val httpUrl: HttpUrl = "$baseUrl/multiple-response-schemas"
+            .toHttpUrl()
+            .newBuilder()
+            .build()
+
+        val headerBuilder = Headers.Builder()
+            .header("Accept", accept?.value)
+        additionalHeaders.forEach { headerBuilder.header(it.key, it.value) }
+        val httpHeaders: Headers = headerBuilder.build()
+
+        val request: Request = Request.Builder()
+            .url(httpUrl)
+            .headers(httpHeaders)
+            .get()
+            .build()
+
+        return request.execute(client, objectMapper, jacksonTypeRef())
+    }
+}
diff --git a/src/test/resources/examples/okHttpClientMultiMediaType/client/ApiService.kt b/src/test/resources/examples/okHttpClientMultiMediaType/client/ApiService.kt
index c914ff8..8cfbf4b 100644
--- a/src/test/resources/examples/okHttpClientMultiMediaType/client/ApiService.kt
+++ b/src/test/resources/examples/okHttpClientMultiMediaType/client/ApiService.kt
@@ -1,5 +1,6 @@
 package examples.okHttpClientMultiMediaType.client
 
+import com.fasterxml.jackson.databind.JsonNode
 import com.fasterxml.jackson.databind.ObjectMapper
 import examples.okHttpClientMultiMediaType.models.ContentType
 import examples.okHttpClientMultiMediaType.models.QueryResult
@@ -73,3 +74,37 @@ class ExamplePath2Service(
             apiClient.getExamplePath2(explodeListQueryParam, queryParam2, accept, additionalHeaders)
         }
 }
+
+/**
+ * The circuit breaker registry should have the proper configuration to correctly action on circuit
+ * breaker transitions based on the client exceptions [ApiClientException], [ApiServerException] and
+ * [IOException].
+ *
+ * @see ApiClientException
+ * @see ApiServerException
+ */
+@Suppress("unused")
+class MultipleResponseSchemasService(
+    private val circuitBreakerRegistry: CircuitBreakerRegistry,
+    objectMapper: ObjectMapper,
+    baseUrl: String,
+    client: OkHttpClient
+) {
+    var circuitBreakerName: String = "multipleResponseSchemasClient"
+
+    private val apiClient: MultipleResponseSchemasClient = MultipleResponseSchemasClient(
+        objectMapper,
+        baseUrl,
+        client
+    )
+
+    @Throws(ApiException::class)
+    fun getMultipleResponseSchemas(
+        accept: ContentType?,
+        additionalHeaders: Map<String, String> =
+            emptyMap()
+    ): ApiResponse<JsonNode?> =
+        withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) {
+            apiClient.getMultipleResponseSchemas(accept, additionalHeaders)
+        }
+}
diff --git a/src/test/resources/examples/okHttpClientMultiMediaType/models/Models.kt b/src/test/resources/examples/okHttpClientMultiMediaType/models/Models.kt
index 12681de..96236f6 100644
--- a/src/test/resources/examples/okHttpClientMultiMediaType/models/Models.kt
+++ b/src/test/resources/examples/okHttpClientMultiMediaType/models/Models.kt
@@ -38,6 +38,24 @@ data class QueryResult(
     val items: List<Content>
 )
 
+data class OtherQueryResult(
+    @param:JsonProperty("items")
+    @get:JsonProperty("items")
+    @get:NotNull
+    @get:Size(min = 0)
+    @get:Valid
+    val items: List<AlternateResponseModel>
+)
+
+data class AlternateResponseModel(
+    @param:JsonProperty("extra_first_attr")
+    @get:JsonProperty("extra_first_attr")
+    val extraFirstAttr: OffsetDateTime? = null,
+    @param:JsonProperty("extra_second_attr")
+    @get:JsonProperty("extra_second_attr")
+    val extraSecondAttr: Int? = null
+)
+
 @JsonTypeInfo(
     use = JsonTypeInfo.Id.NAME,
     include = JsonTypeInfo.As.EXISTING_PROPERTY,