Skip to content

Commit

Permalink
Support for Kotlin Serialization (#327)
Browse files Browse the repository at this point in the history
Introduces support for [kotlinx.serialization](https://kotlinlang.org/docs/serialization.html) (KxS) annotations on the generated models.

A new `--serialization-library` option allows the user to choose between `JACKSON` and `KOTLINX_SERIALIZATION`. 
The Jackson implementation is the default and has not changed, and users will get the same output as previously.

For date and datetime [kotlinx-datetime](https://github.com/Kotlin/kotlinx-datetime) is used to make the generated code work in multi platform / non-JVM environments. Date is translated to `kotlinx.datetime.LocalDate` and datetime to `kotlinx.datetime.Instant`.

**Unsupported features**
1. When KxS is chosen `additionalProperties: true` is not supported. This is due to fact that supporting a `map<String,Any?>` is quite tricky (if at all possible?) with Kotlinx.Serialization, whereas Jackson has `JsonAnyGetter`/`JsonAnySetter` to support this. 
We might be able to configure the property with [@contextual](https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-core/kotlinx.serialization/-contextual/) to instruct the plugin to resolve serializer at runtime.
Related: Kotlin/kotlinx.serialization#1978.
3. URI and UUID are translated to strings as no native Kotlin types exist ([yet](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.uuid/-experimental-uuid-api/)?).
4. `number` without format is currently translated to Java's `BigDecimal` which will not work in non-JVM Kotlin. Works fine for `number`/`float` and `number`/`double` as these are translated into Kotlin types.

**Tests**
I am unsure how many more tests for the examples we want. Many of the examples are concerned with translating OpenAPI into models, and are not specific to the serialization annotations. Any opinions/input/suggestion are welcome.
  • Loading branch information
ulrikandersen authored Nov 18, 2024
1 parent 741451e commit 22944d3
Show file tree
Hide file tree
Showing 32 changed files with 886 additions and 45 deletions.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ The library currently has support for generating:

* Models
* **Jackson** annotated **data classes**
* **Kotlinx.serialization** annotated **data classes**
* Clients
* **OkHttp Client** - with the option for a resilience4j fault-tolerance wrapper
* **OkHttp Client (w/ Jackson Models)** - with the option for a resilience4j fault-tolerance wrapper
* **OpenFeign** annotated client interfaces
* Controllers
* **Spring MVC** annotated controller interfaces
Expand Down Expand Up @@ -210,6 +211,10 @@ This section documents the available CLI parameters for controlling what gets ge
| `--http-model-suffix` | Specify custom suffix for all generated model classes. Defaults to no suffix. |
| `--output-directory` | Allows the generation dir to be overridden. Defaults to current dir |
| `--resources-path` | Allows the path for generated resources to be overridden. Defaults to `src/main/resources` |
| `--serialization-library` | Specify which serialization library to use for annotations in generated model classes. Default: JACKSON |
| | CHOOSE ONE OF: |
| | `JACKSON` - Use Jackson for serialization and deserialization |
| | `KOTLINX_SERIALIZATION` - Use kotlinx.serialization for serialization and deserialization |
| `--src-path` | Allows the path for generated source files to be overridden. Defaults to `src/main/kotlin` |
| `--targets` | Targets are the parts of the application that you want to be generated. |
| | CHOOSE ANY OF: |
Expand Down
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ allprojects {
val jacksonVersion by extra { "2.15.1" }
val junitVersion by extra { "5.9.2" }
val ktorVersion by extra { "2.3.9" }
val kotlinxSerializationVersion by extra { "1.6.3" }
val kotlinxDateTimeVersion by extra { "0.6.1" }

dependencies {
implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
Expand All @@ -56,6 +58,9 @@ dependencies {
implementation("com.reprezen.jsonoverlay:jsonoverlay:4.0.4")
implementation("com.squareup:kotlinpoet:1.14.2") { exclude(module = "kotlin-stdlib-jre7") }
implementation("com.google.flogger:flogger:0.7.4")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion")

implementation("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion")

testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
Expand Down
67 changes: 67 additions & 0 deletions end2end-tests/models-kotlinx/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
val fabrikt: Configuration by configurations.creating

val generationDir = "$buildDir/generated"
val apiFile = "$projectDir/openapi/api.yaml"

sourceSets {
main { java.srcDirs("$generationDir/src/main/kotlin") }
test { java.srcDirs("$generationDir/src/test/kotlin") }
}

plugins {
id("org.jetbrains.kotlin.jvm") version "1.8.20" // Apply the Kotlin JVM plugin to add support for Kotlin.
kotlin("plugin.serialization") version "1.8.20"
}

java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

val junitVersion: String by rootProject.extra
val kotlinxSerializationVersion: String by rootProject.extra
val kotlinxDateTimeVersion: String by rootProject.extra

dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion")

testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
testImplementation("org.junit.jupiter:junit-jupiter-params:$junitVersion")
testImplementation("org.assertj:assertj-core:3.24.2")
}

tasks {

val generateCode by creating(JavaExec::class) {
inputs.files(apiFile)
outputs.dir(generationDir)
outputs.cacheIf { true }
classpath = rootProject.files("./build/libs/fabrikt-${rootProject.version}.jar")
mainClass.set("com.cjbooms.fabrikt.cli.CodeGen")
args = listOf(
"--output-directory", generationDir,
"--base-package", "com.example",
"--api-file", apiFile,
"--validation-library", "NO_VALIDATION",
"--targets", "http_models",
"--serialization-library", "KOTLINX_SERIALIZATION",
"--http-model-opts", "SEALED_INTERFACES_FOR_ONE_OF",
)
dependsOn(":jar")
dependsOn(":shadowJar")
}

withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.jvmTarget = "17"
dependsOn(generateCode)
}


withType<Test> {
useJUnitPlatform()
jvmArgs = listOf("--add-opens=java.base/java.lang=ALL-UNNAMED", "--add-opens=java.base/java.util=ALL-UNNAMED")

}
}
98 changes: 98 additions & 0 deletions end2end-tests/models-kotlinx/openapi/api.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
openapi: "3.0.0"
info:
version: 1.0.0
title: Swagger Petstore
license:
name: MIT
servers:
- url: http://petstore.swagger.io/v1
paths: {}
components:
schemas:
TransportationDevice:
type: object
required:
- deviceType
- make
- model
properties:
deviceType:
type: string
enum:
- bike
- skateboard
- rollerskates
- Ho_ver-boaRD
make:
type: string
model:
type: string
format: uuid
Pet:
type: object
required:
- id
- name
- dateOfBirth
properties:
id:
type: integer
format: int64
name:
type: string
tag:
type: string
dateOfBirth:
type: string
format: date
lastFedAt:
type: string
format: date-time
earTagUuid:
type: string
format: uuid
imageUrl:
type: string
format: uri
Pets:
type: array
maxItems: 100
items:
$ref: "#/components/schemas/Pet"
Phone:
oneOf:
- $ref: "#/components/schemas/LandlinePhone"
- $ref: "#/components/schemas/MobilePhone"
discriminator:
propertyName: type
mapping:
landline: '#/components/schemas/LandlinePhone'
mobile: '#/components/schemas/MobilePhone'
LandlinePhone:
type: object
required:
- number
- area_code
properties:
number:
type: string
area_code:
type: string
MobilePhone:
type: object
required:
- number
properties:
number:
type: string
Error:
type: object
required:
- code
- message
properties:
code:
type: integer
format: int32
message:
type: string
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.cjbooms.fabrikt.models.kotlinx

import com.example.models.TransportationDevice
import com.example.models.TransportationDeviceDeviceType
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class KotlinxSerializationEnumTest {

@Test
fun `must serialize entity with enum field`() {
val device = TransportationDevice(
deviceType = TransportationDeviceDeviceType.BIKE,
make = "Specialized",
model = "Chisel"
)
val json = Json.encodeToString(device)
assertThat(json).isEqualTo("""
{"deviceType":"bike","make":"Specialized","model":"Chisel"}
""".trimIndent())
}

@Test
fun `must deserialize entity with enum field`() {
val json = """
{"deviceType":"bike","make":"Specialized","model":"Chisel"}
""".trimIndent()
val device = Json.decodeFromString(TransportationDevice.serializer(), json)
assertThat(device).isEqualTo(
TransportationDevice(
deviceType = TransportationDeviceDeviceType.BIKE,
make = "Specialized",
model = "Chisel"
)
)
}

@Test
fun `must fail with SerializationException if enum value is not valid`() {
val json = """
{"deviceType":"car","make":"Specialized","model":"Chisel"}
""".trimIndent()
val exception = assertThrows<SerializationException> {
Json.decodeFromString<TransportationDevice>(json)
}
assertThat(exception.message).isEqualTo("com.example.models.TransportationDeviceDeviceType does not contain element with name 'car' at path \$.deviceType")
}

@Test
fun `must fail with SerializationException if required fields are missing`() {
val json = """
{"deviceType":"bike"}
""".trimIndent()
val exception = assertThrows<SerializationException> {
Json.decodeFromString<TransportationDevice>(json)
}
assertThat(exception.message).contains("Fields [make, model] are required for type with serial name 'com.example.models.TransportationDevice', but they were missing at path: \$")
}

@Test
fun `must serialize entity with enum field with mixed case`() {
val device = TransportationDevice(
deviceType = TransportationDeviceDeviceType.HO_VER_BOA_RD,
make = "Hover",
model = "Board"
)
val json = Json.encodeToString(device)
assertThat(json).isEqualTo("""
{"deviceType":"Ho_ver-boaRD","make":"Hover","model":"Board"}
""".trimIndent())
}

@Test
fun `must deserialize entity with enum field with mixed case`() {
val json = """
{"deviceType":"Ho_ver-boaRD","make":"Hover","model":"Board"}
""".trimIndent()
val device = Json.decodeFromString(TransportationDevice.serializer(), json)
assertThat(device).isEqualTo(
TransportationDevice(
deviceType = TransportationDeviceDeviceType.HO_VER_BOA_RD,
make = "Hover",
model = "Board"
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.cjbooms.fabrikt.models.kotlinx

import com.example.models.LandlinePhone
import com.example.models.Phone
import kotlinx.serialization.encodeToString
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class KotlinxSerializationOneOfPolymorphicTest {

@Test
fun `must serialize Phone with type info`() {
val phone: Phone = LandlinePhone(number = "1234567890", areaCode = "123")
val json = kotlinx.serialization.json.Json.encodeToString(phone)

// Note that "type" is added because we are serializing a subtype of Phone
// (See https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/polymorphism.md#sealed-classes)
assertThat(json).isEqualTo("""
{"type":"landline","number":"1234567890","area_code":"123"}
""".trimIndent())
}

@Test
fun `must serialize LandlinePhone without type info`() {
val phone: LandlinePhone = LandlinePhone(number = "1234567890", areaCode = "123")
val json = kotlinx.serialization.json.Json.encodeToString(phone)

// Note that "type" is not added because we are serializing the specific class LandlinePhone
// (See https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/polymorphism.md#sealed-classes)
assertThat(json).isEqualTo("""
{"number":"1234567890","area_code":"123"}
""".trimIndent())
}

@Test
fun `must deserialize Phone into LandlinePhone`() {
val json = """
{"type":"landline","number":"1234567890","area_code":"123"}
""".trimIndent()
val phone: Phone = kotlinx.serialization.json.Json.decodeFromString(json)
assertThat(phone).isEqualTo(LandlinePhone(number = "1234567890", areaCode = "123"))
}

@Test
fun `must deserialize LandlinePhone specific class`() {
val json = """
{"number":"1234567890","area_code":"123"}
""".trimIndent()
val phone: LandlinePhone = kotlinx.serialization.json.Json.decodeFromString(json)
assertThat(phone).isEqualTo(LandlinePhone(number = "1234567890", areaCode = "123"))
}
}
Loading

0 comments on commit 22944d3

Please sign in to comment.