From 07979369628b5c6e7c10f11ecbd1f841ce317cb0 Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 24 Oct 2026 07:30:58 -0400 Subject: [PATCH 1/3] Test custom scalars without visible properties written with a type or object id --- .../kickstart/tools/CustomScalarInputTest.kt | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt b/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt index d859a32e..fbed674f 100644 --- a/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt @@ -1,6 +1,9 @@ package graphql.kickstart.tools +import com.fasterxml.jackson.annotation.JsonIdentityInfo import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.annotation.ObjectIdGenerators import graphql.ExceptionWhileDataFetching import graphql.GraphQL import graphql.GraphQLContext @@ -217,6 +220,28 @@ class CustomScalarInputTest { assertEquals(data, mapOf("otherTokens" to "OtherToken(value=other) {} {}")) } + @Test + fun `Kotlin custom scalars without visible properties are passed when written with a type or object id`() { + val data = assertNoGraphQlErrors(graphQL(inputSchema)) { + """ + query { + optionalTypedToken(token: "optional") + optionalWrappedToken(token: "optional") + optionalIdToken(token: "optional") + markedTokens(input: { typed: "input", wrapped: "input", id: "input", typedList: ["first", "second"] }) + markedTokenNode(input: { typed: "node", wrapped: "node", id: "node" }) + } + """ + } + + assertEquals(data, mapOf( + "optionalTypedToken" to "optional", + "optionalWrappedToken" to "optional", + "optionalIdToken" to "optional", + "markedTokens" to "input input input [first, second]", + "markedTokenNode" to "node node node")) + } + @Test fun `errors converting inputs with custom scalars name the actual problem`() { val result = graphQL(inputSchema).execute( @@ -234,7 +259,8 @@ class CustomScalarInputTest { private fun graphQL(schema: String, options: SchemaParserOptions = SchemaParserOptions.defaultOptions()): GraphQL { val graphQLSchema = SchemaParser.newParser() .schemaString(schema) - .scalars(localDateScalar, opaqueScalar, secretScalar, boxScalar, moneyScalar, tokenScalar, customScalarUUID, uploadScalar) + .scalars(localDateScalar, opaqueScalar, secretScalar, boxScalar, moneyScalar, tokenScalar, typedTokenScalar, wrappedTokenScalar, + idTokenScalar, customScalarUUID, uploadScalar) .resolvers(resolver) .options(options) .build() @@ -251,6 +277,9 @@ class CustomScalarInputTest { scalar Money scalar Upload scalar Token + scalar TypedToken + scalar WrappedToken + scalar IdToken type Query { event(input: EventInput!): String! @@ -267,6 +296,11 @@ class CustomScalarInputTest { tokenInput(input: TokenInput!): String! tokenNode(input: NodeInput!): String! otherTokens(input: OtherTokensInput!): String! + optionalTypedToken(token: TypedToken): String! + optionalWrappedToken(token: WrappedToken): String! + optionalIdToken(token: IdToken): String! + markedTokens(input: MarkedTokensInput!): String! + markedTokenNode(input: NodeInput!): String! } input EventInput { @@ -291,6 +325,9 @@ class CustomScalarInputTest { secret: Secret price: Money token: Token + typed: TypedToken + wrapped: WrappedToken + id: IdToken } input TokenInput { @@ -303,6 +340,13 @@ class CustomScalarInputTest { node: Token } + input MarkedTokensInput { + typed: TypedToken + wrapped: WrappedToken + id: IdToken + typedList: [TypedToken!] + } + input PaymentInput { price: Money reference: Opaque @@ -341,6 +385,12 @@ class CustomScalarInputTest { 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 optionalTypedToken(token: Optional): String = token.map { it.toString() }.orElse("empty") + fun optionalWrappedToken(token: Optional): String = token.map { it.toString() }.orElse("empty") + fun optionalIdToken(token: Optional): String = token.map { it.toString() }.orElse("empty") + fun markedTokens(input: MarkedTokensInput): String = "${input.typed} ${input.wrapped} ${input.id} ${input.typedList}" + fun markedTokenNode(input: ObjectNode): String = + listOf("typed", "wrapped", "id").joinToString(" ") { input.get(it).let { node -> if (node is POJONode) node.pojo.toString() else "$node" } } fun map(input: Map) = receive("map", input) fun mapList(inputs: List>) = receive("mapList", inputs) @@ -395,6 +445,28 @@ class CustomScalarInputTest { data class OtherToken(val value: String = "other") + @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) + class TypedToken(private val value: String = "default") { + override fun toString() = value + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT) + class WrappedToken(private val value: String = "default") { + override fun toString() = value + } + + @JsonIdentityInfo(generator = ObjectIdGenerators.IntSequenceGenerator::class) + class IdToken(private val value: String = "default") { + override fun toString() = value + } + + data class MarkedTokensInput( + val typed: TypedToken? = null, + val wrapped: WrappedToken? = null, + val id: IdToken? = null, + val typedList: List? = null + ) + data class OtherTokensInput(val bean: OtherToken? = null, val map: Map? = null, val node: ObjectNode? = null) class BoxContentDeserializer : StdDeserializer(String::class.java) { @@ -428,6 +500,9 @@ class CustomScalarInputTest { 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 val typedTokenScalar = scalar("TypedToken") { TypedToken(it) } + private val wrappedTokenScalar = scalar("WrappedToken") { WrappedToken(it) } + private val idTokenScalar = scalar("IdToken") { IdToken(it) } private fun scalar(name: String, parse: (String) -> T): GraphQLScalarType = GraphQLScalarType.newScalar() .name(name) From 771b5f11a2e0d7d5dc94c6cf86a24483c6712eb4 Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 24 Oct 2026 07:57:39 -0400 Subject: [PATCH 2/3] Pass custom scalars without visible properties written with a type or object id --- .../tools/resolver/ArgumentConverter.kt | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt index 27e74bf4..110caa91 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt @@ -1,5 +1,6 @@ package graphql.kickstart.tools.resolver +import com.fasterxml.jackson.annotation.ObjectIdGenerator import tools.jackson.core.JacksonException import tools.jackson.core.JsonGenerator import tools.jackson.core.JsonParser @@ -55,7 +56,7 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { } } ?: throw e try { - read(embedded) + readWithNativeTypeIds(embedded) } catch (retryError: JacksonException) { throw retryError.apply { addSuppressed(e) } } @@ -83,6 +84,9 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { private fun read(value: Any?): Any? = reader.readValue(bufferFailingOnEmptyBeans(value)) + // append copies the empty buffer's support for native ids, so its parser reads the type ids embedded values are written with + private fun readWithNativeTypeIds(value: Any): Any? = reader.readValue(bufferFailingOnEmptyBeans(value).append(TokenBuffer(null, true))) + private fun bufferFailingOnEmptyBeans(value: Any?): TokenBuffer = buffer(FailOnEmptyBeans(value)) private fun buffer(value: Any?): TokenBuffer = writer.writeValueIntoBuffer(value).forceUseOfBigDecimal(useBigDecimalForFloats) @@ -105,12 +109,28 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { 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 + override fun handleUnexpectedToken(context: DeserializationContext, targetType: JavaType, t: JsonToken?, p: JsonParser, failureMsg: String?): Any? { + val value = p.embeddedValue() ?: return NOT_HANDLED + return bindAsObjectId(context, value, targetType) ?: convertOnItsOwn(value, targetType) + } 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) + + // the deserializer of a class with an object id reads an embedded value as the id of an object read before + private fun bindAsObjectId(context: DeserializationContext, value: Any, idType: JavaType): Any? { + val idReader = context.findRootValueDeserializer(context.constructType(value.javaClass)).getObjectIdReader(context) + if (idReader == null || idReader.idType != idType) { + return null + } + val generatorKey = idReader.generator.key(null) + val generator = context.getAttribute(generatorKey) as ObjectIdGenerator<*>? + ?: idReader.generator.newForSerialization(null).also { context.setAttribute(generatorKey, it) } + val id = generator.generateId(value) + context.findObjectId(id, idReader.generator, idReader.resolver).bindItem(context, value) + return id + } } // FAIL_ON_EMPTY_BEANS whatever the mapper says: a scalar class with no visible properties would otherwise be read back as @@ -139,9 +159,8 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { 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)) { + val bean = streamWriteContext().currentValue() + if (bean != null && hasNoProperties(bean.javaClass)) { serializers.reportBadDefinition(bean.javaClass, "No properties discovered to write ${bean.javaClass.name}") } return super.writeEndObject() @@ -153,11 +172,15 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { private class EmbeddedValue(private val value: Any) : JacksonSerializable { override fun serialize(gen: JsonGenerator, serializers: SerializationContext) { + val typeSerializer = serializers.findTypeSerializer(serializers.constructType(value.javaClass)) + gen.writeTypeId(typeSerializer?.typeIdResolver?.idFromValue(serializers, value)) gen.writeEmbeddedObject(value) + // a TokenBuffer gives the type id to every token after it until another one is written + gen.writeTypeId(null) } override fun serializeWithType(gen: JsonGenerator, serializers: SerializationContext, typeSer: TypeSerializer) { - gen.writeEmbeddedObject(value) + serialize(gen, serializers) } } } From a17cb09632bef35ac005d9693cf543c32202760f Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 24 Oct 2026 08:23:30 -0400 Subject: [PATCH 3/3] Fail instead of passing type and object ids for custom scalars without visible properties --- .../tools/resolver/ArgumentConverter.kt | 32 +++--------------- .../kickstart/tools/CustomScalarInputTest.kt | 33 ++++++++++++------- 2 files changed, 26 insertions(+), 39 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt index 110caa91..65c39c2b 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt @@ -1,6 +1,5 @@ package graphql.kickstart.tools.resolver -import com.fasterxml.jackson.annotation.ObjectIdGenerator import tools.jackson.core.JacksonException import tools.jackson.core.JsonGenerator import tools.jackson.core.JsonParser @@ -56,7 +55,7 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { } } ?: throw e try { - readWithNativeTypeIds(embedded) + read(embedded) } catch (retryError: JacksonException) { throw retryError.apply { addSuppressed(e) } } @@ -84,9 +83,6 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { private fun read(value: Any?): Any? = reader.readValue(bufferFailingOnEmptyBeans(value)) - // append copies the empty buffer's support for native ids, so its parser reads the type ids embedded values are written with - private fun readWithNativeTypeIds(value: Any): Any? = reader.readValue(bufferFailingOnEmptyBeans(value).append(TokenBuffer(null, true))) - private fun bufferFailingOnEmptyBeans(value: Any?): TokenBuffer = buffer(FailOnEmptyBeans(value)) private fun buffer(value: Any?): TokenBuffer = writer.writeValueIntoBuffer(value).forceUseOfBigDecimal(useBigDecimalForFloats) @@ -109,28 +105,12 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { 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? { - val value = p.embeddedValue() ?: return NOT_HANDLED - return bindAsObjectId(context, value, targetType) ?: convertOnItsOwn(value, targetType) - } + 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) - - // the deserializer of a class with an object id reads an embedded value as the id of an object read before - private fun bindAsObjectId(context: DeserializationContext, value: Any, idType: JavaType): Any? { - val idReader = context.findRootValueDeserializer(context.constructType(value.javaClass)).getObjectIdReader(context) - if (idReader == null || idReader.idType != idType) { - return null - } - val generatorKey = idReader.generator.key(null) - val generator = context.getAttribute(generatorKey) as ObjectIdGenerator<*>? - ?: idReader.generator.newForSerialization(null).also { context.setAttribute(generatorKey, it) } - val id = generator.generateId(value) - context.findObjectId(id, idReader.generator, idReader.resolver).bindItem(context, value) - return id - } } // FAIL_ON_EMPTY_BEANS whatever the mapper says: a scalar class with no visible properties would otherwise be read back as @@ -172,15 +152,11 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) { private class EmbeddedValue(private val value: Any) : JacksonSerializable { override fun serialize(gen: JsonGenerator, serializers: SerializationContext) { - val typeSerializer = serializers.findTypeSerializer(serializers.constructType(value.javaClass)) - gen.writeTypeId(typeSerializer?.typeIdResolver?.idFromValue(serializers, value)) gen.writeEmbeddedObject(value) - // a TokenBuffer gives the type id to every token after it until another one is written - gen.writeTypeId(null) } override fun serializeWithType(gen: JsonGenerator, serializers: SerializationContext, typeSer: TypeSerializer) { - serialize(gen, serializers) + gen.writeEmbeddedObject(value) } } } diff --git a/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt b/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt index fbed674f..8b900ab8 100644 --- a/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt @@ -221,25 +221,36 @@ class CustomScalarInputTest { } @Test - fun `Kotlin custom scalars without visible properties are passed when written with a type or object id`() { + fun `Kotlin custom scalars without visible properties written with a type or object id are passed in ObjectNode arguments`() { val data = assertNoGraphQlErrors(graphQL(inputSchema)) { """ query { - optionalTypedToken(token: "optional") - optionalWrappedToken(token: "optional") - optionalIdToken(token: "optional") - markedTokens(input: { typed: "input", wrapped: "input", id: "input", typedList: ["first", "second"] }) markedTokenNode(input: { typed: "node", wrapped: "node", id: "node" }) } """ } - assertEquals(data, mapOf( - "optionalTypedToken" to "optional", - "optionalWrappedToken" to "optional", - "optionalIdToken" to "optional", - "markedTokens" to "input input input [first, second]", - "markedTokenNode" to "node node node")) + assertEquals(data, mapOf("markedTokenNode" to "node node node")) + } + + @Test + fun `Kotlin custom scalars without visible properties written with a type or object id fail rather than lose their value`() { + val result = graphQL(inputSchema).execute( + """ + query { + optionalTypedToken(token: "optional") + optionalWrappedToken(token: "optional") + optionalIdToken(token: "optional") + typed: markedTokens(input: { typed: "input" }) + wrapped: markedTokens(input: { wrapped: "input" }) + id: markedTokens(input: { id: "input" }) + typedList: markedTokens(input: { typedList: ["first", "second"] }) + } + """) + + assertEquals( + result.errors.map { it.path?.single() }.toSet(), + setOf("optionalTypedToken", "optionalWrappedToken", "optionalIdToken", "typed", "wrapped", "id", "typedList")) } @Test