diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt new file mode 100644 index 00000000..6c55b85f --- /dev/null +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/ArgumentConverter.kt @@ -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, 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(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>() + 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(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() diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt index 3c222bc7..c9a1046f 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt @@ -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 @@ -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) @@ -94,9 +94,7 @@ internal class MethodFieldResolver( } if (value == null || shouldValueBeConverted(value, definition, parameterType, environment)) { - return@add mapper.convertValue(value, object : TypeReference() { - override fun getType() = parameterType - }) + return@add argumentConverter.convert(value) } return@add value diff --git a/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt b/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt new file mode 100644 index 00000000..ffb8f9cc --- /dev/null +++ b/src/test/kotlin/graphql/kickstart/tools/CustomScalarInputTest.kt @@ -0,0 +1,367 @@ +package graphql.kickstart.tools + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.fasterxml.jackson.databind.exc.InvalidDefinitionException +import com.fasterxml.jackson.databind.exc.MismatchedInputException +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.databind.node.POJONode +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer +import graphql.ExceptionWhileDataFetching +import graphql.GraphQL +import graphql.GraphQLContext +import graphql.execution.CoercedVariables +import graphql.language.StringValue +import graphql.language.Value +import graphql.schema.Coercing +import graphql.schema.GraphQLScalarType +import org.junit.Test +import java.time.LocalDate +import java.util.* +import javax.servlet.http.Part + +class CustomScalarInputTest { + + private val resolver = QueryResolver() + + @Test + fun `custom scalars in input objects are passed to the resolver`() { + val variables = mapOf( + "event" to mapOf("date" to "2021-03-04", "opaque" to "variable"), + "file" to MockPart("a.txt", "Hello"), + "files" to listOf(MockPart("b.txt", "big"), MockPart("c.txt", "world")) + ) + val data = assertNoGraphQlErrors(graphQL(inputSchema), variables) { + """ + query(${'$'}event: EventInput!, ${'$'}file: Upload, ${'$'}files: [Upload!]) { + literal: event(input: { date: "2020-01-02", opaque: "literal" }) + opaqueOnly: event(input: { opaque: "alone" }) + variable: event(input: ${'$'}event) + file: event(input: { file: ${'$'}file }) + events(inputs: [{ date: "2020-01-02" }, { opaque: "second" }]) + optionalEvent(input: { date: "2020-01-02", opaque: "optional" }) + message(input: { name: "greeting", file: ${'$'}file, files: ${'$'}files }) + dateNode(input: { date: "2020-01-02" }) + dateLabel: label(input: { date: "2020-01-02" }) + opaqueLabel: label(input: { opaque: "secret" }) + } + """ + } + + assertEquals(data - "opaqueLabel", mapOf( + "literal" to "2020-01-02 literal", + "opaqueOnly" to "alone", + "variable" to "2021-03-04 variable", + "file" to "a.txt: Hello", + "events" to listOf("2020-01-02", "second"), + "optionalEvent" to "2020-01-02 optional", + "message" to "greeting: Hello big world", + "dateNode" to "LocalDate 2020-01-02", + "dateLabel" to "2020-01-02 null" + )) + // a String gets toString(), which is meaningless for a class that doesn't override it + assert((data["opaqueLabel"] as String).startsWith("null ${Opaque::class.java.name}@")) { data["opaqueLabel"].toString() } + } + + @Test + fun `custom scalars in Map arguments are passed as coerced values`() { + val schema = """ + scalar LocalDate + scalar Money + scalar UUID + + type Query { + map(input: MapInput!): Boolean! + mapList(inputs: [MapInput!]!): Boolean! + optionalMap(input: OptionalMapInput): Boolean! + rawMap(input: RawMapInput!): Boolean! + } + + input MapInput { + name: String + date: LocalDate + price: Money + id: UUID + next: MapInput + } + + input OptionalMapInput { + price: Money + } + + input RawMapInput { + price: Money + } + """ + + assertNoGraphQlErrors(graphQL(schema)) { + """ + query { + map(input: { name: null, date: "2020-01-02", price: "5", id: "4dbd9a3d-4a26-4d10-9bb2-5b2dc4c8a0f0", next: { date: "2021-03-04" } }) + mapList(inputs: [{ price: "6" }, { price: "7" }]) + optionalMap(input: { price: "8" }) + rawMap(input: { price: "9" }) + } + """ + } + + // including those the ObjectMapper has a serializer for, e.g. UUID + assertEquals(resolver.received, mapOf( + "map" to mapOf( + "name" to null, + "date" to LocalDate.of(2020, 1, 2), + "price" to Money(5, "CAD"), + "id" to UUID.fromString("4dbd9a3d-4a26-4d10-9bb2-5b2dc4c8a0f0"), + "next" to mapOf("date" to LocalDate.of(2021, 3, 4)) + ), + "mapList" to listOf(mapOf("price" to Money(6, "CAD")), mapOf("price" to Money(7, "CAD"))), + "optionalMap" to Optional.of(mapOf("price" to Money(8, "CAD"))), + "rawMap" to mapOf("price" to Money(9, "CAD")) + )) + + assertNoGraphQlErrors(graphQL(schema, configuredMapperOptions)) { + """ + query { + map(input: { name: null, date: "2020-01-02" }) + } + """ + } + + // the configured (de)serializers aren't used, but the inclusion setting is + assertEquals(resolver.received["map"], mapOf("date" to LocalDate.of(2020, 1, 2))) + } + + @Test + fun `custom scalars the ObjectMapper can convert are still converted by it`() { + val data = assertNoGraphQlErrors(graphQL(inputSchema)) { + """ + query { + payment(input: { price: "5", reference: "ref", box: "text" }) + withDate: payment(input: { price: "5", reference: "ref", box: "text", date: "2020-01-02" }) + boxOfDate: payment(input: { box: "date:2021-03-04" }) + afterBoxOfDate: payment(input: { price: "5", reference: "ref", box: "text", date: "2020-01-02" }) + priceNode(input: { price: "5" }) + price(price: "5") + } + """ + } + val configured = assertNoGraphQlErrors(graphQL(inputSchema, configuredMapperOptions), mapOf("file" to MockPart("a.txt", "Hello"))) { + """ + query(${'$'}file: Upload) { + event(input: { date: "2020-01-02", file: ${'$'}file }) + } + """ + } + + // a box of a date can't be converted, so the deserializer gets it embedded and has no serialized form to read. + // That doesn't change how a later box of text is read + assertEquals(data, mapOf( + "payment" to "MoneyDto(amount=5, currency=CAD) OpaqueDto(value=ref) text", + "withDate" to "MoneyDto(amount=5, currency=CAD) OpaqueDto(value=ref) 2020-01-02 text", + "boxOfDate" to "", + "afterBoxOfDate" to "MoneyDto(amount=5, currency=CAD) OpaqueDto(value=ref) 2020-01-02 text", + "priceNode" to """{"price":{"amount":5,"currency":"CAD"}}""", + "price" to "MoneyDto(amount=5, currency=CAD)" + )) + assertEquals(configured, mapOf("event" to "2020-01-03 copy of a.txt: Hello")) + } + + @Test + fun `errors converting inputs with custom scalars name the actual problem`() { + val result = graphQL(inputSchema).execute( + """ + query { + message(input: { name: null, date: "2020-01-02" }) + } + """) + + val exception = (result.errors.single() as ExceptionWhileDataFetching).exception + assertEquals((exception.cause as MismatchedInputException).path.map { it.fieldName }, listOf("name")) + assertEquals((exception.suppressed.single().cause as InvalidDefinitionException).type.rawClass, LocalDate::class.java) + } + + private fun graphQL(schema: String, options: SchemaParserOptions = SchemaParserOptions.defaultOptions()): GraphQL { + val graphQLSchema = SchemaParser.newParser() + .schemaString(schema) + .scalars(localDateScalar, opaqueScalar, boxScalar, moneyScalar, customScalarUUID, uploadScalar) + .resolvers(resolver) + .options(options) + .build() + .makeExecutableSchema() + return GraphQL.newGraphQL(graphQLSchema).build() + } + + private val inputSchema = """ + scalar LocalDate + scalar Opaque + scalar Box + scalar Money + scalar Upload + + type Query { + event(input: EventInput!): String! + events(inputs: [EventInput!]!): [String!]! + optionalEvent(input: EventInput): String! + message(input: MessageInput!): String! + label(input: LabelInput!): String! + dateNode(input: NodeInput!): String! + priceNode(input: NodeInput!): String! + payment(input: PaymentInput!): String! + price(price: Money!): String! + } + + input EventInput { + date: LocalDate + opaque: Opaque + file: Upload + } + + input MessageInput { + name: String + date: LocalDate + file: Upload + files: [Upload!] + } + + input LabelInput { + date: LocalDate + opaque: Opaque + } + + input NodeInput { + date: LocalDate + price: Money + } + + input PaymentInput { + price: Money + reference: Opaque + date: LocalDate + box: Box + } + """ + + private val configuredMapperOptions = SchemaParserOptions.newOptions() + .objectMapperConfigurer { mapper, _ -> + mapper.registerModule(SimpleModule() + .addSerializer(LocalDate::class.java, ToStringSerializer.instance) + .addDeserializer(LocalDate::class.java, NextDayDeserializer()) + .addSerializer(Part::class.java, PartSerializer()) + .addDeserializer(Part::class.java, PartDeserializer())) + mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) + } + .build() + + class QueryResolver : GraphQLQueryResolver { + val received = mutableMapOf() + + fun event(input: EventInput): String = + listOfNotNull(input.date, input.opaque?.value, input.file?.let { "${it.name}: ${it.content()}" }).joinToString(" ") + + fun events(inputs: List): List = inputs.map { event(it) } + fun optionalEvent(input: Optional): String = input.map { event(it) }.orElse("empty") + fun message(input: MessageInput): String = "${input.name}: ${(listOfNotNull(input.file) + input.files).joinToString(" ") { it.content() }}" + fun label(input: LabelInput): String = "${input.date} ${input.opaque}" + fun dateNode(input: ObjectNode): String = (input["date"] as POJONode).pojo.let { "${it.javaClass.simpleName} $it" } + fun priceNode(input: ObjectNode): String = input.toString() + fun payment(input: PaymentInput): String = listOfNotNull(input.price, input.reference, input.date, input.box).joinToString(" ") + fun price(price: MoneyDto): String = price.toString() + + fun map(input: Map) = receive("map", input) + fun mapList(inputs: List>) = receive("mapList", inputs) + fun optionalMap(input: Optional>) = receive("optionalMap", input) + + // the signature from https://github.com/graphql-java-kickstart/graphql-java-tools/issues/424 + fun rawMap(input: LinkedHashMap<*, *>) = receive("rawMap", input) + + private fun receive(field: String, input: Any): Boolean { + received[field] = input + return true + } + + private fun Part.content() = String(inputStream.readBytes()) + } + + class EventInput { + var date: LocalDate? = null + var opaque: Opaque? = null + var file: Part? = null + } + + data class MessageInput(val name: String, val date: LocalDate? = null, val file: Part? = null, val files: List = emptyList()) + + data class LabelInput(val date: String? = null, val opaque: String? = null) + + class PaymentInput { + var price: MoneyDto? = null + var reference: OpaqueDto? = null + var date: LocalDate? = null + + @field:JsonDeserialize(using = BoxContentDeserializer::class) + var box: String? = null + } + + // serialized as {"value": ...}, which Jackson can't create it from, like the coerced values of many custom scalars + class Opaque(text: String) { + val value = text + } + + data class OpaqueDto(val value: String) + + data class Money(val amount: Int, val currency: String) + + data class MoneyDto(val amount: Int, val currency: String) + + // only converts if its content does + data class Box(val content: Any) + + // reads the serialized form, and an embedded Box wouldn't fail it + class BoxContentDeserializer : StdDeserializer(String::class.java) { + override fun deserialize(p: JsonParser, context: DeserializationContext): String = + p.codec.readTree(p).path("content").textValue() ?: "" + } + + class NextDayDeserializer : StdDeserializer(LocalDate::class.java) { + override fun deserialize(p: JsonParser, context: DeserializationContext): LocalDate = LocalDate.parse(p.text).plusDays(1) + } + + // the workaround from https://github.com/graphql-java-kickstart/graphql-java-tools/issues/466 + class PartSerializer : StdSerializer(Part::class.java) { + override fun serialize(value: Part, gen: JsonGenerator, provider: SerializerProvider) { + gen.writeStartObject() + gen.writeStringField("name", value.name) + gen.writeBinaryField("content", value.inputStream.readBytes()) + gen.writeEndObject() + } + } + + class PartDeserializer : StdDeserializer(Part::class.java) { + override fun deserialize(p: JsonParser, context: DeserializationContext): Part { + val node = p.codec.readTree(p) + return MockPart("copy of ${node["name"].textValue()}", String(node["content"].binaryValue())) + } + } + + private val localDateScalar = scalar("LocalDate") { LocalDate.parse(it) } + private val opaqueScalar = scalar("Opaque") { Opaque(it) } + private val boxScalar = scalar("Box") { Box(if (it.startsWith("date:")) LocalDate.parse(it.removePrefix("date:")) else it) } + private val moneyScalar = scalar("Money") { Money(it.toInt(), "CAD") } + + private fun scalar(name: String, parse: (String) -> T): GraphQLScalarType = GraphQLScalarType.newScalar() + .name(name) + .coercing(object : Coercing { + override fun serialize(dataFetcherResult: Any, context: GraphQLContext, locale: Locale) = dataFetcherResult.toString() + override fun parseValue(input: Any, context: GraphQLContext, locale: Locale): T = parse(input as String) + override fun parseLiteral(input: Value<*>, variables: CoercedVariables, context: GraphQLContext, locale: Locale): T = + parse(checkNotNull((input as StringValue).value)) + }) + .build() +}