Repository: cjbooms/fabrikt (kotlin)

Issue:
Add support for JSON Merge Patch
JSON Merge Patch ([RFC 7386](https://www.rfc-editor.org/rfc/rfc7386)) is a standard request body format for PATCH endpoints. It defines how to partially update an existing entity, by distinguishing values between explicit "null"s and unspecified properties. For example, the body `{}` would describe a no-op, where as the body `{'name'=null}` would describe the operation to "delete" the `name` property of the entity.

To my knowledge, https://github.com/OpenAPITools/jackson-databind-nullable is a good library to support this kind of behavior in Java/Kotlin. It would be nice to have this supported by Fabrikt too.

The "proper" solution would be to check for media type `application/merge-patch+json` and generate the model classes appropriately. However, this would couple model generation and controller generation, which, to my knowledge, is not something Fabrikt was made for so far. 

So, the "easy" solution could be to add a custom extension to type schemas, like `x-merge-patch: true` and generate the model classes for those types to be used by the JSON Merge Patch pattern.

Pull request:
Fixes #184 

I added support for an extension called `x-json-merge-patch`. Schemas annotated with that extension wrap all their fields in a `JsonNullable`. The effect applies to all nested objects as well but not to `$ref`ed types. This is the usual behavior as described in RFC 7386 but also allows for custom behavior, should the need exist.

I'm not sure why or how one would combine this feature with discriminator-based polymorphism, so I didn't write any tests for it, but in theory, I added support for that as well.

Patch:
diff --git a/README.md b/README.md
index 6c9014f2..598d0b26 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ This library was built to take advantage of the complex modeling features availa
  - Polymorphism (`@JsonSubTypes`)
  - Maps of Maps of Maps
  - GraalVM Native Reflection Registration
+ - Json Merge Patch (via `JsonNullable`) (add `x-json-merge-patch: true` to schemas)
 
 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. 
 
diff --git a/build.gradle.kts b/build.gradle.kts
index 9443ba70..889e0202 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -53,6 +53,7 @@ dependencies {
     testImplementation("org.junit.jupiter:junit-jupiter-engine:5.1.0")
     testImplementation("org.junit.jupiter:junit-jupiter-params:5.1.0")
     testImplementation("org.assertj:assertj-core:3.14.0")
+
     // Below dependencies are solely present so code examples in the test resources dir compile
     testImplementation("javax.validation:validation-api:2.0.1.Final")
     testImplementation("jakarta.validation:jakarta.validation-api:3.0.2")
@@ -62,6 +63,7 @@ dependencies {
     testImplementation("com.squareup.okhttp3:okhttp:4.9.1")
     testImplementation("com.pinterest.ktlint:ktlint-core:0.41.0")
     testImplementation("com.pinterest:ktlint:0.41.0")
+    testImplementation("org.openapitools:jackson-databind-nullable:0.2.6")
 }
 
 tasks {
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt
index 6ec6ec3b..d026baa1 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt
@@ -14,28 +14,40 @@ import com.squareup.kotlinpoet.TypeName
 import com.squareup.kotlinpoet.TypeSpec
 import com.squareup.kotlinpoet.asTypeName
 
-enum class ClassType {
-    VANILLA_MODEL,
-    SUPER_MODEL,
-    SUB_MODEL
+data class ClassSettings(
+    val polymorphyType: PolymorphyType,
+    val isMergePatchPattern: Boolean,
+) {
+    enum class PolymorphyType {
+        NONE,
+        SUPER,
+        SUB,
+    }
 }
 
 object PropertyUtils {
-
     fun PropertyInfo.addToClass(
         type: TypeName,
         parameterizedType: TypeName,
         classBuilder: TypeSpec.Builder,
         constructorBuilder: FunSpec.Builder,
-        classType: ClassType = ClassType.VANILLA_MODEL,
+        classSettings: ClassSettings = ClassSettings(ClassSettings.PolymorphyType.NONE, false),
         validationAnnotations: ValidationAnnotations = JavaxValidationAnnotations
     ) {
-        val property = PropertySpec.builder(name, type)
+        val wrappedType =
+            if (classSettings.isMergePatchPattern)
+                ClassName(
+                    "org.openapitools.jackson.nullable",
+                    "JsonNullable"
+                ).parameterizedBy(type.copy(nullable = false))
+            else
+                type
+        val property = PropertySpec.builder(name, wrappedType)
 
         if (this is PropertyInfo.AdditionalProperties) {
             property.initializer(name)
             property.addAnnotation(JacksonMetadata.ignore)
-            val constructorParameter: ParameterSpec.Builder = ParameterSpec.builder(name, type)
+            val constructorParameter: ParameterSpec.Builder = ParameterSpec.builder(name, wrappedType)
             constructorParameter.defaultValue("mutableMapOf()")
             constructorBuilder.addParameter(constructorParameter.build())
 
@@ -59,18 +71,18 @@ object PropertyUtils {
                     .build()
             )
         } else {
-            when (classType) {
-                ClassType.SUPER_MODEL -> {
+            when (classSettings.polymorphyType) {
+                ClassSettings.PolymorphyType.SUPER -> {
                     if (this is PropertyInfo.Field && isPolymorphicDiscriminator) property.addModifiers(KModifier.ABSTRACT)
                     else property.addModifiers(KModifier.OPEN)
                 }
 
-                ClassType.SUB_MODEL -> {
+                ClassSettings.PolymorphyType.SUB -> {
                     if (this is PropertyInfo.Field && isPolymorphicDiscriminator) {
                         property.addModifiers(KModifier.OVERRIDE)
                         when (maybeDiscriminator) {
                             is PropertyInfo.DiscriminatorKey.EnumKey ->
-                                property.initializer("%T.%L", type, maybeDiscriminator.enumKey)
+                                property.initializer("%T.%L", wrappedType, maybeDiscriminator.enumKey)
 
                             is PropertyInfo.DiscriminatorKey.StringKey ->
                                 property.initializer("%S", maybeDiscriminator.stringValue)
@@ -90,7 +102,7 @@ object PropertyUtils {
                     property.addValidationAnnotations(this, validationAnnotations)
                 }
 
-                ClassType.VANILLA_MODEL -> {
+                ClassSettings.PolymorphyType.NONE -> {
                     property.addAnnotation(JacksonMetadata.jacksonParameterAnnotation(oasKey))
                     property.addAnnotation(JacksonMetadata.jacksonPropertyAnnotation(oasKey))
                     property.addValidationAnnotations(this, validationAnnotations)
@@ -99,12 +111,22 @@ object PropertyUtils {
 
             if (this !is PropertyInfo.Field ||
                 !isPolymorphicDiscriminator ||
-                isSubTypeDiscriminatorWithNoValue(classType)
+                isSubTypeDiscriminatorWithNoValue(classSettings)
             ) {
                 property.initializer(name)
-                val constructorParameter: ParameterSpec.Builder = ParameterSpec.builder(name, type)
-                val default = getDefaultValue(this, parameterizedType)
-                if (!isRequired) default?.setDefault(constructorParameter) ?: constructorParameter.defaultValue("null")
+                val constructorParameter: ParameterSpec.Builder = ParameterSpec.builder(name, wrappedType)
+                val oasDefault = getDefaultValue(this, parameterizedType)
+                if (!isRequired) {
+                    if (oasDefault != null) {
+                        oasDefault.setDefault(constructorParameter)
+                    } else {
+                        val undefinedDefault = if (classSettings.isMergePatchPattern)
+                            "JsonNullable.undefined()"
+                        else
+                            "null"
+                        constructorParameter.defaultValue(undefinedDefault)
+                    }
+                }
                 constructorBuilder.addParameter(constructorParameter.build())
             }
         }
@@ -112,8 +134,8 @@ object PropertyUtils {
         classBuilder.addProperty(property.build())
     }
 
-    private fun PropertyInfo.Field.isSubTypeDiscriminatorWithNoValue(classType: ClassType) =
-        classType == ClassType.SUB_MODEL && isPolymorphicDiscriminator && maybeDiscriminator == null
+    private fun PropertyInfo.Field.isSubTypeDiscriminatorWithNoValue(classType: ClassSettings) =
+        classType.polymorphyType == ClassSettings.PolymorphyType.SUB && isPolymorphicDiscriminator && maybeDiscriminator == null
 
     private fun getDefaultValue(propTypeInfo: PropertyInfo, parameterizedType: TypeName): OasDefault? {
         return when (propTypeInfo) {
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonModelGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonModelGenerator.kt
index b788d55a..9bea448f 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonModelGenerator.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonModelGenerator.kt
@@ -2,8 +2,9 @@ package com.cjbooms.fabrikt.generators.model
 
 import com.cjbooms.fabrikt.cli.ModelCodeGenOptionType
 import com.cjbooms.fabrikt.configurations.Packages
-import com.cjbooms.fabrikt.generators.ClassType
+import com.cjbooms.fabrikt.generators.ClassSettings
 import com.cjbooms.fabrikt.generators.GeneratorUtils.toClassName
+import com.cjbooms.fabrikt.generators.JavaxValidationAnnotations
 import com.cjbooms.fabrikt.generators.PropertyUtils.addToClass
 import com.cjbooms.fabrikt.generators.PropertyUtils.isNullable
 import com.cjbooms.fabrikt.generators.TypeFactory.createList
@@ -11,7 +12,6 @@ import com.cjbooms.fabrikt.generators.TypeFactory.createMapOfMapsStringToStringA
 import com.cjbooms.fabrikt.generators.TypeFactory.createMapOfStringToType
 import com.cjbooms.fabrikt.generators.TypeFactory.createMutableMapOfMapsStringToStringType
 import com.cjbooms.fabrikt.generators.TypeFactory.createMutableMapOfStringToType
-import com.cjbooms.fabrikt.generators.JavaxValidationAnnotations
 import com.cjbooms.fabrikt.generators.ValidationAnnotations
 import com.cjbooms.fabrikt.generators.model.JacksonMetadata.JSON_VALUE
 import com.cjbooms.fabrikt.generators.model.JacksonMetadata.basePolymorphicType
@@ -59,6 +59,7 @@ import com.squareup.kotlinpoet.asTypeName
 import java.io.Serializable
 import java.net.MalformedURLException
 import java.net.URL
+
 class JacksonModelGenerator(
     private val packages: Packages,
     private val sourceApi: SourceApi,
@@ -171,15 +172,17 @@ class JacksonModelGenerator(
                 modelName,
                 properties,
                 schemaInfo.schema.discriminator,
+                schemaInfo.schema.extensions,
                 allSchemas
             )
             schemaInfo.schema.isPolymorphicSubType(api) -> polymorphicSubType(
                 modelName,
                 properties,
-                schemaInfo.schema.getSuperType(api)!!.let { SchemaInfo(it.name, it) }
+                schemaInfo.schema.getSuperType(api)!!.let { SchemaInfo(it.name, it) },
+                schemaInfo.schema.extensions,
             )
             schemaInfo.typeInfo is KotlinTypeInfo.Enum -> buildEnumClass(schemaInfo.typeInfo)
-            else -> standardDataClass(modelName, properties)
+            else -> standardDataClass(modelName, properties, schemaInfo.schema.extensions)
         }
     }
 
@@ -196,7 +199,11 @@ class JacksonModelGenerator(
             when (it) {
                 is PropertyInfo.ObjectInlinedField -> {
                     val props = it.schema.topLevelProperties(HTTP_SETTINGS, enclosingSchema)
-                    val currentModel = standardDataClass(it.name.toModelClassName(enclosingModelName), props)
+                    val currentModel = standardDataClass(
+                        it.name.toModelClassName(enclosingModelName),
+                        props,
+                        it.schema.extensions
+                    )
                     val inlinedModels = buildInLinedModels(props, enclosingSchema, apiDocUrl)
                     inlinedModels + currentModel
                 }
@@ -207,7 +214,8 @@ class JacksonModelGenerator(
                     if (it.schema.isComplexTypedAdditionalProperties("additionalProperties")) setOf(
                         standardDataClass(
                             if (it.schema.isInlinedTypedAdditionalProperties()) it.schema.toMapValueClassName() else it.schema.toModelClassName(),
-                            it.schema.topLevelProperties(HTTP_SETTINGS, enclosingSchema)
+                            it.schema.topLevelProperties(HTTP_SETTINGS, enclosingSchema),
+                            it.schema.extensions
                         )
                     )
                     else emptySet()
@@ -220,7 +228,7 @@ class JacksonModelGenerator(
                             items.isInlinedObjectDefinition() ->
                                 items.topLevelProperties(HTTP_SETTINGS, enclosingSchema).let { props ->
                                     buildInLinedModels(props, enclosingSchema, apiDocUrl) + standardDataClass(
-                                        it.name.toModelClassName(enclosingModelName), props
+                                        it.name.toModelClassName(enclosingModelName), props, it.schema.extensions
                                     )
                                 }
                             items.isInlinedEnumDefinition() ->
@@ -317,18 +325,26 @@ class JacksonModelGenerator(
             val schema = mapField.schema.additionalPropertiesSchema
             standardDataClass(
                 if (schema.isInlinedTypedAdditionalProperties()) schema.toMapValueClassName() else schema.toModelClassName(),
-                mapField.schema.additionalPropertiesSchema.topLevelProperties(HTTP_SETTINGS)
+                mapField.schema.additionalPropertiesSchema.topLevelProperties(HTTP_SETTINGS),
+                mapField.schema.extensions
             )
         } else null
 
-    private fun standardDataClass(modelName: String, properties: Collection<PropertyInfo>): TypeSpec {
+    private fun standardDataClass(
+        modelName: String,
+        properties: Collection<PropertyInfo>,
+        extensions: Map<String, Any>,
+    ): TypeSpec {
         val classBuilder = TypeSpec.classBuilder(generatedType(packages.base, modelName))
             .addSerializableInterface()
             .addQuarkusReflectionAnnotation()
             .addMicronautIntrospectedAnnotation()
             .addMicronautReflectionAnnotation()
             .addCompanionObject()
-        properties.addToClass(classBuilder, ClassType.VANILLA_MODEL)
+        properties.addToClass(
+            classBuilder,
+            ClassSettings(ClassSettings.PolymorphyType.NONE, extensions.hasJsonMergePatchExtension)
+        )
         return classBuilder.build()
     }
 
@@ -336,7 +352,8 @@ class JacksonModelGenerator(
         modelName: String,
         properties: Collection<PropertyInfo>,
         discriminator: Discriminator,
-        allSchemas: List<SchemaInfo>
+        extensions: Map<String, Any>,
+        allSchemas: List<SchemaInfo>,
     ): TypeSpec {
         val classBuilder = TypeSpec.classBuilder(generatedType(packages.base, modelName))
             .addModifiers(KModifier.SEALED)
@@ -361,7 +378,10 @@ class JacksonModelGenerator(
             .addMicronautIntrospectedAnnotation()
             .addMicronautReflectionAnnotation()
 
-        properties.addToClass(classBuilder, ClassType.SUPER_MODEL)
+        properties.addToClass(
+            classBuilder,
+            ClassSettings(ClassSettings.PolymorphyType.SUPER, extensions.hasJsonMergePatchExtension)
+        )
 
         return classBuilder.build()
     }
@@ -369,7 +389,8 @@ class JacksonModelGenerator(
     private fun polymorphicSubType(
         modelName: String,
         properties: Collection<PropertyInfo>,
-        superType: SchemaInfo
+        superType: SchemaInfo,
+        extensions: Map<String, Any>,
     ): TypeSpec {
         val classBuilder = TypeSpec.classBuilder(generatedType(packages.base, modelName))
             .addSerializableInterface()
@@ -382,14 +403,14 @@ class JacksonModelGenerator(
             )
         properties.addToClass(
             classBuilder,
-            ClassType.SUB_MODEL
+            ClassSettings(ClassSettings.PolymorphyType.SUB, extensions.hasJsonMergePatchExtension)
         )
         return classBuilder.build()
     }
 
     private fun Collection<PropertyInfo>.addToClass(
         classBuilder: TypeSpec.Builder,
-        classType: ClassType
+        classType: ClassSettings
     ): TypeSpec.Builder {
         val constructorBuilder = FunSpec.constructorBuilder()
         this.forEach {
@@ -409,7 +430,8 @@ class JacksonModelGenerator(
                 validationAnnotations
             )
         }
-        if (constructorBuilder.parameters.isNotEmpty() && classBuilder.modifiers.isEmpty()) classBuilder.addModifiers(KModifier.DATA)
+        if (constructorBuilder.parameters.isNotEmpty() && classBuilder.modifiers.isEmpty()) classBuilder.addModifiers(
+            KModifier.DATA)
         return classBuilder.primaryConstructor(constructorBuilder.build())
     }
 
@@ -443,7 +465,10 @@ class JacksonModelGenerator(
         return this
     }
 
-    private fun TypeSpec.Builder.addOptionalAnnotation(optionType: ModelCodeGenOptionType, type: ClassName): TypeSpec.Builder {
+    private fun TypeSpec.Builder.addOptionalAnnotation(
+        optionType: ModelCodeGenOptionType,
+        type: ClassName
+    ): TypeSpec.Builder {
         if (options.any { it == optionType })
             this.addAnnotation(
                 AnnotationSpec.builder(type).build()
@@ -452,3 +477,6 @@ class JacksonModelGenerator(
     }
 }
 
+private val Map<String, Any>.hasJsonMergePatchExtension
+    get() = this.containsKey("x-json-merge-patch")
+


Test patch:
diff --git a/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt
index 4990a909..19ff51f0 100644
--- a/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt
+++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt
@@ -37,6 +37,7 @@ class ModelGeneratorTest {
         "externalReferences",
         "githubApi",
         "inLinedObject",
+        "jsonMergePatch",
         "mapExamples",
         "mixingCamelSnakeLispCase",
         "oneOfPolymorphicModels",
diff --git a/src/test/resources/examples/jsonMergePatch/api.yaml b/src/test/resources/examples/jsonMergePatch/api.yaml
new file mode 100644
index 00000000..240c95df
--- /dev/null
+++ b/src/test/resources/examples/jsonMergePatch/api.yaml
@@ -0,0 +1,76 @@
+openapi: 3.0.0
+paths: { }
+info:
+  title: ""
+  version: ""
+components:
+  schemas:
+    NoMergePatchInline:
+      type: object
+      properties:
+        inner:
+          type: object
+          properties:
+            p:
+              type: string
+    TopLevelLevelMergePatchInline:
+      type: object
+      x-json-merge-patch: true
+      properties:
+        inner:
+          type: object
+          properties:
+            p:
+              type: string
+    InnerOnlyMergePatchInline:
+      type: object
+      properties:
+        inner:
+          type: object
+          x-json-merge-patch: true
+          properties:
+            p:
+              type: string
+    NestedMergePatchInline:
+      type: object
+      x-json-merge-patch: true
+      properties:
+        inner:
+          type: object
+          x-json-merge-patch: true
+          properties:
+            p:
+              type: string
+    NoMergePatchRef:
+      type: object
+      properties:
+        inner:
+          $ref: '#/components/schemas/InnerNotMergePatch'
+    TopLevelLevelMergePatchRef:
+      type: object
+      x-json-merge-patch: true
+      properties:
+        inner:
+          $ref: '#/components/schemas/InnerNotMergePatch'
+    InnerOnlyMergePatchRef:
+      type: object
+      properties:
+        inner:
+          $ref: '#/components/schemas/InnerMergePatch'
+    NestedMergePatchRef:
+      type: object
+      x-json-merge-patch: true
+      properties:
+        inner:
+          $ref: '#/components/schemas/InnerMergePatch'
+    InnerMergePatch:
+      type: object
+      x-json-merge-patch: true
+      properties:
+        p:
+          type: string
+    InnerNotMergePatch:
+      type: object
+      properties:
+        p:
+          type: string
diff --git a/src/test/resources/examples/jsonMergePatch/models/Models.kt b/src/test/resources/examples/jsonMergePatch/models/Models.kt
new file mode 100644
index 00000000..33113868
--- /dev/null
+++ b/src/test/resources/examples/jsonMergePatch/models/Models.kt
@@ -0,0 +1,98 @@
+package examples.jsonMergePatch.models
+
+import com.fasterxml.jackson.annotation.JsonProperty
+import org.openapitools.jackson.nullable.JsonNullable
+import javax.validation.Valid
+import kotlin.String
+
+data class InnerMergePatch(
+    @param:JsonProperty("p")
+    @get:JsonProperty("p")
+    val p: JsonNullable<String> = JsonNullable.undefined()
+)
+
+data class InnerNotMergePatch(
+    @param:JsonProperty("p")
+    @get:JsonProperty("p")
+    val p: String? = null
+)
+
+data class InnerOnlyMergePatchInline(
+    @param:JsonProperty("inner")
+    @get:JsonProperty("inner")
+    @get:Valid
+    val inner: InnerOnlyMergePatchInlineInner? = null
+)
+
+data class InnerOnlyMergePatchInlineInner(
+    @param:JsonProperty("p")
+    @get:JsonProperty("p")
+    val p: JsonNullable<String> = JsonNullable.undefined()
+)
+
+data class InnerOnlyMergePatchRef(
+    @param:JsonProperty("inner")
+    @get:JsonProperty("inner")
+    @get:Valid
+    val inner: InnerMergePatch? = null
+)
+
+data class NestedMergePatchInline(
+    @param:JsonProperty("inner")
+    @get:JsonProperty("inner")
+    @get:Valid
+    val inner: JsonNullable<NestedMergePatchInlineInner> = JsonNullable.undefined()
+)
+
+data class NestedMergePatchInlineInner(
+    @param:JsonProperty("p")
+    @get:JsonProperty("p")
+    val p: JsonNullable<String> = JsonNullable.undefined()
+)
+
+data class NestedMergePatchRef(
+    @param:JsonProperty("inner")
+    @get:JsonProperty("inner")
+    @get:Valid
+    val inner: JsonNullable<InnerMergePatch> = JsonNullable.undefined()
+)
+
+data class NoMergePatchInline(
+    @param:JsonProperty("inner")
+    @get:JsonProperty("inner")
+    @get:Valid
+    val inner: NoMergePatchInlineInner? = null
+)
+
+data class NoMergePatchInlineInner(
+    @param:JsonProperty("p")
+    @get:JsonProperty("p")
+    val p: String? = null
+)
+
+data class NoMergePatchRef(
+    @param:JsonProperty("inner")
+    @get:JsonProperty("inner")
+    @get:Valid
+    val inner: InnerNotMergePatch? = null
+)
+
+data class TopLevelLevelMergePatchInline(
+    @param:JsonProperty("inner")
+    @get:JsonProperty("inner")
+    @get:Valid
+    val inner: JsonNullable<TopLevelLevelMergePatchInlineInner> = JsonNullable.undefined()
+)
+
+data class TopLevelLevelMergePatchInlineInner(
+    @param:JsonProperty("p")
+    @get:JsonProperty("p")
+    val p: String? = null
+)
+
+data class TopLevelLevelMergePatchRef(
+    @param:JsonProperty("inner")
+    @get:JsonProperty("inner")
+    @get:Valid
+    val inner: JsonNullable<InnerNotMergePatch> = JsonNullable.undefined()
+)