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

Support for Kotlin Serialization #327

Merged
merged 18 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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