Skip to content
Open
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
143 changes: 143 additions & 0 deletions src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package graphql.kickstart.tools.resolver

import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JavaType
import com.fasterxml.jackson.databind.JsonSerializable
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler
import com.fasterxml.jackson.databind.deser.ValueInstantiator
import com.fasterxml.jackson.databind.jsontype.TypeSerializer
import com.fasterxml.jackson.databind.util.TokenBuffer
import java.io.IOException
import java.lang.reflect.Type

/**
* Converts an argument value that graphql-java already coerced to the type of the resolver method's parameter.
*
* ObjectMapper.convertValue serializes a value and reads it back, which the coerced values of many custom scalars don't
* survive, e.g. an upload, or a LocalDate without the jsr310 module. Those values are written as embedded objects
* instead, which Jackson's deserializers return as they are if they have the type being read, and convert with
* toString() if a String is read.
*
* An untyped parameter, e.g. a Map<String, Any?>, gets every coerced value as it is, apart from strings, numbers,
* booleans and enums, which are converted as before.
*/
internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) {

private val javaType = mapper.typeFactory.constructType(type)
private val isUntyped = javaType.isUntyped()

fun convert(value: Any?): Any? {
if (value == null) {
return mapper.convertValue(value, javaType)
}

if (isUntyped) {
// nothing reads an untyped value through its serialized form, so every coerced value is kept
return read(embed(value) { true } ?: value)
}

// a deserializer may read the serialized form, so values are only embedded if converting fails
return try {
mapper.convertValue<Any?>(value, javaType)
} catch (e: IllegalArgumentException) {
// a class whose value failed to convert isn't tried again in this value, since that can be expensive, e.g. for
// a list of uploads. It isn't remembered across calls, so the same input always gives the same result
val nonConvertingClasses = HashSet<Class<*>>()
val embedded = embed(value) { scalar ->
when {
scalar.javaClass in nonConvertingClasses -> true
convertsToOwnClass(scalar) -> false
else -> {
nonConvertingClasses += scalar.javaClass
true
}
}
} ?: throw e
try {
read(embedded)
} catch (retryError: IllegalArgumentException) {
// the values that failed to convert likely hid the actual problem
throw retryError.apply { addSuppressed(e) }
}
}
}

/**
* Returns a copy of the value in which the scalar values that [shouldEmbed] accepts are wrapped in [EmbeddedValue],
* or null if it accepts none.
*/
private fun embed(value: Any, shouldEmbed: (Any) -> Boolean): Any? {
var embedsAny = false
fun copy(value: Any?): Any? = when (value) {
null, is String, is Number, is Boolean, is Enum<*> -> value
is Map<*, *> -> value.mapValuesTo(LinkedHashMap()) { copy(it.value) }
is Collection<*> -> value.map { copy(it) }
else -> if (shouldEmbed(value)) EmbeddedValue(value).also { embedsAny = true } else value
}

return copy(value).takeIf { embedsAny }
}

private fun convertsToOwnClass(value: Any): Boolean = try {
mapper.convertValue(value, value.javaClass)
true
} catch (e: IllegalArgumentException) {
false
}

// ObjectMapper.convertValue, but read with the handler for embedded values
private fun read(value: Any): Any? {
val buffer = TokenBuffer(mapper, false).forceUseOfBigDecimal(mapper.isEnabled(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS))
return try {
writer.writeValue(buffer, value)
reader.readValue<Any?>(buffer.asParser())
} catch (e: IOException) {
throw IllegalArgumentException(e.message, e)
}
}

private val embeddedValueHandler = object : DeserializationProblemHandler() {
// called for an interface or abstract class, e.g. Part, since no deserializer of it returns embedded values
override fun handleMissingInstantiator(
context: DeserializationContext,
instClass: Class<*>,
valueInstantiator: ValueInstantiator?,
p: JsonParser,
msg: String?
): Any? {
val value = if (p.hasToken(JsonToken.VALUE_EMBEDDED_OBJECT)) p.embeddedObject else null
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
override fun handleWeirdNativeValue(context: DeserializationContext, targetType: JavaType, valueToConvert: Any, p: JsonParser): Any? =
mapper.convertValue(valueToConvert, targetType)
}

private val writer = mapper.writer().without(SerializationFeature.WRAP_ROOT_VALUE)

// built on first use, since readerFor looks up the deserializer right away, at schema build, and most arguments
// never need the reader
private val reader by lazy {
mapper.readerFor(javaType)
.without(DeserializationFeature.UNWRAP_ROOT_VALUE)
.withHandler(embeddedValueHandler)
}

// written to the buffer as an embedded object, which it keeps as it is
private class EmbeddedValue(private val value: Any) : JsonSerializable {
override fun serialize(gen: JsonGenerator, serializers: SerializerProvider) = gen.writeEmbeddedObject(value)

override fun serializeWithType(gen: JsonGenerator, serializers: SerializerProvider, typeSer: TypeSerializer) = gen.writeEmbeddedObject(value)
}
}

// Object, or a Map, Collection, array or Optional of it, is read by UntypedObjectDeserializer, which returns embedded values as they are
private fun JavaType.isUntyped(): Boolean = isJavaLangObject || (isContainerType || isReferenceType) && contentType.isUntyped()
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package graphql.kickstart.tools.resolver

import com.fasterxml.jackson.core.type.TypeReference
import graphql.GraphQLContext
import graphql.kickstart.tools.*
import graphql.kickstart.tools.SchemaParserOptions.GenericWrapper
Expand Down Expand Up @@ -77,6 +76,7 @@ internal class MethodFieldResolver(

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

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

if (value == null || shouldValueBeConverted(value, definition, parameterType, environment)) {
return@add mapper.convertValue(value, object : TypeReference<Any>() {
override fun getType() = parameterType
})
return@add argumentConverter.convert(value)
}

return@add value
Expand Down
Loading
Loading