From f374f9e71dcf7f9a5dbd58bed7748e0d0dc25169 Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 22:58:03 -0400 Subject: [PATCH 1/4] Test Kotlin custom scalars without visible properties in arguments --- .../kickstart/tools/CustomScalarInputTest.kt | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt b/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt index 39221484..4504ef52 100644 --- a/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt @@ -188,6 +188,21 @@ class CustomScalarInputTest { assertEquals(data, mapOf("payment" to "kept")) } + @Test + fun `Kotlin custom scalars without visible properties are passed`() { + val data = assertNoGraphQlErrors(graphQL(inputSchema)) { + """ + query { + token(token: "argument") + tokenInput(input: { token: "input" }) + tokenNode(input: { token: "node" }) + } + """ + } + + assertEquals(data, mapOf("token" to "argument", "tokenInput" to "input", "tokenNode" to "node")) + } + @Test fun `errors converting inputs with custom scalars name the actual problem`() { val result = graphQL(inputSchema).execute( @@ -205,7 +220,7 @@ class CustomScalarInputTest { private fun graphQL(schema: String, options: SchemaParserOptions = SchemaParserOptions.defaultOptions()): GraphQL { val graphQLSchema = SchemaParser.newParser() .schemaString(schema) - .scalars(localDateScalar, opaqueScalar, secretScalar, boxScalar, moneyScalar, customScalarUUID, uploadScalar) + .scalars(localDateScalar, opaqueScalar, secretScalar, boxScalar, moneyScalar, tokenScalar, customScalarUUID, uploadScalar) .resolvers(resolver) .options(options) .build() @@ -221,6 +236,7 @@ class CustomScalarInputTest { scalar Box scalar Money scalar Upload + scalar Token type Query { event(input: EventInput!): String! @@ -232,6 +248,9 @@ class CustomScalarInputTest { priceNode(input: NodeInput!): String! payment(input: PaymentInput!): String! price(price: Money!): String! + token(token: Token!): String! + tokenInput(input: TokenInput!): String! + tokenNode(input: NodeInput!): String! } input EventInput { @@ -255,6 +274,11 @@ class CustomScalarInputTest { input NodeInput { secret: Secret price: Money + token: Token + } + + input TokenInput { + token: Token } input PaymentInput { @@ -290,6 +314,9 @@ class CustomScalarInputTest { fun priceNode(input: ObjectNode): String = input.toString() fun payment(input: PaymentInput): String = listOfNotNull(input.price, input.reference, input.secret, input.box).joinToString(" ") fun price(price: MoneyDto): String = price.toString() + fun token(token: Token): String = token.toString() + fun tokenInput(input: TokenInput): String = input.token.toString() + fun tokenNode(input: ObjectNode): String = input.get("token").let { if (it is POJONode) it.pojo.toString() else "$it" } fun map(input: Map) = receive("map", input) fun mapList(inputs: List>) = receive("mapList", inputs) @@ -336,6 +363,12 @@ class CustomScalarInputTest { data class Box(val content: Any) + class Token(private val value: String = "default") { + override fun toString() = value + } + + data class TokenInput(val token: Token? = null) + class BoxContentDeserializer : StdDeserializer(String::class.java) { override fun deserialize(p: JsonParser, context: DeserializationContext): String = p.readValueAsTree().path("content").stringValue("") @@ -366,6 +399,7 @@ class CustomScalarInputTest { private val secretScalar = scalar("Secret") { Secret(it) } private val boxScalar = scalar("Box") { Box(if (it.startsWith("secret:")) Secret(it.removePrefix("secret:")) else it) } private val moneyScalar = scalar("Money") { Money(it.toInt(), "CAD") } + private val tokenScalar = scalar("Token") { Token(it) } private fun scalar(name: String, parse: (String) -> T): GraphQLScalarType = GraphQLScalarType.newScalar() .name(name) From 5ef780528f866a177a5a0f95e48e89884befe8f2 Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 23:01:29 -0400 Subject: [PATCH 2/4] Embed Kotlin custom scalars that Jackson writes as empty objects Jackson skips FAIL_ON_EMPTY_BEANS for a class with annotations and writes it as {}. Every Kotlin class has @Metadata, so a Kotlin scalar class without visible properties was read back as a new default instance, or as {} into an ObjectNode. The argument converter now fails on any bean written without properties, so the value is embedded like an unannotated Java class. --- .../tools/resolver/ArgumentConverter.kt | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt index 2b3530b1..3ee35823 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt @@ -4,6 +4,7 @@ import tools.jackson.core.JacksonException import tools.jackson.core.JsonGenerator import tools.jackson.core.JsonParser import tools.jackson.core.JsonToken +import tools.jackson.core.util.JsonGeneratorDelegate import tools.jackson.databind.DeserializationContext import tools.jackson.databind.DeserializationFeature import tools.jackson.databind.JacksonSerializable @@ -14,6 +15,7 @@ import tools.jackson.databind.SerializationFeature import tools.jackson.databind.deser.DeserializationProblemHandler import tools.jackson.databind.deser.ValueInstantiator import tools.jackson.databind.jsontype.TypeSerializer +import tools.jackson.databind.ser.bean.BeanSerializerBase import tools.jackson.databind.util.TokenBuffer import java.lang.reflect.Type @@ -81,7 +83,9 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { private fun read(value: Any?): Any? = reader.readValue(buffer(value)) - private fun buffer(value: Any?): TokenBuffer = + private fun buffer(value: Any?): TokenBuffer = bufferAllowingEmptyBeans(FailOnEmptyBeans(value)) + + private fun bufferAllowingEmptyBeans(value: Any?): TokenBuffer = writer.writeValueIntoBuffer(value).forceUseOfBigDecimal(useBigDecimalForFloats) private val embeddedValueHandler = object : DeserializationProblemHandler() { @@ -97,9 +101,10 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { return if (instClass.isInstance(value)) value else NOT_HANDLED } - // called for an embedded value read as a bean of another class, which the value is converted to on its own + // called for an embedded value read as a bean of another class, which the value is converted to on its own, empty beans + // included, since the value can't be passed as it is anyway override fun handleWeirdNativeValue(context: DeserializationContext, targetType: JavaType, valueToConvert: Any, p: JsonParser): Any? = - mapper.readValue(buffer(valueToConvert), targetType) + mapper.readValue(bufferAllowingEmptyBeans(valueToConvert), targetType) } // FAIL_ON_EMPTY_BEANS whatever the mapper says: a scalar class with no visible properties would otherwise be read back as @@ -114,6 +119,32 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { private val nullToJavaDefaultReader by lazy { reader.without(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) } + // FAIL_ON_EMPTY_BEANS skips a class with annotations, which Jackson writes as an empty object even if it has no properties, + // e.g. any Kotlin class, since they all have @Metadata + private class FailOnEmptyBeans(private val value: Any?) : JacksonSerializable { + override fun serialize(gen: JsonGenerator, serializers: SerializationContext) { + serializers.writeValue(EmptyBeanCheck(gen, serializers), value) + } + + override fun serializeWithType(gen: JsonGenerator, serializers: SerializationContext, typeSer: TypeSerializer) { + serialize(gen, serializers) + } + } + + private class EmptyBeanCheck(gen: JsonGenerator, private val serializers: SerializationContext) : JsonGeneratorDelegate(gen, false) { + override fun writeEndObject(): JsonGenerator { + val context = streamWriteContext() + val bean = context.currentValue() + if (context.entryCount == 0 && bean != null && hasNoProperties(bean.javaClass)) { + serializers.reportBadDefinition(bean.javaClass, "No properties discovered to write ${bean.javaClass.name}") + } + return super.writeEndObject() + } + + private fun hasNoProperties(beanClass: Class<*>) = + (serializers.findValueSerializer(beanClass) as? BeanSerializerBase)?.properties()?.hasNext() == false + } + private class EmbeddedValue(private val value: Any) : JacksonSerializable { override fun serialize(gen: JsonGenerator, serializers: SerializationContext) { gen.writeEmbeddedObject(value) From 13e7af5e97a00a17cc90cd521af59b15b27d20ec Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 24 Oct 2026 07:05:47 -0400 Subject: [PATCH 3/4] Test a Kotlin custom scalar without visible properties in an Optional argument --- .../kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt b/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt index 4504ef52..5e98243a 100644 --- a/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt @@ -194,13 +194,14 @@ class CustomScalarInputTest { """ query { token(token: "argument") + optionalToken(token: "optional") tokenInput(input: { token: "input" }) tokenNode(input: { token: "node" }) } """ } - assertEquals(data, mapOf("token" to "argument", "tokenInput" to "input", "tokenNode" to "node")) + assertEquals(data, mapOf("token" to "argument", "optionalToken" to "optional", "tokenInput" to "input", "tokenNode" to "node")) } @Test @@ -249,6 +250,7 @@ class CustomScalarInputTest { payment(input: PaymentInput!): String! price(price: Money!): String! token(token: Token!): String! + optionalToken(token: Token): String! tokenInput(input: TokenInput!): String! tokenNode(input: NodeInput!): String! } @@ -315,6 +317,7 @@ class CustomScalarInputTest { fun payment(input: PaymentInput): String = listOfNotNull(input.price, input.reference, input.secret, input.box).joinToString(" ") fun price(price: MoneyDto): String = price.toString() fun token(token: Token): String = token.toString() + fun optionalToken(token: Optional): String = token.map { it.toString() }.orElse("empty") fun tokenInput(input: TokenInput): String = input.token.toString() fun tokenNode(input: ObjectNode): String = input.get("token").let { if (it is POJONode) it.pojo.toString() else "$it" } From 91d25e21f821011c3392e11f51d1c77cfd622bea Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 24 Oct 2026 07:19:48 -0400 Subject: [PATCH 4/4] Convert embedded values read as a Map or JsonNode on their own A custom scalar without visible properties is now embedded, and Map and JsonNode deserializers reject embedded values, so a Kotlin scalar class in a Map or ObjectNode field failed where it used to convert through {}. --- .../tools/resolver/ArgumentConverter.kt | 24 +++++++++++------- .../kickstart/tools/CustomScalarInputTest.kt | 25 +++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt index 3ee35823..27e74bf4 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt @@ -75,18 +75,17 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { } private fun convertsToOwnClass(value: Any): Boolean = try { - mapper.readValue(buffer(value), value.javaClass) + mapper.readValue(bufferFailingOnEmptyBeans(value), value.javaClass) true } catch (e: JacksonException) { false } - private fun read(value: Any?): Any? = reader.readValue(buffer(value)) + private fun read(value: Any?): Any? = reader.readValue(bufferFailingOnEmptyBeans(value)) - private fun buffer(value: Any?): TokenBuffer = bufferAllowingEmptyBeans(FailOnEmptyBeans(value)) + private fun bufferFailingOnEmptyBeans(value: Any?): TokenBuffer = buffer(FailOnEmptyBeans(value)) - private fun bufferAllowingEmptyBeans(value: Any?): TokenBuffer = - writer.writeValueIntoBuffer(value).forceUseOfBigDecimal(useBigDecimalForFloats) + private fun buffer(value: Any?): TokenBuffer = writer.writeValueIntoBuffer(value).forceUseOfBigDecimal(useBigDecimalForFloats) private val embeddedValueHandler = object : DeserializationProblemHandler() { // called for an interface or abstract class, e.g. Part, since no deserializer of it returns embedded values @@ -97,14 +96,21 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { p: JsonParser, msg: String? ): Any? { - val value = if (p.hasToken(JsonToken.VALUE_EMBEDDED_OBJECT)) p.embeddedObject else null + val value = p.embeddedValue() return if (instClass.isInstance(value)) value else NOT_HANDLED } - // called for an embedded value read as a bean of another class, which the value is converted to on its own, empty beans - // included, since the value can't be passed as it is anyway + // called for an embedded value read as a bean of another class, which the value is converted to on its own override fun handleWeirdNativeValue(context: DeserializationContext, targetType: JavaType, valueToConvert: Any, p: JsonParser): Any? = - mapper.readValue(bufferAllowingEmptyBeans(valueToConvert), targetType) + convertOnItsOwn(valueToConvert, targetType) + + // called for an embedded value read as a Map or JsonNode, e.g. a scalar without properties, which converts to {} + override fun handleUnexpectedToken(context: DeserializationContext, targetType: JavaType, t: JsonToken?, p: JsonParser, failureMsg: String?): Any? = + p.embeddedValue()?.let { convertOnItsOwn(it, targetType) } ?: NOT_HANDLED + + private fun JsonParser.embeddedValue(): Any? = if (hasToken(JsonToken.VALUE_EMBEDDED_OBJECT)) embeddedObject else null + + private fun convertOnItsOwn(value: Any, targetType: JavaType): Any? = mapper.readValue(buffer(value), targetType) } // FAIL_ON_EMPTY_BEANS whatever the mapper says: a scalar class with no visible properties would otherwise be read back as diff --git a/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt b/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt index 5e98243a..d859a32e 100644 --- a/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt @@ -204,6 +204,19 @@ class CustomScalarInputTest { assertEquals(data, mapOf("token" to "argument", "optionalToken" to "optional", "tokenInput" to "input", "tokenNode" to "node")) } + @Test + fun `Kotlin custom scalars without visible properties still convert to other classes`() { + val data = assertNoGraphQlErrors(graphQL(inputSchema)) { + """ + query { + otherTokens(input: { bean: "bean", map: "map", node: "node" }) + } + """ + } + + assertEquals(data, mapOf("otherTokens" to "OtherToken(value=other) {} {}")) + } + @Test fun `errors converting inputs with custom scalars name the actual problem`() { val result = graphQL(inputSchema).execute( @@ -253,6 +266,7 @@ class CustomScalarInputTest { optionalToken(token: Token): String! tokenInput(input: TokenInput!): String! tokenNode(input: NodeInput!): String! + otherTokens(input: OtherTokensInput!): String! } input EventInput { @@ -283,6 +297,12 @@ class CustomScalarInputTest { token: Token } + input OtherTokensInput { + bean: Token + map: Token + node: Token + } + input PaymentInput { price: Money reference: Opaque @@ -319,6 +339,7 @@ class CustomScalarInputTest { fun token(token: Token): String = token.toString() fun optionalToken(token: Optional): String = token.map { it.toString() }.orElse("empty") fun tokenInput(input: TokenInput): String = input.token.toString() + fun otherTokens(input: OtherTokensInput): String = "${input.bean} ${input.map} ${input.node}" fun tokenNode(input: ObjectNode): String = input.get("token").let { if (it is POJONode) it.pojo.toString() else "$it" } fun map(input: Map) = receive("map", input) @@ -372,6 +393,10 @@ class CustomScalarInputTest { data class TokenInput(val token: Token? = null) + data class OtherToken(val value: String = "other") + + data class OtherTokensInput(val bean: OtherToken? = null, val map: Map? = null, val node: ObjectNode? = null) + class BoxContentDeserializer : StdDeserializer(String::class.java) { override fun deserialize(p: JsonParser, context: DeserializationContext): String = p.readValueAsTree().path("content").stringValue("")