|
| 1 | +package graphql.kickstart.tools |
| 2 | + |
| 3 | +import com.fasterxml.jackson.databind.DeserializationContext |
| 4 | +import com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES |
| 5 | +import com.fasterxml.jackson.databind.JsonDeserializer |
| 6 | +import com.fasterxml.jackson.databind.ObjectMapper |
| 7 | +import com.fasterxml.jackson.databind.deser.BeanDeserializerBase |
| 8 | +import com.fasterxml.jackson.databind.deser.DefaultDeserializationContext |
| 9 | +import com.fasterxml.jackson.databind.deser.std.ContainerDeserializerBase |
| 10 | +import com.fasterxml.jackson.databind.deser.std.ReferenceTypeDeserializer |
| 11 | +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException |
| 12 | +import graphql.kickstart.tools.resolver.MethodFieldResolver |
| 13 | +import graphql.kickstart.tools.util.unwrap |
| 14 | +import graphql.language.TypeName |
| 15 | +import graphql.schema.GraphQLInputObjectField |
| 16 | +import graphql.schema.GraphQLInputObjectType |
| 17 | +import graphql.schema.GraphQLNamedType |
| 18 | +import graphql.schema.GraphQLType |
| 19 | +import graphql.schema.GraphQLTypeUtil |
| 20 | +import org.slf4j.LoggerFactory |
| 21 | + |
| 22 | +/** |
| 23 | + * Checks that the classes of resolver arguments have a property for each field of their input objects, see [UnmappedInputFields]. |
| 24 | + * |
| 25 | + * The fields are looked up in the deserializer that the resolver's ObjectMapper converts the argument with, not in the class |
| 26 | + * itself, so that a field counts as mapped whenever Jackson maps it. An input type can be used by fields whose mappers are |
| 27 | + * configured differently, so it's checked once for each mapper. |
| 28 | + */ |
| 29 | +internal class UnmappedInputFieldChecker( |
| 30 | + private val inputObjects: Map<String, GraphQLInputObjectType>, |
| 31 | + private val unmappedInputFields: UnmappedInputFields |
| 32 | +) { |
| 33 | + private val log = LoggerFactory.getLogger(javaClass) |
| 34 | + |
| 35 | + // a deserializer is cached by its mapper, so this is per mapper, class and input type, and ends recursive input types |
| 36 | + private val checked = mutableSetOf<Pair<JsonDeserializer<*>, String>>() |
| 37 | + |
| 38 | + // reported once, for the first argument found using it |
| 39 | + private val messages = linkedMapOf<Triple<String, String, Class<*>>, String>() |
| 40 | + |
| 41 | + fun check(objectType: String, resolver: MethodFieldResolver) { |
| 42 | + val mapper = resolver.objectMapper |
| 43 | + val context = (mapper.deserializationContext as? DefaultDeserializationContext)?.createDummyInstance(mapper.deserializationConfig) ?: return |
| 44 | + |
| 45 | + resolver.field.inputValueDefinitions.forEachIndexed { index, argument -> |
| 46 | + val inputObject = (argument.type.unwrap() as? TypeName)?.let { inputObjects[it.name] } ?: return@forEachIndexed |
| 47 | + val parameterType = resolver.getMethodParameterType(index) ?: return@forEachIndexed |
| 48 | + val usage = "argument '${argument.name}' of field '$objectType.${resolver.field.name}'" |
| 49 | + |
| 50 | + try { |
| 51 | + // the same lookup as convertValue's when it converts the argument |
| 52 | + val deserializer = context.findRootValueDeserializer(mapper.typeFactory.constructType(parameterType)) |
| 53 | + checkFields(mapper, context, deserializer, inputObject, usage) |
| 54 | + } catch (e: Exception) { |
| 55 | + // e.g. a class Jackson can't deserialize, which only fails once the argument is converted, as it did before this check |
| 56 | + log.debug("Can't check the input fields of $usage with Jackson's deserializer for $parameterType", e) |
| 57 | + } |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | + fun report() { |
| 62 | + if (messages.isEmpty()) { |
| 63 | + return |
| 64 | + } |
| 65 | + |
| 66 | + if (unmappedInputFields == UnmappedInputFields.FAIL) { |
| 67 | + throw SchemaError(messages.values.joinToString("\n")) |
| 68 | + } |
| 69 | + messages.values.forEach { log.warn(it) } |
| 70 | + } |
| 71 | + |
| 72 | + private fun checkFields( |
| 73 | + mapper: ObjectMapper, |
| 74 | + context: DefaultDeserializationContext, |
| 75 | + deserializer: JsonDeserializer<*>?, |
| 76 | + inputObject: GraphQLInputObjectType, |
| 77 | + usage: String |
| 78 | + ) { |
| 79 | + val beanDeserializer = beanDeserializer(context, deserializer) ?: return |
| 80 | + if (!checked.add(beanDeserializer to inputObject.name)) { |
| 81 | + return |
| 82 | + } |
| 83 | + |
| 84 | + inputObject.fields.forEach { field -> |
| 85 | + val property = beanDeserializer.findProperty(field.name) |
| 86 | + if (property == null) { |
| 87 | + knownPropertiesIfUnmapped(mapper, context, beanDeserializer, field.name)?.let { knownProperties -> |
| 88 | + messages.getOrPut(Triple(inputObject.name, field.name, beanDeserializer.handledType())) { |
| 89 | + message(mapper, inputObject, field, beanDeserializer.handledType(), usage, knownProperties) |
| 90 | + } |
| 91 | + } |
| 92 | + } else if (property.valueTypeDeserializer == null) { |
| 93 | + // a polymorphic property's class is only known once its type id is read |
| 94 | + inputObjectOf(field.type)?.let { checkFields(mapper, context, property.valueDeserializer, it, usage) } |
| 95 | + } |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + // the deserializer of the class an input object is converted to, past the lists, arrays and Optionals around it |
| 100 | + private tailrec fun beanDeserializer(context: DeserializationContext, deserializer: JsonDeserializer<*>?): BeanDeserializerBase? = |
| 101 | + when (deserializer) { |
| 102 | + // a delegating creator gets the whole input object, e.g. as a Map |
| 103 | + is BeanDeserializerBase -> deserializer.takeUnless { it.valueInstantiator.canCreateUsingDelegate() } |
| 104 | + // a Map gets the fields as its keys |
| 105 | + is ContainerDeserializerBase<*> -> |
| 106 | + if (deserializer.valueType.isMapLikeType) null else beanDeserializer(context, deserializer.contentDeserializer) |
| 107 | + is ReferenceTypeDeserializer<*> -> |
| 108 | + beanDeserializer(context, context.findContextualValueDeserializer(deserializer.valueType.referencedType, null)) |
| 109 | + else -> null |
| 110 | + } |
| 111 | + |
| 112 | + /** |
| 113 | + * Returns the properties Jackson knows if it fails on the field, or null if it maps or ignores it. |
| 114 | + * |
| 115 | + * `{"<field>": null}` is read into a placeholder object rather than an instance of the class, so neither its constructors nor |
| 116 | + * its setters are called, but the field still goes through Jackson's handling of a property the class doesn't have: ignored |
| 117 | + * names, any setters, ignoreUnknown and problem handlers. Unknown properties are made to fail even if the mapper ignores them. |
| 118 | + * Any other exception, e.g. from a deserializer that can't read into an existing object, means the field can't be checked. |
| 119 | + */ |
| 120 | + private fun knownPropertiesIfUnmapped( |
| 121 | + mapper: ObjectMapper, |
| 122 | + context: DefaultDeserializationContext, |
| 123 | + deserializer: BeanDeserializerBase, |
| 124 | + field: String |
| 125 | + ): Collection<Any>? { |
| 126 | + val config = mapper.deserializationConfig.with(FAIL_ON_UNKNOWN_PROPERTIES) |
| 127 | + return try { |
| 128 | + mapper.treeAsTokens(mapper.createObjectNode().putNull(field)).use { parser -> |
| 129 | + parser.nextToken() |
| 130 | + deserializer.deserialize(parser, context.createInstance(config, parser, mapper.injectableValues), Any()) |
| 131 | + } |
| 132 | + null |
| 133 | + } catch (e: UnrecognizedPropertyException) { |
| 134 | + if (e.propertyName == field) e.knownPropertyIds.orEmpty() else null |
| 135 | + } catch (e: Exception) { |
| 136 | + null |
| 137 | + } |
| 138 | + } |
| 139 | + |
| 140 | + private fun inputObjectOf(type: GraphQLType): GraphQLInputObjectType? = |
| 141 | + (GraphQLTypeUtil.unwrapAllAs<GraphQLType>(type) as? GraphQLNamedType)?.let { inputObjects[it.name] } |
| 142 | + |
| 143 | + private fun message( |
| 144 | + mapper: ObjectMapper, |
| 145 | + inputObject: GraphQLInputObjectType, |
| 146 | + field: GraphQLInputObjectField, |
| 147 | + clazz: Class<*>, |
| 148 | + usage: String, |
| 149 | + knownProperties: Collection<Any> |
| 150 | + ): String { |
| 151 | + val consequence = when { |
| 152 | + !mapper.deserializationConfig.isEnabled(FAIL_ON_UNKNOWN_PROPERTIES) -> "drops the field's value, since the ObjectMapper ignores unknown properties" |
| 153 | + field.hasSetDefaultValue() -> "fails for every '${inputObject.name}' value sent, since the field has a default value" |
| 154 | + else -> "fails whenever the field is sent" |
| 155 | + } |
| 156 | + val properties = knownProperties.map { it.toString() }.sorted().ifEmpty { listOf("none") } |
| 157 | + |
| 158 | + return "Input field '${inputObject.name}.${field.name}' has no matching property in ${clazz.name}. Converting $usage $consequence. " + |
| 159 | + "Known properties: ${properties.joinToString(", ")}. " + |
| 160 | + "Rename the property or map it with @JsonProperty(\"${field.name}\"). If it's left unmapped on purpose, list it in @JsonIgnoreProperties on the class." |
| 161 | + } |
| 162 | +} |
0 commit comments