Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[kotlin] better oneOf, anyOf support #18382

Merged
merged 17 commits into from
May 31, 2024
  •  
  •  
  •  
1 change: 1 addition & 0 deletions bin/configs/kotlin-model-prefix-type-mapping.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ additionalProperties:
library: jvm-retrofit2
enumPropertyNaming: UPPERCASE
serializationLibrary: gson
generateOneOfAnyOfWrappers: true
openapiNormalizer:
SIMPLIFY_ONEOF_ANYOF: false
1 change: 1 addition & 0 deletions docs/generators/kotlin.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|collectionType|Option. Collection type to use|<dl><dt>**array**</dt><dd>kotlin.Array</dd><dt>**list**</dt><dd>kotlin.collections.List</dd></dl>|list|
|dateLibrary|Option. Date library to use|<dl><dt>**threetenbp-localdatetime**</dt><dd>Threetenbp - Backport of JSR310 (jvm only, for legacy app only)</dd><dt>**kotlinx-datetime**</dt><dd>kotlinx-datetime (preferred for multiplatform)</dd><dt>**string**</dt><dd>String</dd><dt>**java8-localdatetime**</dt><dd>Java 8 native JSR310 (jvm only, for legacy app only)</dd><dt>**java8**</dt><dd>Java 8 native JSR310 (jvm only, preferred for jdk 1.8+)</dd><dt>**threetenbp**</dt><dd>Threetenbp - Backport of JSR310 (jvm only, preferred for jdk &lt; 1.8)</dd></dl>|java8|
|enumPropertyNaming|Naming convention for enum properties: 'camelCase', 'PascalCase', 'snake_case', 'UPPERCASE', and 'original'| |original|
|generateOneOfAnyOfWrappers|Generate oneOf, anyOf schemas as wrappers.| |false|
|generateRoomModels|Generate Android Room database models in addition to API models (JVM Volley library only)| |false|
|groupId|Generated artifact package's organization (i.e. maven groupId).| |org.openapitools|
|idea|Add IntellJ Idea plugin and mark Kotlin main and test folders as source folders.| |false|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;

import com.samskivert.mustache.Mustache;
import org.apache.commons.lang3.StringUtils;
import org.openapitools.codegen.CliOption;
import org.openapitools.codegen.CodegenConstants;
Expand Down Expand Up @@ -89,6 +90,8 @@ public class KotlinClientCodegen extends AbstractKotlinCodegen {

public static final String SUPPORT_ANDROID_API_LEVEL_25_AND_BELLOW = "supportAndroidApiLevel25AndBelow";

public static final String GENERATE_ONEOF_ANYOF_WRAPPERS = "generateOneOfAnyOfWrappers";

protected static final String VENDOR_EXTENSION_BASE_NAME_LITERAL = "x-base-name-literal";

protected String dateLibrary = DateLibrary.JAVA8.value;
Expand All @@ -102,11 +105,13 @@ public class KotlinClientCodegen extends AbstractKotlinCodegen {
protected boolean generateRoomModels = false;
protected String roomModelPackage = "";
protected boolean omitGradleWrapper = false;
protected boolean generateOneOfAnyOfWrappers = true;

protected String authFolder;

protected SERIALIZATION_LIBRARY_TYPE serializationLibrary = SERIALIZATION_LIBRARY_TYPE.moshi;
public static final String SERIALIZATION_LIBRARY_DESC = "What serialization library to use: 'moshi' (default), or 'gson' or 'jackson' or 'kotlinx_serialization'";

public enum SERIALIZATION_LIBRARY_TYPE {moshi, gson, jackson, kotlinx_serialization}

public enum DateLibrary {
Expand Down Expand Up @@ -259,6 +264,8 @@ public KotlinClientCodegen() {

cliOptions.add(CliOption.newBoolean(SUPPORT_ANDROID_API_LEVEL_25_AND_BELLOW, "[WARNING] This flag will generate code that has a known security vulnerability. It uses `kotlin.io.createTempFile` instead of `java.nio.file.Files.createTempFile` in order to support Android API level 25 and bellow. For more info, please check the following links https://github.com/OpenAPITools/openapi-generator/security/advisories/GHSA-23x4-m842-fmwf, https://github.com/OpenAPITools/openapi-generator/pull/9284"));

cliOptions.add(CliOption.newBoolean(GENERATE_ONEOF_ANYOF_WRAPPERS, "Generate oneOf, anyOf schemas as wrappers."));

CliOption serializationLibraryOpt = new CliOption(CodegenConstants.SERIALIZATION_LIBRARY, SERIALIZATION_LIBRARY_DESC);
cliOptions.add(serializationLibraryOpt.defaultValue(serializationLibrary.name()));
}
Expand All @@ -283,6 +290,10 @@ public boolean getOmitGradleWrapper() {
return omitGradleWrapper;
}

public boolean getGenerateOneOfAnyOfWrappers() {
return generateOneOfAnyOfWrappers;
}

public void setGenerateRoomModels(Boolean generateRoomModels) {
this.generateRoomModels = generateRoomModels;
}
Expand Down Expand Up @@ -332,6 +343,10 @@ public void setOmitGradleWrapper(boolean omitGradleWrapper) {
this.omitGradleWrapper = omitGradleWrapper;
}

public void setGenerateOneOfAnyOfWrappers(boolean generateOneOfAnyOfWrappers) {
this.generateOneOfAnyOfWrappers = generateOneOfAnyOfWrappers;
}

public SERIALIZATION_LIBRARY_TYPE getSerializationLibrary() {
return this.serializationLibrary;
}
Expand Down Expand Up @@ -443,6 +458,10 @@ public void processOpts() {
additionalProperties.put(this.serializationLibrary.name(), true);
}

if (additionalProperties.containsKey(GENERATE_ONEOF_ANYOF_WRAPPERS)) {
setGenerateOneOfAnyOfWrappers(Boolean.parseBoolean(additionalProperties.get(GENERATE_ONEOF_ANYOF_WRAPPERS).toString()));
}

commonSupportingFiles();

switch (getLibrary()) {
Expand Down Expand Up @@ -513,6 +532,14 @@ public void processOpts() {
supportingFiles.add(new SupportingFile("auth/HttpBasicAuth.kt.mustache", authFolder, "HttpBasicAuth.kt"));
}
}

additionalProperties.put("sanitizeGeneric", (Mustache.Lambda) (fragment, writer) -> {
String content = fragment.execute();
for (final String s : List.of("<", ">", ",", " ", ".")) {
content = content.replace(s, "");
}
writer.write(content);
});
}

private void processDateLibrary() {
Expand Down Expand Up @@ -874,7 +901,7 @@ public ModelsMap postProcessModels(ModelsMap objs) {

for (ModelMap mo : objects.getModels()) {
CodegenModel cm = mo.getModel();
if (getGenerateRoomModels()) {
if (getGenerateRoomModels() || getGenerateOneOfAnyOfWrappers()) {
cm.vendorExtensions.put("x-has-data-class-body", true);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ This runs all tests and packages the library.

All URIs are relative to *{{{basePath}}}*

Class | Method | HTTP request | Description
------------ | ------------- | ------------- | -------------
{{#apiInfo}}{{#apis}}{{#operations}}{{#operation}}*{{classname}}* | [**{{operationId}}**]({{apiDocPath}}{{classname}}.md#{{operationIdLowerCase}}) | **{{httpMethod}}** {{path}} | {{{summary}}}
| Class | Method | HTTP request | Description |
| ------------ | ------------- | ------------- | ------------- |
{{#apiInfo}}{{#apis}}{{#operations}}{{#operation}}| *{{classname}}* | [**{{operationId}}**]({{apiDocPath}}{{classname}}.md#{{operationIdLowerCase}}) | **{{httpMethod}}** {{path}} | {{{summary}}} |
{{/operation}}{{/operations}}{{/apis}}{{/apiInfo}}
{{/generateApiDocs}}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
{{^multiplatform}}
{{#gson}}
{{#generateOneOfAnyOfWrappers}}
import com.google.gson.Gson
import com.google.gson.JsonElement
import com.google.gson.TypeAdapter
import com.google.gson.TypeAdapterFactory
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonWriter
import com.google.gson.annotations.JsonAdapter
{{/generateOneOfAnyOfWrappers}}
import com.google.gson.annotations.SerializedName
{{/gson}}
{{#moshi}}
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
{{/moshi}}
{{#jackson}}
{{#enumUnknownDefaultCase}}
import com.fasterxml.jackson.annotation.JsonEnumDefaultValue
{{/enumUnknownDefaultCase}}
import com.fasterxml.jackson.annotation.JsonProperty
{{#discriminator}}
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
{{/discriminator}}
{{/jackson}}
{{#kotlinx_serialization}}
import {{#serializableModel}}kotlinx.serialization.Serializable as KSerializable{{/serializableModel}}{{^serializableModel}}kotlinx.serialization.Serializable{{/serializableModel}}
import kotlinx.serialization.SerialName
import kotlinx.serialization.Contextual
{{#enumUnknownDefaultCase}}
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
{{/enumUnknownDefaultCase}}
{{#hasEnums}}
{{/hasEnums}}
{{/kotlinx_serialization}}
{{#parcelizeModels}}
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
{{/parcelizeModels}}
{{/multiplatform}}
{{#multiplatform}}
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
{{/multiplatform}}
{{#serializableModel}}
import java.io.Serializable
{{/serializableModel}}
{{#generateRoomModels}}
import {{roomModelPackage}}.{{classname}}RoomModel
import {{packageName}}.infrastructure.ITransformForStorage
{{/generateRoomModels}}
import java.io.IOException

/**
* {{{description}}}
*
*/
{{#parcelizeModels}}
@Parcelize
{{/parcelizeModels}}
{{#multiplatform}}{{^discriminator}}@Serializable{{/discriminator}}{{/multiplatform}}{{#kotlinx_serialization}}{{#serializableModel}}@KSerializable{{/serializableModel}}{{^serializableModel}}@Serializable{{/serializableModel}}{{/kotlinx_serialization}}{{#moshi}}{{#moshiCodeGen}}@JsonClass(generateAdapter = true){{/moshiCodeGen}}{{/moshi}}{{#jackson}}{{#discriminator}}{{>typeInfoAnnotation}}{{/discriminator}}{{/jackson}}
{{#isDeprecated}}
@Deprecated(message = "This schema is deprecated.")
{{/isDeprecated}}
{{>additionalModelTypeAnnotations}}
{{#nonPublicApi}}internal {{/nonPublicApi}}data class {{classname}}(var actualInstance: Any? = null) {

class CustomTypeAdapterFactory : TypeAdapterFactory {
override fun <T> create(gson: Gson, type: TypeToken<T>): TypeAdapter<T>? {
if (!{{classname}}::class.java.isAssignableFrom(type.rawType)) {
return null // this class only serializes '{{classname}}' and its subtypes
}
val elementAdapter = gson.getAdapter(JsonElement::class.java)
{{#composedSchemas}}
{{#anyOf}}
{{^isArray}}
{{^vendorExtensions.x-duplicated-data-type}}
val adapter{{{dataType}}} = gson.getDelegateAdapter(this, TypeToken.get({{{dataType}}}::class.java))
{{/vendorExtensions.x-duplicated-data-type}}
{{/isArray}}
{{#isArray}}
@Suppress("UNCHECKED_CAST")
val adapter{{#sanitizeGeneric}}{{{dataType}}}{{/sanitizeGeneric}} = gson.getDelegateAdapter(this, TypeToken.get(object : TypeToken<{{{dataType}}}>() {}.type)) as TypeAdapter<{{{dataType}}}>
{{/isArray}}
{{/anyOf}}
{{/composedSchemas}}

@Suppress("UNCHECKED_CAST")
return object : TypeAdapter<{{classname}}?>() {
@Throws(IOException::class)
override fun write(out: JsonWriter,value: {{classname}}?) {
if (value?.actualInstance == null) {
elementAdapter.write(out, null)
return
}

{{#composedSchemas}}
{{#anyOf}}
{{^vendorExtensions.x-duplicated-data-type}}
// check if the actual instance is of the type `{{{dataType}}}`
if (value.actualInstance is {{#isArray}}List<*>{{/isArray}}{{^isArray}}{{{dataType}}}{{/isArray}}) {
{{#isPrimitiveType}}
val primitive = adapter{{#sanitizeGeneric}}{{{dataType}}}{{/sanitizeGeneric}}.toJsonTree(value.actualInstance as {{{dataType}}}?).getAsJsonPrimitive()
elementAdapter.write(out, primitive)
return
{{/isPrimitiveType}}
{{^isPrimitiveType}}
{{#isArray}}
List<?> list = (List<?>) value.actualInstance
if (list.get(0) is {{{items.dataType}}}) {
val array = adapter{{#sanitizeGeneric}}{{{dataType}}}{{/sanitizeGeneric}}.toJsonTree(value.actualInstance as {{{dataType}}}?).getAsJsonArray()
elementAdapter.write(out, array)
return
}
{{/isArray}}
{{/isPrimitiveType}}
{{^isArray}}
{{^isPrimitiveType}}
val element = adapter{{{dataType}}}.toJsonTree(value.actualInstance as {{{dataType}}}?)
elementAdapter.write(out, element)
return
{{/isPrimitiveType}}
{{/isArray}}
}
{{/vendorExtensions.x-duplicated-data-type}}
{{/anyOf}}
{{/composedSchemas}}
throw IOException("Failed to serialize as the type doesn't match anyOf schemas: {{#anyOf}}{{{.}}}{{^-last}}, {{/-last}}{{/anyOf}}")
}

@Throws(IOException::class)
override fun read(jsonReader: JsonReader): {{classname}} {
val jsonElement = elementAdapter.read(jsonReader)
val errorMessages = ArrayList<String>()
var actualAdapter: TypeAdapter<*>
val ret = {{classname}}()

{{#composedSchemas}}
{{#anyOf}}
{{^vendorExtensions.x-duplicated-data-type}}
{{^hasVars}}
// deserialize {{{dataType}}}
try {
// validate the JSON object to see if any exception is thrown
{{^isArray}}
{{#isNumber}}
require(jsonElement.getAsJsonPrimitive().isNumber()) {
String.format("Expected json element to be of type Number in the JSON string but got `%s`", jsonElement.toString())
}
actualAdapter = adapter{{#sanitizeGeneric}}{{{dataType}}}{{/sanitizeGeneric}}
ret.actualInstance = actualAdapter.fromJsonTree(jsonElement)
return ret
{{/isNumber}}
{{^isNumber}}
{{#isPrimitiveType}}
require(jsonElement.getAsJsonPrimitive().is{{#isBoolean}}Boolean{{/isBoolean}}{{#isString}}String{{/isString}}{{^isString}}{{^isBoolean}}Number{{/isBoolean}}{{/isString}}()) {
String.format("Expected json element to be of type {{#isBoolean}}Boolean{{/isBoolean}}{{#isString}}String{{/isString}}{{^isString}}{{^isBoolean}}Number{{/isBoolean}}{{/isString}} in the JSON string but got `%s`", jsonElement.toString())
}
actualAdapter = adapter{{#sanitizeGeneric}}{{{dataType}}}{{/sanitizeGeneric}}
ret.actualInstance = actualAdapter.fromJsonTree(jsonElement)
return ret
{{/isPrimitiveType}}
{{/isNumber}}
{{^isNumber}}
{{^isPrimitiveType}}
{{{dataType}}}.validateJsonElement(jsonElement)
actualAdapter = adapter{{#sanitizeGeneric}}{{{dataType}}}{{/sanitizeGeneric}}
ret.actualInstance = actualAdapter.fromJsonTree(jsonElement)
return ret
{{/isPrimitiveType}}
{{/isNumber}}
{{/isArray}}
{{#isArray}}
require(jsonElement.isJsonArray) {
String.format("Expected json element to be a array type in the JSON string but got `%s`", jsonElement.toString())
}

// validate array items
for(element in jsonElement.getAsJsonArray()) {
{{#items}}
{{#isNumber}}
require(jsonElement.getAsJsonPrimitive().isNumber) {
String.format("Expected json element to be of type Number in the JSON string but got `%s`", jsonElement.toString())
}
{{/isNumber}}
{{^isNumber}}
{{#isPrimitiveType}}
require(element.getAsJsonPrimitive().is{{#isBoolean}}Boolean{{/isBoolean}}{{#isString}}String{{/isString}}{{^isString}}{{^isBoolean}}Number{{/isBoolean}}{{/isString}}) {
String.format("Expected array items to be of type {{#isBoolean}}Boolean{{/isBoolean}}{{#isString}}String{{/isString}}{{^isString}}{{^isBoolean}}Number{{/isBoolean}}{{/isString}} in the JSON string but got `%s`", jsonElement.toString())
}
{{/isPrimitiveType}}
{{/isNumber}}
{{^isNumber}}
{{^isPrimitiveType}}
{{{dataType}}}.validateJsonElement(element)
{{/isPrimitiveType}}
{{/isNumber}}
{{/items}}
}
actualAdapter = adapter{{#sanitizeGeneric}}{{{dataType}}}{{/sanitizeGeneric}}
ret.actualInstance = actualAdapter.fromJsonTree(jsonElement)
return ret
{{/isArray}}
//log.log(Level.FINER, "Input data matches schema '{{{dataType}}}'")
} catch (e: Exception) {
// deserialization failed, continue
errorMessages.add(String.format("Deserialization for {{{dataType}}} failed with `%s`.", e.message))
//log.log(Level.FINER, "Input data does not match schema '{{{dataType}}}'", e)
}
{{/hasVars}}
{{#hasVars}}
// deserialize {{{.}}}
try {
// validate the JSON object to see if any exception is thrown
{{.}}.validateJsonElement(jsonElement)
log.log(Level.FINER, "Input data matches schema '{{{.}}}'")
actualAdapter = adapter{{.}}
ret.actualInstance = actualAdapter.fromJsonTree(jsonElement)
return ret
} catch (e: Exception) {
// deserialization failed, continue
errorMessages.add(String.format("Deserialization for {{{.}}} failed with `%s`.", e.message))
//log.log(Level.FINER, "Input data does not match schema '{{{.}}}'", e)
}
{{/hasVars}}
{{/vendorExtensions.x-duplicated-data-type}}
{{/anyOf}}
{{/composedSchemas}}

throw IOException(String.format("Failed deserialization for {{classname}}: no schema match result. Detailed failure message for anyOf schemas: %s. JSON: %s", errorMessages, jsonElement.toString()))
}
}.nullSafe() as TypeAdapter<T>
}
}
}
Loading
Loading