diff --git a/pom.xml b/pom.xml
index 69df3183..f9e50d44 100644
--- a/pom.xml
+++ b/pom.xml
@@ -294,6 +294,18 @@
11
true
+
+
+
+
+ default-testCompile
+
+ false
+
+
+
diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt
index 5459c470..fe903b99 100644
--- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt
+++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt
@@ -649,11 +649,6 @@ internal class SchemaClassScanner(
* At runtime the value is whatever the input object's class (or the Map) deserializes it into.
*/
private fun handleInputValueWithoutJavaType(graphQLType: InputObjectTypeDefinition, inputValueDefinition: InputValueDefinition, inputGraphQLType: TypeName, clazz: Class<*>?) {
- if (clazz != null && !isPropertyMap(clazz)) {
- log.warn("Cannot find a public getter or field for field '${inputValueDefinition.name}: ${inputGraphQLType.name}' of input type '${graphQLType.name}' on ${clazz.name}. " +
- "If Jackson doesn't map it some other way, e.g. through @JsonProperty, the field's value is rejected or dropped when the input is deserialized.")
- }
-
when (val definition = definitionsByName[inputGraphQLType.name]) {
is ScalarTypeDefinition -> handleFoundScalarType(definition)
is InputObjectTypeDefinition -> {
diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt
index ce1d4d3b..9253cc9f 100644
--- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt
+++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt
@@ -5,6 +5,7 @@ import graphql.Scalars
import graphql.introspection.Introspection
import graphql.introspection.Introspection.DirectiveLocation.INPUT_FIELD_DEFINITION
import graphql.kickstart.tools.directive.DirectiveWiringHelper
+import graphql.kickstart.tools.resolver.MethodFieldResolver
import graphql.kickstart.tools.util.extensionsOf
import graphql.kickstart.tools.util.getDocumentation
import graphql.kickstart.tools.util.unwrap
@@ -86,6 +87,13 @@ class SchemaParser internal constructor(
}
val interfaces = interfaceDefinitions.map { createInterfaceObject(it, inputObjects) }
val objects = objectDefinitions.map { createObject(it, interfaces, inputObjects) }
+ if (options.unmappedInputFields != UnmappedInputFields.IGNORE) {
+ val checker = UnmappedInputFieldChecker(inputObjects.associateBy { it.name }, options.unmappedInputFields)
+ objectDefinitions.forEach { type ->
+ fieldResolversByType[type].orEmpty().values.filterIsInstance().forEach { checker.check(type.name, it) }
+ }
+ checker.report()
+ }
val unions = unionDefinitions.map { createUnionObject(it, objects) }
val enums = enumDefinitions.map { createEnumObject(it) }
diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParserOptions.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParserOptions.kt
index d1c6a5e8..3f9afa5f 100644
--- a/src/main/kotlin/graphql/kickstart/tools/SchemaParserOptions.kt
+++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParserOptions.kt
@@ -37,7 +37,8 @@ data class SchemaParserOptions internal constructor(
val fieldVisibility: GraphqlFieldVisibility?,
val includeUnusedTypes: Boolean,
val useCommentsForDescriptions: Boolean,
- val allowUndeclaredDirectives: Boolean
+ val allowUndeclaredDirectives: Boolean,
+ val unmappedInputFields: UnmappedInputFields
) {
companion object {
@JvmStatic
@@ -68,6 +69,7 @@ data class SchemaParserOptions internal constructor(
private var includeUnusedTypes = false
private var useCommentsForDescriptions = true
private var allowUndeclaredDirectives = false
+ private var unmappedInputFields = UnmappedInputFields.WARN
fun contextClass(contextClass: Class<*>) = this.apply {
this.contextClass = contextClass
@@ -162,6 +164,24 @@ data class SchemaParserOptions internal constructor(
this.allowUndeclaredDirectives = allowUndeclaredDirectives
}
+ /**
+ * What to do about input object fields that the class of a resolver argument has no property for. Jackson fails to convert
+ * the argument when such a field is sent, or drops the field's value if the ObjectMapper ignores unknown properties.
+ * Defaults to [UnmappedInputFields.WARN].
+ *
+ * The classes are checked when the schema is built, with the deserializer of each resolver field's own ObjectMapper, so
+ * anything that maps a property for Jackson counts, e.g. setters, creators, naming strategies, aliases, ignored properties
+ * and any setters. A field is still reported if the ObjectMapper ignores unknown properties, since that's usually set for
+ * every class at once, but not if its class has `@JsonIgnoreProperties(ignoreUnknown = true)`.
+ *
+ * Classes that Jackson doesn't deserialize property by property aren't checked: those with a custom deserializer, a
+ * delegating creator, a builder or type info, and those with injected values. Neither are classes with `@JsonUnwrapped`
+ * properties, whose unknown fields Jackson drops, nor arguments bound to a Map, a JsonNode or a type variable.
+ */
+ fun unmappedInputFields(unmappedInputFields: UnmappedInputFields) = this.apply {
+ this.unmappedInputFields = unmappedInputFields
+ }
+
fun build(): SchemaParserOptions {
val coroutineContextProvider = coroutineContextProvider
?: DefaultCoroutineContextProvider(Dispatchers.Default)
@@ -203,7 +223,8 @@ data class SchemaParserOptions internal constructor(
fieldVisibility,
includeUnusedTypes,
useCommentsForDescriptions,
- allowUndeclaredDirectives
+ allowUndeclaredDirectives,
+ unmappedInputFields
)
}
}
diff --git a/src/main/kotlin/graphql/kickstart/tools/UnmappedInputFieldChecker.kt b/src/main/kotlin/graphql/kickstart/tools/UnmappedInputFieldChecker.kt
new file mode 100644
index 00000000..1980bdac
--- /dev/null
+++ b/src/main/kotlin/graphql/kickstart/tools/UnmappedInputFieldChecker.kt
@@ -0,0 +1,178 @@
+package graphql.kickstart.tools
+
+import com.fasterxml.jackson.databind.BeanProperty
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
+import com.fasterxml.jackson.databind.JavaType
+import com.fasterxml.jackson.databind.JsonDeserializer
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.databind.deser.BeanDeserializerBase
+import com.fasterxml.jackson.databind.deser.DefaultDeserializationContext
+import com.fasterxml.jackson.databind.deser.std.ContainerDeserializerBase
+import com.fasterxml.jackson.databind.deser.std.ReferenceTypeDeserializer
+import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
+import graphql.kickstart.tools.resolver.MethodFieldResolver
+import graphql.kickstart.tools.util.unwrap
+import graphql.language.TypeName
+import graphql.schema.GraphQLInputObjectField
+import graphql.schema.GraphQLInputObjectType
+import graphql.schema.GraphQLNamedType
+import graphql.schema.GraphQLType
+import graphql.schema.GraphQLTypeUtil
+import org.slf4j.LoggerFactory
+
+/**
+ * Checks that the classes of resolver arguments have a property for each field of their input objects, see [UnmappedInputFields].
+ *
+ * The fields are looked up in the deserializer that the resolver's ObjectMapper converts the argument with, not in the class
+ * itself, so that a field counts as mapped whenever Jackson maps it. An input type can be used by fields whose mappers are
+ * configured differently, so it's checked once for each mapper.
+ */
+internal class UnmappedInputFieldChecker(
+ private val inputObjects: Map,
+ private val unmappedInputFields: UnmappedInputFields
+) {
+ private val log = LoggerFactory.getLogger(javaClass)
+
+ // a deserializer is cached by its mapper, so this is per mapper, class and input type, and ends recursive input types
+ private val checked = mutableSetOf, String>>()
+
+ // reported once for each consequence, with the first argument found using it, since mappers differ in ignoring unknown properties
+ private val messages = linkedMapOf()
+
+ fun check(objectType: String, resolver: MethodFieldResolver) {
+ resolver.field.inputValueDefinitions.forEachIndexed { index, argument ->
+ val inputObject = (argument.type.unwrap() as? TypeName)?.let { inputObjects[it.name] } ?: return@forEachIndexed
+ val usage = "argument '${argument.name}' of field '$objectType.${resolver.field.name}'"
+
+ try {
+ val parameterType = resolver.getMethodParameterType(index) ?: return@forEachIndexed
+ val mapper = resolver.objectMapper
+ val context = (mapper.deserializationContext as? DefaultDeserializationContext)
+ ?.createDummyInstance(mapper.deserializationConfig) ?: return@forEachIndexed
+ // the same lookup as convertValue's when it converts the argument
+ val deserializer = context.findRootValueDeserializer(mapper.typeFactory.constructType(parameterType))
+ checkFields(mapper, context, deserializer, null, inputObject, usage)
+ } catch (e: VirtualMachineError) {
+ throw e
+ } catch (e: Throwable) {
+ // e.g. a class Jackson can't deserialize, or one that refers to a missing class, which only fails once the argument is
+ // converted, as it did before this check. Errors are caught too, since the lookup used to run on the first query
+ log.debug("Can't check the input fields of $usage with Jackson", e)
+ }
+ }
+ }
+
+ fun report() {
+ if (messages.isEmpty()) {
+ return
+ }
+
+ if (unmappedInputFields == UnmappedInputFields.FAIL) {
+ throw SchemaError(messages.values.joinToString("\n"))
+ }
+ messages.values.forEach { log.warn(it) }
+ }
+
+ private fun checkFields(
+ mapper: ObjectMapper,
+ context: DefaultDeserializationContext,
+ deserializer: JsonDeserializer<*>?,
+ property: BeanProperty?,
+ inputObject: GraphQLInputObjectType,
+ usage: String
+ ) {
+ val beanDeserializer = beanDeserializer(context, deserializer, property) ?: return
+ if (!checked.add(beanDeserializer to inputObject.name)) {
+ return
+ }
+
+ inputObject.fields.forEach { field ->
+ val property = beanDeserializer.findProperty(field.name)
+ if (property == null) {
+ knownPropertiesIfUnmapped(mapper, context, beanDeserializer, field.name)?.let { knownProperties ->
+ val unmapped = Unmapped(inputObject.name, field.name, beanDeserializer.handledType(), consequence(mapper, inputObject, field))
+ messages.getOrPut(unmapped) { message(unmapped, usage, knownProperties) }
+ }
+ } else if (property.valueTypeDeserializer == null) {
+ // a polymorphic property's class is only known once its type id is read
+ inputObjectOf(field.type)?.let { checkFields(mapper, context, property.valueDeserializer, property, it, usage) }
+ }
+ }
+ }
+
+ // the deserializer of the class an input object is converted to, past the lists, arrays and Optionals around it
+ private tailrec fun beanDeserializer(context: DeserializationContext, deserializer: JsonDeserializer<*>?, property: BeanProperty?): BeanDeserializerBase? =
+ when (deserializer) {
+ // a delegating creator gets the whole input object, e.g. as a Map
+ is BeanDeserializerBase -> deserializer.takeUnless { it.valueInstantiator.canCreateUsingDelegate() }
+ // a Map gets the fields as its keys
+ is ContainerDeserializerBase<*> ->
+ if (deserializer.valueType.isMapLikeType || isPolymorphic(context, deserializer.contentType)) null
+ else beanDeserializer(context, deserializer.contentDeserializer, property)
+ is ReferenceTypeDeserializer<*> -> {
+ val referencedType = deserializer.valueType.referencedType
+ // looked up with the property, like Jackson does, so that its annotations apply, e.g. @JsonIgnoreProperties
+ if (isPolymorphic(context, referencedType)) null
+ else beanDeserializer(context, context.findContextualValueDeserializer(referencedType, property), property)
+ }
+ else -> null
+ }
+
+ /**
+ * Whether the elements of a list, an array or an Optional are read with type info, so that their class is only known once their
+ * type id is read. Their deserializer is the base class's, which doesn't know the subclasses' properties. The same lookup as
+ * Jackson's when it creates the deserializer: type info from a property's annotation, or else from the class or default typing.
+ */
+ private fun isPolymorphic(context: DeserializationContext, contentType: JavaType): Boolean =
+ contentType.getTypeHandler() != null || context.factory.findTypeDeserializer(context.config, contentType) != null
+
+ /**
+ * Returns the properties Jackson knows if it fails on the field, or null if it maps or ignores it.
+ *
+ * `{"": null}` is read into a placeholder object rather than an instance of the class, so neither its constructors nor
+ * its setters are called, but the field still goes through Jackson's handling of a property the class doesn't have: ignored
+ * names, any setters, ignoreUnknown and problem handlers. Unknown properties are made to fail even if the mapper ignores them.
+ * Any other exception, e.g. from a deserializer that can't read into an existing object, means the field can't be checked.
+ */
+ private fun knownPropertiesIfUnmapped(
+ mapper: ObjectMapper,
+ context: DefaultDeserializationContext,
+ deserializer: BeanDeserializerBase,
+ field: String
+ ): Collection? {
+ val config = mapper.deserializationConfig.with(FAIL_ON_UNKNOWN_PROPERTIES)
+ return try {
+ mapper.treeAsTokens(mapper.createObjectNode().putNull(field)).use { parser ->
+ parser.nextToken()
+ deserializer.deserialize(parser, context.createInstance(config, parser, mapper.injectableValues), Any())
+ }
+ null
+ } catch (e: UnrecognizedPropertyException) {
+ if (e.propertyName == field) e.knownPropertyIds.orEmpty() else null
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ private fun inputObjectOf(type: GraphQLType): GraphQLInputObjectType? =
+ (GraphQLTypeUtil.unwrapAllAs(type) as? GraphQLNamedType)?.let { inputObjects[it.name] }
+
+ private fun consequence(mapper: ObjectMapper, inputObject: GraphQLInputObjectType, field: GraphQLInputObjectField): String =
+ when {
+ !mapper.deserializationConfig.isEnabled(FAIL_ON_UNKNOWN_PROPERTIES) -> "drops the field's value, since the ObjectMapper ignores unknown properties"
+ field.hasSetDefaultValue() -> "fails for every '${inputObject.name}' value sent, since the field has a default value"
+ else -> "fails whenever the field is sent"
+ }
+
+ private fun message(unmapped: Unmapped, usage: String, knownProperties: Collection): String {
+ val properties = knownProperties.map { it.toString() }.sorted().ifEmpty { listOf("none") }
+
+ return "Input field '${unmapped.inputObject}.${unmapped.field}' has no matching property in ${unmapped.clazz.name}. " +
+ "Converting $usage ${unmapped.consequence}. " +
+ "Known properties: ${properties.joinToString(", ")}. " +
+ "Rename the property or map it with @JsonProperty(\"${unmapped.field}\"). If it's left unmapped on purpose, list it in @JsonIgnoreProperties on the class."
+ }
+
+ private data class Unmapped(val inputObject: String, val field: String, val clazz: Class<*>, val consequence: String)
+}
diff --git a/src/main/kotlin/graphql/kickstart/tools/UnmappedInputFields.kt b/src/main/kotlin/graphql/kickstart/tools/UnmappedInputFields.kt
new file mode 100644
index 00000000..80a4e296
--- /dev/null
+++ b/src/main/kotlin/graphql/kickstart/tools/UnmappedInputFields.kt
@@ -0,0 +1,16 @@
+package graphql.kickstart.tools
+
+/**
+ * What the [SchemaParser] does about input object fields that the class of a resolver argument has no property for,
+ * see [SchemaParserOptions.Builder.unmappedInputFields].
+ */
+enum class UnmappedInputFields {
+ /** Doesn't check the classes. */
+ IGNORE,
+
+ /** Logs a warning for each field. */
+ WARN,
+
+ /** Fails with a [SchemaError] that lists every field. */
+ FAIL
+}
diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt
index c9a1046f..50d296d9 100644
--- a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt
+++ b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt
@@ -1,5 +1,6 @@
package graphql.kickstart.tools.resolver
+import com.fasterxml.jackson.databind.ObjectMapper
import graphql.GraphQLContext
import graphql.kickstart.tools.*
import graphql.kickstart.tools.SchemaParserOptions.GenericWrapper
@@ -48,9 +49,12 @@ internal class MethodFieldResolver(
private val numberOfParameters = method.parameterCountWithoutContinuation()
private val hasAdditionalParameter = numberOfParameters == (field.inputValueDefinitions.size + getIndexOffset() + 1)
+ // the arguments are converted with the same ObjectMapper that the UnmappedInputFieldChecker checks their classes with
+ internal val objectMapper: ObjectMapper by lazy { options.objectMapperProvider.provide(field) }
+
override fun createDataFetcher(): DataFetcher<*> {
val args = mutableListOf()
- val mapper = options.objectMapperProvider.provide(field)
+ val mapper = objectMapper
// Add source argument if this is a resolver (but not a root resolver)
if (this.search.requiredFirstParameterType != null) {
@@ -198,7 +202,8 @@ internal class MethodFieldResolver(
private fun getJavaMethodParameterIndex(index: Int) = index + getIndexOffset()
- private fun getMethodParameterType(index: Int): JavaType? {
+ // the type that the value of the argument at the index is converted to
+ internal fun getMethodParameterType(index: Int): JavaType? {
val methodIndex = getJavaMethodParameterIndex(index)
val parameters = method.parameterTypes
diff --git a/src/test/kotlin/graphql/kickstart/tools/UnmappedInputFieldsFixtures.java b/src/test/kotlin/graphql/kickstart/tools/UnmappedInputFieldsFixtures.java
new file mode 100644
index 00000000..3ba4faf8
--- /dev/null
+++ b/src/test/kotlin/graphql/kickstart/tools/UnmappedInputFieldsFixtures.java
@@ -0,0 +1,203 @@
+package graphql.kickstart.tools;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonUnwrapped;
+
+import java.beans.ConstructorProperties;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+// Input classes as they're written in Java, by hand or with Lombok, so their properties aren't named by the Kotlin module.
+public class UnmappedInputFieldsFixtures {
+
+ // the class from https://github.com/graphql-java-kickstart/graphql-java-tools/issues/77#issuecomment-662855626
+ public static class ProjectInput {
+ private Integer id;
+ private String name;
+ private String description;
+ private List technolgies;
+ private List profiles;
+
+ public Integer getId() {
+ return id;
+ }
+
+ public void setId(Integer id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public List getTechnolgies() {
+ return technolgies;
+ }
+
+ public void setTechnolgies(List technolgies) {
+ this.technolgies = technolgies;
+ }
+
+ public List getProfiles() {
+ return profiles;
+ }
+
+ public void setProfiles(List profiles) {
+ this.profiles = profiles;
+ }
+ }
+
+ public static class TechnologyInput {
+ private Integer id;
+ private String name;
+
+ public Integer getId() {
+ return id;
+ }
+
+ public void setId(Integer id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+ }
+
+ public static class SetterOnlyInput {
+ private String name;
+ private NestedInput nested;
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public void setNested(NestedInput nested) {
+ this.nested = nested;
+ }
+
+ public String describe() {
+ return name + " " + nested.describe();
+ }
+ }
+
+ public static class NestedInput {
+ private String value;
+
+ public void setValue(String value) {
+ this.value = value;
+ }
+
+ public String describe() {
+ return value;
+ }
+ }
+
+ public static class ConstructorPropertiesInput {
+ private final String name;
+
+ @ConstructorProperties({"name"})
+ public ConstructorPropertiesInput(String name) {
+ this.name = name;
+ }
+
+ public String describe() {
+ return name;
+ }
+ }
+
+ public static class PrivateFieldInput {
+ @JsonProperty("name")
+ private String value;
+
+ public String describe() {
+ return value;
+ }
+ }
+
+ public static class AliasInput {
+ private String title;
+
+ @JsonAlias("name")
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String describe() {
+ return title;
+ }
+ }
+
+ public static class AnySetterInput {
+ private final Map values = new TreeMap<>();
+
+ @JsonAnySetter
+ public void set(String name, Object value) {
+ values.put(name, value);
+ }
+
+ public String describe() {
+ return values.toString();
+ }
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class IgnoreUnknownInput {
+ public String name;
+ }
+
+ @JsonIgnoreProperties({"legacy"})
+ public static class IgnoredNameInput {
+ public String name;
+ }
+
+ public static class JsonIgnoreInput {
+ public String name;
+
+ @JsonIgnore
+ public String secret;
+ }
+
+ public static class UnwrappedInput {
+ public String name;
+
+ @JsonUnwrapped
+ public Address address;
+ }
+
+ public static class Address {
+ public String street;
+ }
+
+ public static class ConflictingCreatorsInput {
+ @JsonCreator
+ public ConflictingCreatorsInput(@JsonProperty("name") String name) {
+ }
+
+ @JsonCreator
+ public ConflictingCreatorsInput(@JsonProperty("name") Integer name) {
+ }
+ }
+}
diff --git a/src/test/kotlin/graphql/kickstart/tools/UnmappedInputFieldsTest.kt b/src/test/kotlin/graphql/kickstart/tools/UnmappedInputFieldsTest.kt
new file mode 100644
index 00000000..7bb65261
--- /dev/null
+++ b/src/test/kotlin/graphql/kickstart/tools/UnmappedInputFieldsTest.kt
@@ -0,0 +1,1042 @@
+package graphql.kickstart.tools
+
+import ch.qos.logback.classic.Level
+import ch.qos.logback.classic.Logger
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.core.read.ListAppender
+import com.fasterxml.jackson.annotation.JacksonInject
+import com.fasterxml.jackson.annotation.JsonCreator
+import com.fasterxml.jackson.annotation.JsonFormat
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonSubTypes
+import com.fasterxml.jackson.annotation.JsonTypeInfo
+import com.fasterxml.jackson.annotation.JsonUnwrapped
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.databind.BeanDescription
+import com.fasterxml.jackson.databind.DeserializationConfig
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.databind.InjectableValues
+import com.fasterxml.jackson.databind.JavaType
+import com.fasterxml.jackson.databind.JsonDeserializer
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.PropertyNamingStrategies
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.fasterxml.jackson.databind.annotation.JsonNaming
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer
+import com.fasterxml.jackson.databind.module.SimpleDeserializers
+import com.fasterxml.jackson.databind.module.SimpleModule
+import graphql.GraphQL
+import graphql.kickstart.tools.UnmappedInputFieldsFixtures.*
+import graphql.schema.DataFetchingEnvironment
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.slf4j.LoggerFactory
+import java.util.*
+
+class UnmappedInputFieldsTest {
+
+ @Test
+ fun `misspelled property of an input class is reported`() {
+ val (schema, warnings) = withWarnings { projectParser().makeExecutableSchema() }
+
+ assertEquals(warnings, listOf(TECHNOLOGIES_MESSAGE))
+
+ // the warning predicts what happens when a client sends the field
+ val result = GraphQL.newGraphQL(schema).build().execute(
+ """
+ mutation {
+ createProject(input: { name: "tools", technologies: [{ name: "Kotlin" }] })
+ }
+ """)
+ assertContains(result.errors.single().message, "Unrecognized field \"technologies\"")
+ }
+
+ @Test
+ fun `misspelled property of an input class fails the build if asked to`() {
+ val error = assertThrows(SchemaError::class.java) {
+ projectParser(failOptions().build()).makeExecutableSchema()
+ }
+
+ assertEquals(error.message, TECHNOLOGIES_MESSAGE)
+ }
+
+ @Test
+ fun `misspelled scalar property and Kotlin property named like a boolean getter are reported`() {
+ val (schema, warnings) = withWarnings {
+ SchemaParser.newParser()
+ .schemaString(
+ """
+ type Query {
+ greet(input: GreetingInput!): String
+ active(input: ActiveInput!): Boolean
+ }
+
+ input GreetingInput {
+ name: String!
+ }
+
+ input ActiveInput {
+ active: Boolean
+ }
+ """)
+ .resolvers(object : GraphQLQueryResolver {
+ fun greet(input: GreetingInput): String? = input.nmae
+ fun active(input: ActiveInput): Boolean = input.isActive
+ })
+ .build()
+ .makeExecutableSchema()
+ }
+
+ // the Kotlin module keeps the "is" of a property, unlike the getter name of a Java boolean
+ assertEquals(warnings, listOf(
+ unmapped("GreetingInput.name", GreetingInput::class.java, "argument 'input' of field 'Query.greet'", "nmae"),
+ unmapped("ActiveInput.active", ActiveInput::class.java, "argument 'input' of field 'Query.active'", "isActive")
+ ))
+
+ val result = GraphQL.newGraphQL(schema).build().execute("query { active(input: { active: true }) }")
+ assertContains(result.errors.single().message, "Unrecognized field \"active\"")
+ }
+
+ @Test
+ fun `misspelled properties of nested input classes are all reported through lists, arrays and Optionals`() {
+ val error = assertThrows(SchemaError::class.java) {
+ SchemaParser.newParser()
+ .schemaString(
+ """
+ type Query {
+ outer(input: OuterInput): String
+ }
+
+ input OuterInput {
+ middle: MiddleInput
+ }
+
+ input MiddleInput {
+ list: [ListItemInput]
+ array: [ArrayItemInput]
+ optional: OptionalItemInput
+ }
+
+ input ListItemInput {
+ name: String
+ }
+
+ input ArrayItemInput {
+ name: String
+ }
+
+ input OptionalItemInput {
+ name: String
+ }
+ """)
+ .resolvers(object : GraphQLQueryResolver {
+ fun outer(input: Optional): String? = input.toString()
+ })
+ .options(failOptions().build())
+ .build()
+ .makeExecutableSchema()
+ }
+
+ val usage = "argument 'input' of field 'Query.outer'"
+ assertEquals(error.message, listOf(
+ unmapped("ListItemInput.name", ListItemInput::class.java, usage, "nmae"),
+ unmapped("ArrayItemInput.name", ArrayItemInput::class.java, usage, "nmae"),
+ unmapped("OptionalItemInput.name", OptionalItemInput::class.java, usage, "nmae")
+ ).joinToString("\n"))
+ }
+
+ @Test
+ fun `input class used by several fields is reported once, including fields from extensions`() {
+ val error = assertThrows(SchemaError::class.java) {
+ SchemaParser.newParser()
+ .schemaString(
+ """
+ type Query {
+ test: String
+ }
+
+ type Mutation {
+ create(input: ItemInput!): String
+ update(input: ItemInput!): String
+ }
+
+ input ItemInput {
+ name: String
+ }
+
+ extend input ItemInput {
+ label: String
+ }
+ """)
+ .resolvers(object : GraphQLQueryResolver {
+ fun test(): String? = null
+ }, object : GraphQLMutationResolver {
+ fun create(input: ItemInput): String? = input.name
+ fun update(input: ItemInput): String? = input.name
+ })
+ .options(failOptions().build())
+ .build()
+ .makeExecutableSchema()
+ }
+
+ assertEquals(error.message, unmapped("ItemInput.label", ItemInput::class.java, "argument 'input' of field 'Mutation.create'", "lable, name"))
+ }
+
+ @Test
+ fun `misspelled property is reported if the ObjectMapper ignores unknown properties`() {
+ val (schema, warnings) = withWarnings {
+ SchemaParser.newParser()
+ .schemaString(
+ """
+ type Query {
+ greet(input: GreetingInput!): String
+ }
+
+ input GreetingInput {
+ name: String!
+ }
+ """)
+ .resolvers(object : GraphQLQueryResolver {
+ fun greet(input: GreetingInput): String? = input.nmae
+ })
+ .options(SchemaParserOptions.newOptions()
+ .objectMapperConfigurer { mapper, _ -> mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) }
+ .build())
+ .build()
+ .makeExecutableSchema()
+ }
+
+ assertEquals(warnings, listOf(unmapped("GreetingInput.name", GreetingInput::class.java, "argument 'input' of field 'Query.greet'", "nmae",
+ "drops the field's value, since the ObjectMapper ignores unknown properties")))
+
+ val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) {
+ """
+ query {
+ greet(input: { name: "Ada" })
+ }
+ """
+ }
+ assertEquals(data["greet"], null)
+ }
+
+ @Test
+ fun `input class is checked with the ObjectMapper of each field using it`() {
+ val parser = { options: SchemaParserOptions.Builder ->
+ SchemaParser.newParser()
+ .schemaString(
+ """
+ type Query {
+ snake(input: NameInput!): String
+ camel(input: NameInput!): String
+ }
+
+ input NameInput {
+ firstName: String
+ }
+ """)
+ .resolvers(object : GraphQLQueryResolver {
+ fun snake(input: NameInput): String? = input.firstName
+ fun camel(input: NameInput): String? = input.firstName
+ })
+ .options(options
+ .objectMapperConfigurer { mapper, context ->
+ if (context.field.name == "snake") {
+ mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
+ }
+ }
+ .build())
+ .build()
+ }
+
+ val error = assertThrows(SchemaError::class.java) {
+ parser(failOptions()).makeExecutableSchema()
+ }
+ assertEquals(error.message, unmapped("NameInput.firstName", NameInput::class.java, "argument 'input' of field 'Query.snake'", "first_name"))
+
+ val gql = GraphQL.newGraphQL(parser(SchemaParserOptions.newOptions()).makeExecutableSchema()).build()
+ val result = gql.execute("query { snake(input: { firstName: \"Ada\" }) camel(input: { firstName: \"Ada\" }) }")
+ assertContains(result.errors.single().message, "Unrecognized field \"firstName\"")
+ assertEquals(result.getData