From df3caf7379a2f903285efb64a6006ba3339c854e Mon Sep 17 00:00:00 2001 From: Jonas Jelten Date: Mon, 25 May 2026 16:38:03 +0200 Subject: [PATCH] [kotlin-client][jvm-ktor] Support nullable response types The jvm-ktor library did not handle OpenAPI schemas that allow null as a valid response body (e.g. anyOf: [T, {type: null}] or allOf: [T] with nullable: true). Two template bugs prevented this from working: 1. api.mustache used {{{returnType}}} directly, ignoring the isNullable flag on the response property. Now it appends ? when returnProperty.isNullable is true. 2. HttpResponse.kt.mustache hardcoded on every generic type parameter. This made HttpResponse invalid at the Kotlin type-system level. Removing the : Any constraint lets the generated code use nullable types naturally. --- .../libraries/jvm-ktor/api.mustache | 2 +- .../infrastructure/HttpResponse.kt.mustache | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-ktor/api.mustache b/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-ktor/api.mustache index bdffe7382d58..76c15358e2cc 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-ktor/api.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-ktor/api.mustache @@ -52,7 +52,7 @@ import com.fasterxml.jackson.databind.ObjectMapper {{#returnType}} @Suppress("UNCHECKED_CAST") {{/returnType}} - {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}open suspend fun {{operationId}}({{#allParams}}{{{paramName}}}: {{{dataType}}}{{^required}}?{{/required}}{{^-last}}, {{/-last}}{{/allParams}}): HttpResponse<{{{returnType}}}{{^returnType}}Unit{{/returnType}}> { + {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}open suspend fun {{operationId}}({{#allParams}}{{{paramName}}}: {{{dataType}}}{{^required}}?{{/required}}{{^-last}}, {{/-last}}{{/allParams}}): HttpResponse<{{{returnType}}}{{^returnType}}Unit{{/returnType}}{{#returnProperty}}{{#isNullable}}?{{/isNullable}}{{/returnProperty}}> { val localVariableAuthNames = listOf({{#authMethods}}"{{name}}"{{^-last}}, {{/-last}}{{/authMethods}}) diff --git a/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-ktor/infrastructure/HttpResponse.kt.mustache b/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-ktor/infrastructure/HttpResponse.kt.mustache index be6d259adfd0..f6242e00e256 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-ktor/infrastructure/HttpResponse.kt.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-ktor/infrastructure/HttpResponse.kt.mustache @@ -5,12 +5,12 @@ import io.ktor.http.isSuccess import io.ktor.util.reflect.TypeInfo import io.ktor.util.reflect.typeInfo -{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}open class HttpResponse({{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val response: io.ktor.client.statement.HttpResponse, {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val provider: BodyProvider) { +{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}open class HttpResponse({{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val response: io.ktor.client.statement.HttpResponse, {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val provider: BodyProvider) { {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val status: Int = response.status.value {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val success: Boolean = response.status.isSuccess() {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val headers: Map> = response.headers.mapEntries() {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}suspend fun body(): T = provider.body(response) - {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}suspend fun typedBody(type: TypeInfo): V = provider.typedBody(response, type) + {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}suspend fun typedBody(type: TypeInfo): V = provider.typedBody(response, type) {{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}companion object { private fun Headers.mapEntries(): Map> { @@ -21,31 +21,31 @@ import io.ktor.util.reflect.typeInfo } } -{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}interface BodyProvider { +{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}interface BodyProvider { suspend fun body(response: io.ktor.client.statement.HttpResponse): T - suspend fun typedBody(response: io.ktor.client.statement.HttpResponse, type: TypeInfo): V + suspend fun typedBody(response: io.ktor.client.statement.HttpResponse, type: TypeInfo): V } -{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}class TypedBodyProvider(private val type: TypeInfo) : BodyProvider { +{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}class TypedBodyProvider(private val type: TypeInfo) : BodyProvider { @Suppress("UNCHECKED_CAST") override suspend fun body(response: io.ktor.client.statement.HttpResponse): T = response.call.body(type) as T @Suppress("UNCHECKED_CAST") - override suspend fun typedBody(response: io.ktor.client.statement.HttpResponse, type: TypeInfo): V = + override suspend fun typedBody(response: io.ktor.client.statement.HttpResponse, type: TypeInfo): V = response.call.body(type) as V } -{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}class MappedBodyProvider(private val provider: BodyProvider, private val block: S.() -> T) : BodyProvider { +{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}class MappedBodyProvider(private val provider: BodyProvider, private val block: S.() -> T) : BodyProvider { override suspend fun body(response: io.ktor.client.statement.HttpResponse): T = block(provider.body(response)) - override suspend fun typedBody(response: io.ktor.client.statement.HttpResponse, type: TypeInfo): V = + override suspend fun typedBody(response: io.ktor.client.statement.HttpResponse, type: TypeInfo): V = provider.typedBody(response, type) } -{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}inline fun io.ktor.client.statement.HttpResponse.wrap(): HttpResponse = +{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}inline fun io.ktor.client.statement.HttpResponse.wrap(): HttpResponse = HttpResponse(this, TypedBodyProvider(typeInfo())) -{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}fun HttpResponse.map(block: T.() -> V): HttpResponse = +{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}fun HttpResponse.map(block: T.() -> V): HttpResponse = HttpResponse(response, MappedBodyProvider(provider, block))