diff --git a/README.md b/README.md index 5715a6d1..ef0bb5ed 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: diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/GeneratorUtils.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/GeneratorUtils.kt index b69848c1..a431639b 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 = this.filterParams("path") fun Operation.getQueryParams(): List = 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 58e9780d..69220890 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)) } diff --git a/src/test/resources/examples/okHttpClientMultiMediaType/api.yaml b/src/test/resources/examples/okHttpClientMultiMediaType/api.yaml index 3ab3781e..3f775e0d 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 16b9931d..051ee54d 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 = + emptyMap() + ): ApiResponse { + 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 c914ff86..8cfbf4b6 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 = + emptyMap() + ): ApiResponse = + 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 12681dee..96236f62 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 ) +data class OtherQueryResult( + @param:JsonProperty("items") + @get:JsonProperty("items") + @get:NotNull + @get:Size(min = 0) + @get:Valid + val items: List +) + +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,