Skip to content

Commit 0140de6

Browse files
authored
Merge pull request #856 from graphql-java-kickstart/bugfix/436
Pass coerced custom scalar values through to input objects and Map arguments
2 parents a653953 + 3606993 commit 0140de6

3 files changed

Lines changed: 512 additions & 4 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package graphql.kickstart.tools.resolver
2+
3+
import com.fasterxml.jackson.core.JsonGenerator
4+
import com.fasterxml.jackson.core.JsonParser
5+
import com.fasterxml.jackson.core.JsonToken
6+
import com.fasterxml.jackson.databind.DeserializationContext
7+
import com.fasterxml.jackson.databind.DeserializationFeature
8+
import com.fasterxml.jackson.databind.JavaType
9+
import com.fasterxml.jackson.databind.JsonSerializable
10+
import com.fasterxml.jackson.databind.ObjectMapper
11+
import com.fasterxml.jackson.databind.SerializationFeature
12+
import com.fasterxml.jackson.databind.SerializerProvider
13+
import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler
14+
import com.fasterxml.jackson.databind.deser.ValueInstantiator
15+
import com.fasterxml.jackson.databind.jsontype.TypeSerializer
16+
import com.fasterxml.jackson.databind.util.TokenBuffer
17+
import java.io.IOException
18+
import java.lang.reflect.Type
19+
20+
/**
21+
* Converts an argument value that graphql-java already coerced to the type of the resolver method's parameter.
22+
*
23+
* ObjectMapper.convertValue serializes a value and reads it back, which the coerced values of many custom scalars don't
24+
* survive, e.g. an upload, or a LocalDate without the jsr310 module. Those values are written as embedded objects
25+
* instead, which Jackson's deserializers return as they are if they have the type being read, and convert with
26+
* toString() if a String is read.
27+
*
28+
* An untyped parameter, e.g. a Map<String, Any?>, gets every coerced value as it is, apart from strings, numbers,
29+
* booleans and enums, which are converted as before.
30+
*/
31+
internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) {
32+
33+
private val javaType = mapper.typeFactory.constructType(type)
34+
private val isUntyped = javaType.isUntyped()
35+
36+
fun convert(value: Any?): Any? {
37+
if (value == null) {
38+
return mapper.convertValue(value, javaType)
39+
}
40+
41+
if (isUntyped) {
42+
// nothing reads an untyped value through its serialized form, so every coerced value is kept
43+
return read(embed(value) { true } ?: value)
44+
}
45+
46+
// a deserializer may read the serialized form, so values are only embedded if converting fails
47+
return try {
48+
mapper.convertValue<Any?>(value, javaType)
49+
} catch (e: IllegalArgumentException) {
50+
// a class whose value failed to convert isn't tried again in this value, since that can be expensive, e.g. for
51+
// a list of uploads. It isn't remembered across calls, so the same input always gives the same result
52+
val nonConvertingClasses = HashSet<Class<*>>()
53+
val embedded = embed(value) { scalar ->
54+
when {
55+
scalar.javaClass in nonConvertingClasses -> true
56+
convertsToOwnClass(scalar) -> false
57+
else -> {
58+
nonConvertingClasses += scalar.javaClass
59+
true
60+
}
61+
}
62+
} ?: throw e
63+
try {
64+
read(embedded)
65+
} catch (retryError: IllegalArgumentException) {
66+
// the values that failed to convert likely hid the actual problem
67+
throw retryError.apply { addSuppressed(e) }
68+
}
69+
}
70+
}
71+
72+
/**
73+
* Returns a copy of the value in which the scalar values that [shouldEmbed] accepts are wrapped in [EmbeddedValue],
74+
* or null if it accepts none.
75+
*/
76+
private fun embed(value: Any, shouldEmbed: (Any) -> Boolean): Any? {
77+
var embedsAny = false
78+
fun copy(value: Any?): Any? = when (value) {
79+
null, is String, is Number, is Boolean, is Enum<*> -> value
80+
is Map<*, *> -> value.mapValuesTo(LinkedHashMap()) { copy(it.value) }
81+
is Collection<*> -> value.map { copy(it) }
82+
else -> if (shouldEmbed(value)) EmbeddedValue(value).also { embedsAny = true } else value
83+
}
84+
85+
return copy(value).takeIf { embedsAny }
86+
}
87+
88+
private fun convertsToOwnClass(value: Any): Boolean = try {
89+
mapper.convertValue(value, value.javaClass)
90+
true
91+
} catch (e: IllegalArgumentException) {
92+
false
93+
}
94+
95+
// ObjectMapper.convertValue, but read with the handler for embedded values
96+
private fun read(value: Any): Any? {
97+
val buffer = TokenBuffer(mapper, false).forceUseOfBigDecimal(mapper.isEnabled(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS))
98+
return try {
99+
writer.writeValue(buffer, value)
100+
reader.readValue<Any?>(buffer.asParser())
101+
} catch (e: IOException) {
102+
throw IllegalArgumentException(e.message, e)
103+
}
104+
}
105+
106+
private val embeddedValueHandler = object : DeserializationProblemHandler() {
107+
// called for an interface or abstract class, e.g. Part, since no deserializer of it returns embedded values
108+
override fun handleMissingInstantiator(
109+
context: DeserializationContext,
110+
instClass: Class<*>,
111+
valueInstantiator: ValueInstantiator?,
112+
p: JsonParser,
113+
msg: String?
114+
): Any? {
115+
val value = if (p.hasToken(JsonToken.VALUE_EMBEDDED_OBJECT)) p.embeddedObject else null
116+
return if (instClass.isInstance(value)) value else NOT_HANDLED
117+
}
118+
119+
// called for an embedded value read as a bean of another class, which the value is converted to on its own
120+
override fun handleWeirdNativeValue(context: DeserializationContext, targetType: JavaType, valueToConvert: Any, p: JsonParser): Any? =
121+
mapper.convertValue(valueToConvert, targetType)
122+
}
123+
124+
private val writer = mapper.writer().without(SerializationFeature.WRAP_ROOT_VALUE)
125+
126+
// built on first use, since readerFor looks up the deserializer right away, at schema build, and most arguments
127+
// never need the reader
128+
private val reader by lazy {
129+
mapper.readerFor(javaType)
130+
.without(DeserializationFeature.UNWRAP_ROOT_VALUE)
131+
.withHandler(embeddedValueHandler)
132+
}
133+
134+
// written to the buffer as an embedded object, which it keeps as it is
135+
private class EmbeddedValue(private val value: Any) : JsonSerializable {
136+
override fun serialize(gen: JsonGenerator, serializers: SerializerProvider) = gen.writeEmbeddedObject(value)
137+
138+
override fun serializeWithType(gen: JsonGenerator, serializers: SerializerProvider, typeSer: TypeSerializer) = gen.writeEmbeddedObject(value)
139+
}
140+
}
141+
142+
// Object, or a Map, Collection, array or Optional of it, is read by UntypedObjectDeserializer, which returns embedded values as they are
143+
private fun JavaType.isUntyped(): Boolean = isJavaLangObject || (isContainerType || isReferenceType) && contentType.isUntyped()

‎src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package graphql.kickstart.tools.resolver
22

3-
import com.fasterxml.jackson.core.type.TypeReference
43
import graphql.GraphQLContext
54
import graphql.kickstart.tools.*
65
import graphql.kickstart.tools.SchemaParserOptions.GenericWrapper
@@ -77,6 +76,7 @@ internal class MethodFieldResolver(
7776

7877
val isNonNull = definition.type is NonNullType
7978
val isOptional = this.typeResolver.getRawClass(parameterType) == Optional::class.java
79+
val argumentConverter = ArgumentConverter(mapper, parameterType)
8080

8181
args.add { environment ->
8282
val argumentPresent = environment.arguments.containsKey(definition.name)
@@ -94,9 +94,7 @@ internal class MethodFieldResolver(
9494
}
9595

9696
if (value == null || shouldValueBeConverted(value, definition, parameterType, environment)) {
97-
return@add mapper.convertValue(value, object : TypeReference<Any>() {
98-
override fun getType() = parameterType
99-
})
97+
return@add argumentConverter.convert(value)
10098
}
10199

102100
return@add value

0 commit comments

Comments
 (0)