diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 4884884a081e9..9a8ce99da6720 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -160,7 +160,7 @@ 1.0.0 1.8.10 1.6.4 - 1.4.1 + 1.5.0 3.4.1 3.2.0 4.2.0 diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive-kotlin-serialization-common/runtime/pom.xml b/extensions/resteasy-reactive/quarkus-resteasy-reactive-kotlin-serialization-common/runtime/pom.xml index 562fe31ad0e83..19f23cbc32a5d 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive-kotlin-serialization-common/runtime/pom.xml +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive-kotlin-serialization-common/runtime/pom.xml @@ -26,6 +26,10 @@ org.jetbrains.kotlinx kotlinx-serialization-json + + org.jetbrains.kotlin + kotlin-reflect + io.quarkus quarkus-junit5-internal diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive-kotlin-serialization-common/runtime/src/main/java/io/quarkus/resteasy/reactive/kotlin/serialization/common/runtime/JsonConfig.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive-kotlin-serialization-common/runtime/src/main/java/io/quarkus/resteasy/reactive/kotlin/serialization/common/runtime/JsonConfig.java index 7631e7e0a8e4f..4655bd1b1da86 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive-kotlin-serialization-common/runtime/src/main/java/io/quarkus/resteasy/reactive/kotlin/serialization/common/runtime/JsonConfig.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive-kotlin-serialization-common/runtime/src/main/java/io/quarkus/resteasy/reactive/kotlin/serialization/common/runtime/JsonConfig.java @@ -1,5 +1,6 @@ package io.quarkus.resteasy.reactive.kotlin.serialization.common.runtime; +import java.util.Optional; import java.util.StringJoiner; import io.quarkus.runtime.annotations.ConfigGroup; @@ -100,6 +101,28 @@ public class JsonConfig { @ConfigItem(defaultValue = "false") public boolean useArrayPolymorphism = false; + /** + * Specifies the {@code JsonNamingStrategy} that should be used for all properties in classes for serialization and + * deserialization. + * This strategy is applied for all entities that have {@code StructureKind.CLASS}. + *

+ *

+ * {@code null} by default. + *

+ *

+ * This element can be one of two things: + *

    + *
  1. the fully qualified class name of a type implements the {@code NamingStrategy} interface and has a no-arg + * constructor
  2. + *
  3. a value in the form {@code NamingStrategy.SnakeCase} which refers to built-in values provided by the kotlin + * serialization + * library itself. + *
  4. + *
+ */ + @ConfigItem(name = "naming-strategy") + public Optional namingStrategy; + @Override public String toString() { return new StringJoiner(", ", JsonConfig.class.getSimpleName() + "[", "]") diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive-kotlin-serialization-common/runtime/src/main/kotlin/io/quarkus/resteasy/reactive/kotlin/serialization/common/runtime/JsonProducer.kt b/extensions/resteasy-reactive/quarkus-resteasy-reactive-kotlin-serialization-common/runtime/src/main/kotlin/io/quarkus/resteasy/reactive/kotlin/serialization/common/runtime/JsonProducer.kt index 09c24b4b80235..b6abedc87305b 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive-kotlin-serialization-common/runtime/src/main/kotlin/io/quarkus/resteasy/reactive/kotlin/serialization/common/runtime/JsonProducer.kt +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive-kotlin-serialization-common/runtime/src/main/kotlin/io/quarkus/resteasy/reactive/kotlin/serialization/common/runtime/JsonProducer.kt @@ -7,10 +7,16 @@ import jakarta.enterprise.inject.Produces import jakarta.inject.Singleton import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonBuilder +import kotlinx.serialization.json.JsonNamingStrategy +import kotlinx.serialization.json.JsonNamingStrategy.Builtins +import java.lang.Thread +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.isAccessible @Singleton class JsonProducer { - @ExperimentalSerializationApi @Singleton @Produces @@ -29,12 +35,65 @@ class JsonProducer { useAlternativeNames = configuration.json.useAlternativeNames useArrayPolymorphism = configuration.json.useArrayPolymorphism + configuration.json.namingStrategy.ifPresent { strategy -> + loadStrategy(this, strategy, this@JsonProducer) + } val sortedCustomizers = sortCustomizersInDescendingPriorityOrder(customizers) for (customizer in sortedCustomizers) { customizer.customize(this) } } + @ExperimentalSerializationApi + private fun loadStrategy( + jsonBuilder: JsonBuilder, + strategy: String, + jsonProducer: JsonProducer + ) { + val strategyProperty: KMutableProperty1 = ( + JsonBuilder::class.memberProperties + .find { member -> member.name == "namingStrategy" } + ?: throw ReflectiveOperationException("Could not find the namingStrategy property on JsonBuilder") + ) as KMutableProperty1 + strategyProperty.isAccessible = true + + strategyProperty.set( + jsonBuilder, + if (strategy.startsWith("JsonNamingStrategy")) { + jsonProducer.extractBuiltIn(strategy) + } else { + jsonProducer.loadStrategyClass(strategy) + } + ) + } + + @ExperimentalSerializationApi + private fun loadStrategyClass( + strategy: String + ): JsonNamingStrategy { + try { + val strategyClass: Class = Thread.currentThread().contextClassLoader.loadClass(strategy) as Class + val constructor = strategyClass.constructors + .find { it.parameterCount == 0 } + ?: throw ReflectiveOperationException("No no-arg constructor found on $strategy") + return constructor.newInstance() as JsonNamingStrategy + } catch (e: ReflectiveOperationException) { + throw IllegalArgumentException("Error loading naming strategy: ${strategy.substringAfter('.')}", e) + } + } + + @ExperimentalSerializationApi + private fun extractBuiltIn( + strategy: String + ): JsonNamingStrategy { + val kClass = Builtins::class + val property = kClass.memberProperties.find { property -> + property.name == strategy.substringAfter('.') + } ?: throw IllegalArgumentException("Unknown naming strategy provided: ${strategy.substringAfter('.')}") + + return property.get(JsonNamingStrategy) as JsonNamingStrategy + } + private fun sortCustomizersInDescendingPriorityOrder( customizers: Iterable ): List { diff --git a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/GreetingResource.kt b/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/GreetingResource.kt index 5e7bf549507f3..28a4d209cff58 100644 --- a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/GreetingResource.kt +++ b/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/GreetingResource.kt @@ -62,7 +62,7 @@ class GreetingResource { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) fun marry(person: Person): Person { - return Person(person.name.substringBefore(" ") + " Halpert") + return Person(person.fullName.substringBefore(" ") + " Halpert") } @GET diff --git a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/TitleCase.kt b/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/TitleCase.kt new file mode 100644 index 0000000000000..50b30a3d95645 --- /dev/null +++ b/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/TitleCase.kt @@ -0,0 +1,12 @@ +package io.quarkus.it.kotser + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.json.JsonNamingStrategy + +@OptIn(ExperimentalSerializationApi::class) +class TitleCase : JsonNamingStrategy { + override fun serialNameForJson(descriptor: SerialDescriptor, elementIndex: Int, serialName: String): String { + return serialName[0].uppercase() + serialName.substring(1) + } +} diff --git a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/model/Person.kt b/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/model/Person.kt index 07411157a9b21..b36d1ad8f7ab8 100644 --- a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/model/Person.kt +++ b/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/model/Person.kt @@ -3,7 +3,7 @@ package io.quarkus.it.kotser.model import kotlinx.serialization.Serializable @Serializable -data class Person(var name: String, var defaulted: String = "hi there!") { +data class Person(var fullName: String, var defaulted: String = "hi there!") { override fun toString(): String { TODO("this shouldn't get called. a proper serialization should be invoked.") } diff --git a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/model/Person2.kt b/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/model/Person2.kt index a958d2771e4d2..b5e3718e1d4f9 100644 --- a/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/model/Person2.kt +++ b/integration-tests/kotlin-serialization/src/main/kotlin/io/quarkus/it/kotser/model/Person2.kt @@ -3,7 +3,7 @@ package io.quarkus.it.kotser.model import kotlinx.serialization.Serializable @Serializable -data class Person2(var name: String, var defaulted: String = "hey") { +data class Person2(var fullName: String, var defaulted: String = "hey") { override fun toString(): String { TODO("this shouldn't get called. a proper serialization should be invoked.") } diff --git a/integration-tests/kotlin-serialization/src/main/resources/application.properties b/integration-tests/kotlin-serialization/src/main/resources/application.properties index 4dadb74e15205..cbe86cde035ca 100644 --- a/integration-tests/kotlin-serialization/src/main/resources/application.properties +++ b/integration-tests/kotlin-serialization/src/main/resources/application.properties @@ -1 +1,3 @@ -quarkus.kotlin-serialization.json.encode-defaults=true \ No newline at end of file +quarkus.kotlin-serialization.json.encode-defaults=true +#quarkus.kotlin-serialization.json.naming-strategy=io.quarkus.it.kotser.TitleCase +#quarkus.kotlin-serialization.json.naming-strategy=JsonNamingStrategy.SnakeCase \ No newline at end of file diff --git a/integration-tests/kotlin-serialization/src/test/kotlin/io/quarkus/it/kotser/ResourceTest.kt b/integration-tests/kotlin-serialization/src/test/kotlin/io/quarkus/it/kotser/ResourceTest.kt index a9a19e4a701b5..cb14dd4913d1e 100644 --- a/integration-tests/kotlin-serialization/src/test/kotlin/io/quarkus/it/kotser/ResourceTest.kt +++ b/integration-tests/kotlin-serialization/src/test/kotlin/io/quarkus/it/kotser/ResourceTest.kt @@ -9,16 +9,36 @@ import jakarta.ws.rs.core.MediaType import org.hamcrest.CoreMatchers import org.hamcrest.CoreMatchers.`is` import org.junit.jupiter.api.Test +import java.util.Properties @QuarkusTest open class ResourceTest { + + val nameField: String + var defaulted = "defaulted" + + init { + val properties = Properties() + properties.load(javaClass.getResourceAsStream("/application.properties")) + val strategy: String? = properties.get("quarkus.kotlin-serialization.json.naming-strategy") as String? + when (strategy) { + "JsonNamingStrategy.SnakeCase" -> nameField = "full_name" + TitleCase::class.qualifiedName -> { + nameField = "FullName" + defaulted = "Defaulted" + } + null -> nameField = "fullName" + else -> throw IllegalArgumentException("unknown strategy: $strategy") + } + } + @Test fun testGetFlow() { When { get("/flow") } Then { statusCode(200) - body(`is`("""[{"name":"Jim Halpert","defaulted":"hi there!"}]""")) + body(`is`("""[{"$nameField":"Jim Halpert","$defaulted":"hi there!"}]""")) } } @@ -28,7 +48,7 @@ open class ResourceTest { get("/") } Then { statusCode(200) - body(`is`("""{"name":"Jim Halpert","defaulted":"hi there!"}""")) + body(`is`("""{"$nameField":"Jim Halpert","$defaulted":"hi there!"}""")) } } @@ -38,7 +58,7 @@ open class ResourceTest { get("/restResponse") } Then { statusCode(200) - body(`is`("""{"name":"Jim Halpert","defaulted":"hi there!"}""")) + body(`is`("""{"$nameField":"Jim Halpert","$defaulted":"hi there!"}""")) } } @@ -48,7 +68,7 @@ open class ResourceTest { get("/restResponseList") } Then { statusCode(200) - body(`is`("""[{"name":"Jim Halpert","defaulted":"hi there!"}]""")) + body(`is`("""[{"$nameField":"Jim Halpert","$defaulted":"hi there!"}]""")) } } @@ -58,7 +78,7 @@ open class ResourceTest { get("/unknownType") } Then { statusCode(200) - body(`is`("""{"name":"Foo Bar","defaulted":"hey"}""")) + body(`is`("""{"$nameField":"Foo Bar","$defaulted":"hey"}""")) } } @@ -68,7 +88,7 @@ open class ResourceTest { get("/suspend") } Then { statusCode(200) - body(`is`("""{"name":"Jim Halpert","defaulted":"hi there!"}""")) + body(`is`("""{"$nameField":"Jim Halpert","$defaulted":"hi there!"}""")) } } @@ -78,20 +98,20 @@ open class ResourceTest { get("/suspendList") } Then { statusCode(200) - body(`is`("""[{"name":"Jim Halpert","defaulted":"hi there!"}]""")) + body(`is`("""[{"$nameField":"Jim Halpert","$defaulted":"hi there!"}]""")) } } @Test fun testPost() { Given { - body("""{ "name": "Pam Beasley" }""") + body("""{ "$nameField": "Pam Beasley" }""") contentType(JSON) } When { post("/") } Then { statusCode(200) - body(`is`("""{"name":"Pam Halpert","defaulted":"hi there!"}""")) + body(`is`("""{"$nameField":"Pam Halpert","$defaulted":"hi there!"}""")) } }