Skip to content

Commit

Permalink
[kotlin] better oneOf, anyOf support (#18382)
Browse files Browse the repository at this point in the history
* add validteJsonElement

* add oneOf support

* various fixes, add tests

* minor fixes

* minor fixes

* update data class

* remove comments

* array support, add test

* update api client constructor

* add anyOf support

* add new files

* fix merge

* update

* update

* update

* update
  • Loading branch information
wing328 committed May 31, 2024
1 parent 1c7e5c4 commit 353320c
Show file tree
Hide file tree
Showing 648 changed files with 8,996 additions and 6,249 deletions.
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

0 comments on commit 353320c

Please sign in to comment.