Skip to content

Commit

Permalink
Multiple response content types (#90)
Browse files Browse the repository at this point in the history
* Add extra route with multiple responses

* Change response type when there are multiple different response schemas

* Use JsonNode in case the response is an array

* Update test

* Exclude default response
  • Loading branch information
herojan authored Dec 2, 2021
1 parent 04dfe9d commit e4a66d6
Show file tree
Hide file tree
Showing 7 changed files with 166 additions and 12 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
}
Expand Down
54 changes: 53 additions & 1 deletion src/test/resources/examples/okHttpClientMultiMediaType/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -83,6 +110,7 @@ components:
x-extensible-enum:
- application/json
- application/vnd.custom.media+json

QueryResult:
type: "object"
required:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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())
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit e4a66d6

Please sign in to comment.