From 8a80a7208920a224d6ddadec059124d7666df25d Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 9 Oct 2026 21:03:23 -0400 Subject: [PATCH] Register input field types that have no Java property When the scanner couldn't find a Java property for an input field, e.g. because the input is bound to a Map or ObjectNode, the getter name doesn't match the schema field (getFoo_Back for foo_back) or the field is private, it only logged "Cannot find definition for field ..." and never registered the field's type. Nested input objects and custom scalars only reachable that way were left out of the scan, and the parser then failed with "Expected type 'X' to be a GraphQLInputType, but it wasn't!", which pointed users at the wrong problem. The scanner now still registers what such a field needs. A nested input object is registered without a class and its fields are scanned the same way; a class found for it later is set and scanned as usual, so it doesn't clash. A custom scalar is registered like any other. An enum can't be built without its Java class, so if none is found for it anywhere, the scan fails with a message naming the enum, the input type, the field and the class, and how to fix it. The garbled warning is replaced by one that's only logged when an input class (not a Map or JsonNode) lacks a matching property. Input fields of a Map or JsonNode are no longer matched against the methods and fields it inherits from the JDK or Jackson, so fields named like values, elements or fields work with ObjectNode. Getters declared by a Map subclass are still used. A class taken from the dictionary for an input field is matched against the field's unwrapped type, so a list field whose element type is in the dictionary no longer fails with "Java class is not a List". A misspelled property on an input class whose nested input type is only reachable through it used to fail the build with the misleading error above. It now builds with the warning and, like any other misspelled property, fails when a client sends the field, or the value is dropped if the ObjectMapper ignores unknown properties. There's still no build-time check that input class properties match the schema (#422). The parser's "Expected type ... to be a GraphQLInputType" error is replaced by one that tells apart a type that isn't defined, a type of the wrong kind (an output type used as an input or vice versa) and a type that was defined but never reached while scanning, with a hint for reaching it that depends on the kind of type. It names the field or argument using the type and its source location. Schemas passed through schemaString() are still parsed as one document without a source name, so their line numbers count from the start of all strings (#392). Co-Authored-By: Claude Opus 5.5 --- .../kickstart/tools/SchemaClassScanner.kt | 122 ++++-- .../graphql/kickstart/tools/SchemaParser.kt | 65 +++- .../kickstart/tools/NestedInputTypesTest.kt | 365 ++++++++++++++++++ .../kickstart/tools/SchemaClassScannerTest.kt | 49 +++ .../kickstart/tools/SchemaParserTest.kt | 259 ++++++++++++- src/test/resources/UnreachedType.graphqls | 11 + 6 files changed, 824 insertions(+), 47 deletions(-) create mode 100644 src/test/resources/UnreachedType.graphqls diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index bea11ace..a741eddd 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -1,5 +1,6 @@ package graphql.kickstart.tools +import com.fasterxml.jackson.databind.JsonNode import graphql.kickstart.tools.resolver.FieldResolver import graphql.kickstart.tools.resolver.FieldResolverScanner import graphql.kickstart.tools.util.* @@ -8,6 +9,7 @@ import graphql.schema.GraphQLScalarType import graphql.schema.idl.ScalarInfo import org.apache.commons.lang3.ClassUtils import org.slf4j.LoggerFactory +import java.lang.reflect.Member import java.lang.reflect.Method /** @@ -45,6 +47,7 @@ internal class SchemaClassScanner( private val typeClassMatcher = TypeClassMatcher(definitionsByName) private val dictionary = mutableMapOf, DictionaryEntry>() private val unvalidatedTypes = mutableSetOf>() + private val enumsWithoutClass = mutableMapOf() private val queue = linkedSetOf() private val fieldResolversByType = mutableMapOf>() @@ -173,6 +176,11 @@ internal class SchemaClassScanner( } private fun validateAndCreateResult(rootTypeHolder: RootTypesHolder): ScannedSchemaObjects { + enumsWithoutClass + .filter { dictionary[it.key]?.javaType == null } + .values.firstOrNull() + ?.let { throw SchemaClassScannerError(it) } + initialDictionary .filter { !it.value.accessed } .forEach { @@ -384,31 +392,83 @@ internal class SchemaClassScanner( scanInterfacesOfType(graphQLType) } - is InputObjectTypeDefinition -> { - val inputObjectTypes = listOf(graphQLType) + inputExtensionDefinitions.filter { it.name == graphQLType.name } - inputObjectTypes - .flatMap { it.inputValueDefinitions } - .forEach { inputValueDefinition -> - val inputGraphQLType = inputValueDefinition.type.unwrap() - if (inputGraphQLType is TypeName && !ScalarInfo.GRAPHQL_SPECIFICATION_SCALARS_DEFINITIONS.containsKey(inputGraphQLType.name)) { - val inputValueJavaType = findInputValueType(inputValueDefinition.name, inputGraphQLType, javaType.unwrap()) - if (inputValueJavaType != null) { - handleFoundType(typeClassMatcher.match(TypeClassMatcher.PotentialMatch.parameterType( - inputValueDefinition.type, - inputValueJavaType, - GenericType(javaType, options).relativeToType(javaType), - InputObjectReference(inputValueDefinition) - ))) - } else { - var mappingAdvice = "Try adding it manually to the dictionary" - if (javaType.unwrap().name.contains("Map")) { - mappingAdvice = " or add a class to represent your input type instead of a Map." - } - log.warn("Cannot find definition for field '${inputValueDefinition.name}: ${inputGraphQLType.name}' on input type '${graphQLType.name}' -> ${javaType.unwrap().name}. $mappingAdvice") - } - } + is InputObjectTypeDefinition -> handleInputObjectFields(graphQLType, javaType) + } + } + + /** + * Finds the types of the input object's fields through the properties of its class. A null [javaType] means the input object + * has no known class, e.g. because it's only reachable through a Map. + */ + private fun handleInputObjectFields(graphQLType: InputObjectTypeDefinition, javaType: JavaType?) { + val inputObjectTypes = listOf(graphQLType) + inputExtensionDefinitions.filter { it.name == graphQLType.name } + inputObjectTypes + .flatMap { it.inputValueDefinitions } + .forEach { inputValueDefinition -> + val inputGraphQLType = inputValueDefinition.type.unwrap() + if (inputGraphQLType is TypeName && !ScalarInfo.GRAPHQL_SPECIFICATION_SCALARS_DEFINITIONS.containsKey(inputGraphQLType.name)) { + val propertyType = javaType?.unwrap()?.let { findInputValueTypeInType(inputValueDefinition.name, it) } + val inputValueJavaType = propertyType ?: initialDictionary[inputGraphQLType.name]?.get() + if (inputValueJavaType != null) { + val genericContext = javaType ?: inputValueJavaType + handleFoundType(typeClassMatcher.match(TypeClassMatcher.PotentialMatch.parameterType( + // a class from the dictionary is for the unwrapped type, not for its list or non-null wrapper + if (propertyType != null) inputValueDefinition.type else inputGraphQLType, + inputValueJavaType, + GenericType(genericContext, options).relativeToType(genericContext), + InputObjectReference(inputValueDefinition) + ))) + } else { + handleInputValueWithoutJavaType(graphQLType, inputValueDefinition, inputGraphQLType, javaType?.unwrap()) } + } } + } + + /** + * Without a Java property for an input field its type can still be built, as long as it doesn't need a class of its own. + * 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}. " + + "Unless Jackson maps it some other way, e.g. through @JsonProperty, check that the property name matches the schema, " + + "otherwise 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 -> { + val isNewType = !dictionary.containsKey(definition) + // registered without a class, so a class found for it later still gets set and scanned + handleFoundType(definition, null, InputObjectReference(inputValueDefinition)) + if (isNewType) { + handleInputObjectFields(definition, null) + } + } + // the enum class may still be found through another field, so this is only reported once scanning is done + is EnumTypeDefinition -> enumsWithoutClass.putIfAbsent(definition, enumWithoutClassMessage(definition, graphQLType, inputValueDefinition, clazz)) + // undefined types and output types are reported by the SchemaParser + else -> {} + } + } + + private fun isPropertyMap(clazz: Class<*>) = + java.util.Map::class.java.isAssignableFrom(clazz) || JsonNode::class.java.isAssignableFrom(clazz) || clazz == Object::class.java + + private fun isLibraryClass(clazz: Class<*>) = + listOf("java.", "javax.", "kotlin.", "com.fasterxml.jackson.").any { clazz.name.startsWith(it) } + + private fun enumWithoutClassMessage(enum: EnumTypeDefinition, graphQLType: InputObjectTypeDefinition, inputValueDefinition: InputValueDefinition, clazz: Class<*>?): String { + val field = inputValueDefinition.name + val problem = "Enum type '${enum.name}' is used by field '$field' of input type '${graphQLType.name}', but its Java enum can't be determined" + return when { + clazz == null -> "$problem because no class was found for input type '${graphQLType.name}'. " + + "Pass the enum class for type '${enum.name}' in the parser's dictionary, or a class for '${graphQLType.name}' with a public getter or field '$field'." + isPropertyMap(clazz) && isLibraryClass(clazz) -> "$problem because '${graphQLType.name}' is bound to ${clazz.name}, which has no type information for its fields. " + + "Pass the enum class for type '${enum.name}' in the parser's dictionary, or use a class for '${graphQLType.name}' with a public getter or field '$field'." + else -> "$problem because ${clazz.name} has no public getter or field named '$field'. " + + "Add a public getter or field '$field' to the class used for '${graphQLType.name}', or pass the enum class for type '${enum.name}' in the parser's dictionary." } } @@ -425,17 +485,11 @@ internal class SchemaClassScanner( queue.add(QueueItem(graphQLType, javaType)) } - private fun findInputValueType(name: String, inputGraphQLType: TypeName, clazz: Class): JavaType? { - val inputValueType = findInputValueTypeInType(name, clazz) - if (inputValueType != null) { - return inputValueType - } - - return initialDictionary[inputGraphQLType.name]?.get() - } - private fun findInputValueTypeInType(name: String, clazz: Class<*>): JavaType? { - val methods = clazz.methods + // a Map or JsonNode has no properties for the input's fields, only methods that may share their names, e.g. ObjectNode.fields(), + // but a subclass of one may still declare typed getters or fields of its own + val isProperty = { member: Member -> !isPropertyMap(clazz) || !isLibraryClass(member.declaringClass) } + val methods = clazz.methods.filter(isProperty) val (getters, methodsWithParameters) = methods.filter { it.name == name || it.name == "get${name.replaceFirstChar(Char::titlecase)}" @@ -443,7 +497,7 @@ internal class SchemaClassScanner( return getters.find { !it.isSynthetic }?.genericReturnType ?: getters.firstOrNull()?.genericReturnType - ?: clazz.fields.find { it.name == name }?.genericType + ?: clazz.fields.find { it.name == name && isProperty(it) }?.genericType ?: methodsWithParameters.find { !it.isSynthetic }?.genericReturnType ?: methodsWithParameters.firstOrNull()?.genericReturnType } diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 7417e54c..050ff2a0 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -506,8 +506,7 @@ class SchemaParser internal constructor( scalarType } else { if (!allowedTypeReferences.contains(typeDefinition.name)) { - throw SchemaError("Expected type '${typeDefinition.name}' to be a ${expectedType.simpleName}, but it wasn't! " + - "Was a type only permitted for object types incorrectly used as an input type, or vice-versa?") + throw unexpectedTypeError(typeDefinition, expectedType) } inputObjects.find { it.name == typeDefinition.name } ?: GraphQLTypeReference(typeDefinition.name) } @@ -538,8 +537,7 @@ class SchemaParser internal constructor( scalarType } else { if (!allowedTypeReferences.contains(typeDefinition.name)) { - throw SchemaError("Expected type '${typeDefinition.name}' to be a ${expectedType.simpleName}, but it wasn't! " + - "Was a type only permitted for object types incorrectly used as an input type, or vice-versa?") + throw unexpectedTypeError(typeDefinition, expectedType) } val found = inputObjects.filter { it.name == typeDefinition.name } if (found.size == 1) { @@ -565,6 +563,65 @@ class SchemaParser internal constructor( else -> throw SchemaError("Unknown type: $typeDefinition") } + private fun unexpectedTypeError(typeName: TypeName, expectedType: KClass<*>): SchemaError { + val name = typeName.name + val subject = "Type '$name'" + + (findUsage(typeName)?.let { " used by $it" } ?: "") + + (typeName.sourceLocation?.let { " at line ${it.line}, column ${it.column}" + (it.sourceName?.let { source -> " of $source" } ?: "") } ?: "") + // unused ones first, since the extensions of an unused type are still among the definitions + val definition = unusedDefinitions.find { it.name == name } + ?: definitions.filterIsInstance>().find { it.name == name } + val isInput = expectedType == GraphQLInputType::class + val kind = when (definition) { + is InputObjectTypeDefinition -> "an input object type" + is InterfaceTypeDefinition -> "an interface" + is UnionTypeDefinition -> "a union" + else -> "an object type" + } + + return SchemaError(when { + definition == null -> "$subject is not defined in the schema." + isInput && definition !is InputObjectTypeDefinition && definition !is EnumTypeDefinition && definition !is ScalarTypeDefinition -> + "$subject is $kind, which can't be used as an input type. Input types are input objects, enums and scalars." + !isInput && definition is InputObjectTypeDefinition -> + "$subject is $kind, which can't be used as an output type. Output types are object types, interfaces, unions, enums and scalars." + definition in unusedDefinitions -> + "$subject is defined in the schema, but was not reached while scanning the resolvers and the classes they use, so it wasn't built. " + + unreachedTypeHint(definition) + else -> "$subject can't be used as ${if (isInput) "an input" else "an output"} type." + }) + } + + // what makes the scanner reach the type depends on its kind, see SchemaClassScanner.canIncludeUnusedType + private fun unreachedTypeHint(definition: TypeDefinition<*>): String = when (definition) { + is InterfaceTypeDefinition, is UnionTypeDefinition -> + "Return it from a resolver method or one of those classes, or set SchemaParserOptions.includeUnusedTypes." + is EnumTypeDefinition -> + "Use it in a resolver method or one of those classes, or set SchemaParserOptions.includeUnusedTypes and pass its enum class in the parser's dictionary." + is InputObjectTypeDefinition -> + "Use it in an argument of a resolver method, directly or through another input type. SchemaParserOptions.includeUnusedTypes doesn't include input types." + is ScalarTypeDefinition -> + "Use it in a resolver method or one of those classes. SchemaParserOptions.includeUnusedTypes doesn't include scalars." + else -> + "Return it from a resolver method or one of those classes, or set SchemaParserOptions.includeUnusedTypes and pass a class for it in the parser's dictionary." + } + + // determineType only gets the type reference, so the definition using it is looked up for the error message + private fun findUsage(typeName: TypeName): String? = definitions.asSequence().flatMap { definition -> + when (definition) { + is ObjectTypeDefinition -> fieldUsages(definition.name, definition.fieldDefinitions) + is InterfaceTypeDefinition -> fieldUsages(definition.name, definition.fieldDefinitions) + is InputObjectTypeDefinition -> definition.inputValueDefinitions.map { "input field '${definition.name}.${it.name}'" to it.type } + is DirectiveDefinition -> definition.inputValueDefinitions.map { "argument '${it.name}' of directive '@${definition.name}'" to it.type } + else -> emptyList() + } + }.find { (_, type) -> type.unwrap() === typeName }?.first + + private fun fieldUsages(typeName: String, fields: List): List>> = fields.flatMap { field -> + listOf("field '$typeName.${field.name}'" to field.type) + + field.inputValueDefinitions.map { "argument '${it.name}' of field '$typeName.${field.name}'" to it.type } + } + /** * Returns an optional [String] describing a deprecated field/enum. * If a deprecation directive was defined using the @deprecated directive, diff --git a/src/test/kotlin/graphql/kickstart/tools/NestedInputTypesTest.kt b/src/test/kotlin/graphql/kickstart/tools/NestedInputTypesTest.kt index 2079cb91..f2b093c4 100644 --- a/src/test/kotlin/graphql/kickstart/tools/NestedInputTypesTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/NestedInputTypesTest.kt @@ -1,5 +1,7 @@ package graphql.kickstart.tools +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.node.ObjectNode import graphql.GraphQL import graphql.execution.AsyncExecutionStrategy import org.junit.Test @@ -103,6 +105,369 @@ class NestedInputTypesTest { assertEquals((data["materials"]), emptyList()) } + @Test + fun `nested input types are found through a Map parameter`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + discountName(filter: MaterialFilter!): String + } + + input MaterialFilter { + title: String + requestFilter: RequestFilter + } + + input RequestFilter { + and: [RequestFilter!] + or: [RequestFilter!] + discountTypeFilter: DiscountTypeFilter + } + + input DiscountTypeFilter { + name: String + } + """) + .resolvers(object : GraphQLQueryResolver { + fun discountName(filter: Map): String? { + val requestFilter = filter["requestFilter"] as Map<*, *> + val discountTypeFilter = (requestFilter["and"] as List<*>).single() as Map<*, *> + return (discountTypeFilter["discountTypeFilter"] as Map<*, *>)["name"] as String? + } + }) + .build() + .makeExecutableSchema() + val gql = GraphQL.newGraphQL(schema).build() + + val data = assertNoGraphQlErrors(gql) { + """ + query { + discountName(filter: { requestFilter: { and: [{ discountTypeFilter: { name: "discount" } }] } }) + } + """ + } + + assertEquals(data["discountName"], "discount") + } + + @Test + fun `nested input types are found through ObjectNode parameters`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + test: String + } + + type Mutation { + create(input: EntityCreateInput!): String + update(input: EntityUpdateInput!): String + } + + input EntityCreateInput { + name: String + nested: Nested + } + + input EntityUpdateInput { + id: ID! + nested: Nested + } + + input Nested { + x: Int + } + """) + .resolvers(object : GraphQLQueryResolver { + fun test(): String? = null + }, object : GraphQLMutationResolver { + fun create(input: ObjectNode): String = input.toString() + fun update(input: ObjectNode): String = input.toString() + }) + .build() + .makeExecutableSchema() + val gql = GraphQL.newGraphQL(schema).build() + + val data = assertNoGraphQlErrors(gql) { + """ + mutation { + create(input: { name: "n", nested: { x: 1 } }) + update(input: { id: "2", nested: { x: 3 } }) + } + """ + } + + assertEquals(data["create"], """{"name":"n","nested":{"x":1}}""") + assertEquals(data["update"], """{"id":"2","nested":{"x":3}}""") + } + + @Test + fun `nested input types are found when the input class has no matching property`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + test: String + } + + type Mutation { + createAgreement(data: AgreementCreateInput!): String + } + + input AgreementCreateInput { + name: String + IncludedAgreement_back: IncludedAgreementCreateManyInput + } + + input IncludedAgreementCreateManyInput { + create: [IncludedAgreementCreateInput!] + } + + input IncludedAgreementCreateInput { + name: String + } + """) + .resolvers(object : GraphQLQueryResolver { + fun test(): String? = null + }, object : GraphQLMutationResolver { + fun createAgreement(data: AgreementCreateInput): String? = + data.getIncludedAgreement_Back()?.create?.joinToString { it.name.orEmpty() } + }) + .build() + .makeExecutableSchema() + val gql = GraphQL.newGraphQL(schema).build() + + val data = assertNoGraphQlErrors(gql) { + """ + mutation { + createAgreement(data: { name: "a", IncludedAgreement_back: { create: [{ name: "b" }, { name: "c" }] } }) + } + """ + } + + assertEquals(data["createAgreement"], "b, c") + } + + @Test + fun `nested input type reached without a class is scanned again once its class is found`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + byMap(filter: ColorFilter): String + byClass(color: ColorInput): String + } + + input ColorFilter { + color: ColorInput + } + + input ColorInput { + value: Color + } + + enum Color { + RED + GREEN + } + """) + .resolvers(object : GraphQLQueryResolver { + fun byMap(filter: Map): String = (filter["color"] as Map<*, *>)["value"].toString() + fun byClass(color: ColorInput): String = color.value.toString() + }) + .build() + .makeExecutableSchema() + val gql = GraphQL.newGraphQL(schema).build() + + val data = assertNoGraphQlErrors(gql) { + """ + query { + byMap(filter: { color: { value: RED } }) + byClass(color: { value: GREEN }) + } + """ + } + + assertEquals(data["byMap"], "RED") + assertEquals(data["byClass"], "GREEN") + } + + @Test + fun `list field of a nested input type reached without a class uses the dictionary class of its element`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + byMap(filter: ShipmentFilter): String + byClass(shipment: ShipmentInput): String + } + + input ShipmentFilter { + shipment: ShipmentInput + } + + input ShipmentInput { + lines: [ShipmentLineInput!] + } + + input ShipmentLineInput { + sku: String + } + """) + .resolvers(object : GraphQLQueryResolver { + fun byMap(filter: Map): String = filter["shipment"].toString() + fun byClass(shipment: ShipmentInput): String = shipment.lines.orEmpty().joinToString { it.sku.orEmpty() } + }) + .dictionary("ShipmentLineInput", ShipmentLineInput::class.java) + .build() + .makeExecutableSchema() + val gql = GraphQL.newGraphQL(schema).build() + + val data = assertNoGraphQlErrors(gql) { + """ + query { + byMap(filter: { shipment: { lines: [{ sku: "a" }] } }) + byClass(shipment: { lines: [{ sku: "b" }, { sku: "c" }] }) + } + """ + } + + assertEquals(data["byMap"], "{lines=[{sku=a}]}") + assertEquals(data["byClass"], "b, c") + } + + @Test + fun `input fields bound to ObjectNode are not matched against its own methods`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + create(input: EntityInput!): String + } + + input EntityInput { + values: [Nested] + elements: [Nested] + fields: [Nested] + } + + input Nested { + x: Int + } + """) + .resolvers(object : GraphQLQueryResolver { + fun create(input: ObjectNode): String = input.toString() + }) + .build() + .makeExecutableSchema() + val gql = GraphQL.newGraphQL(schema).build() + + val data = assertNoGraphQlErrors(gql) { + """ + query { + create(input: { values: [{ x: 1 }], elements: [{ x: 2 }], fields: [{ x: 3 }] }) + } + """ + } + + assertEquals(data["create"], """{"values":[{"x":1}],"elements":[{"x":2}],"fields":[{"x":3}]}""") + } + + @Test + fun `input field types are found through the getters of a Map subclass`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + kind(filter: KindFilter!): String + nested(filter: NestedKindFilter!): String + } + + input KindFilter { + kind: Kind + } + + input NestedKindFilter { + inner: InnerKindFilter + } + + input InnerKindFilter { + kind: Kind + } + + enum Kind { + A + B + } + """) + .resolvers(object : GraphQLQueryResolver { + fun kind(filter: KindFilterMap): String = filter.kind.toString() + fun nested(filter: NestedKindFilterMap): String = filter["inner"].toString() + }) + .build() + .makeExecutableSchema() + val gql = GraphQL.newGraphQL(schema).build() + + val data = assertNoGraphQlErrors(gql) { + """ + query { + kind(filter: { kind: B }) + nested(filter: { inner: { kind: A } }) + } + """ + } + + assertEquals(data["kind"], "B") + assertEquals(data["nested"], "{kind=A}") + } + + class KindFilterMap : HashMap() { + val kind: Kind? get() = this["kind"]?.let { Kind.valueOf(it.toString()) } + } + + class NestedKindFilterMap : HashMap() { + val inner: InnerKindFilter? get() = null + } + + class InnerKindFilter { + var kind: Kind? = null + } + + enum class Kind { A, B } + + class AgreementCreateInput { + var name: String? = null + + // the getter doesn't follow the bean naming of the schema field, so only Jackson knows about the property + @field:JsonProperty("IncludedAgreement_back") + private var includedAgreementBack: IncludedAgreementCreateManyInput? = null + + fun getIncludedAgreement_Back(): IncludedAgreementCreateManyInput? = includedAgreementBack + } + + class IncludedAgreementCreateManyInput { + var create: List? = null + } + + class IncludedAgreementCreateInput { + var name: String? = null + } + + class ColorInput { + var value: Color? = null + } + + enum class Color { RED, GREEN } + + class ShipmentInput { + var lines: List? = null + } + + class ShipmentLineInput { + var sku: String? = null + } + class QueryResolver : GraphQLQueryResolver { fun materials(filter: MaterialFilter): List = listOf() } diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerTest.kt index 24f0e662..2199fd94 100644 --- a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerTest.kt @@ -1,6 +1,8 @@ package graphql.kickstart.tools +import graphql.GraphQL import graphql.GraphQLContext +import graphql.Scalars import graphql.execution.CoercedVariables import graphql.language.Value import graphql.schema.* @@ -462,6 +464,53 @@ class SchemaClassScannerTest { var map: Map? = null } + @Test + fun `scanner handles custom scalars only used in input types bound to a Map`() { + val foo = GraphQLScalarType.newScalar(Scalars.GraphQLString) + .name("Foo") + .description("foo") + .build() + + val schema = SchemaParser.newParser() + .resolvers(object : GraphQLQueryResolver { + fun test(): String? = null + }, object : GraphQLMutationResolver { + fun createHuman(input: Map): String = "${input["name"]} ${input["foo"]}" + }) + .scalars(foo) + .schemaString( + """ + scalar Foo + + type Query { + test: String + } + + type Mutation { + createHuman(input: CreateHumanInput!): String + } + + input CreateHumanInput { + name: String + homePlanet: String + foo: Foo + } + """) + .build() + .makeExecutableSchema() + val gql = GraphQL.newGraphQL(schema).build() + + val data = assertNoGraphQlErrors(gql) { + """ + mutation { + createHuman(input: { name: "Luke", foo: "bar" }) + } + """ + } + + assertEquals(data["createHuman"], "Luke bar") + } + @Test fun `scanner allows class to be used for object type and input object type`() { val schema = SchemaParser.newParser() diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt index e5e2ba30..4353764e 100644 --- a/src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt @@ -200,24 +200,146 @@ class SchemaParserTest { @Test fun `parser should throw descriptive exception when object is used as input type incorrectly`() { - assertThrows("Was a type only permitted for object types incorrectly used as an input type, or vice-versa", SchemaError::class.java) { + val error = assertThrows(SchemaError::class.java) { SchemaParser.newParser() .schemaString( """ - type Query { - name(filter: Filter): [String] - } - - type Filter { - filter: String - } - """) + |type Query { + | name(filter: Filter): [String] + |} + | + |type Filter { + | filter: String + |} + """.trimMargin()) .resolvers(object : GraphQLQueryResolver { fun name(filter: Filter): List? = null }) .build() .makeExecutableSchema() } + + assertEquals(error.message, "Type 'Filter' used by argument 'filter' of field 'Query.name' at line 2, column 18 is an object type, " + + "which can't be used as an input type. Input types are input objects, enums and scalars.") + } + + @Test + fun `parser should throw descriptive exception when input object is used as output type incorrectly`() { + val error = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + |type Query { + | filters: [Filter!] + |} + | + |input Filter { + | filter: String + |} + """.trimMargin()) + .resolvers(object : GraphQLQueryResolver { + fun filters(): List? = null + }) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Type 'Filter' used by field 'Query.filters' at line 2, column 15 is an input object type, " + + "which can't be used as an output type. Output types are object types, interfaces, unions, enums and scalars.") + } + + @Test + fun `parser should throw descriptive exception when input type is not defined`() { + val error = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + |type Query { + | test(input: TestInput): String + |} + | + |input TestInput { + | nested: [Nested!] + |} + """.trimMargin()) + .resolvers(object : GraphQLQueryResolver { + fun test(input: Map): String? = null + }) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Type 'Nested' used by input field 'TestInput.nested' at line 6, column 14 is not defined in the schema.") + } + + @Test + fun `parser should throw descriptive exception when type was never reached by the scanner`() { + val error = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .file("UnreachedType.graphqls") + .resolvers(object : GraphQLQueryResolver { + fun pet(): Pet? = null + }) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Type 'Cat' used by field 'Pet.friend' at line 6, column 11 of UnreachedType.graphqls is defined in the schema, " + + "but was not reached while scanning the resolvers and the classes they use, so it wasn't built. " + + "Return it from a resolver method or one of those classes, or set SchemaParserOptions.includeUnusedTypes and pass a class for it in the parser's dictionary.") + } + + @Test + fun `parser should build type that was never reached by the scanner when including unused types`() { + SchemaParser.newParser() + .file("UnreachedType.graphqls") + .resolvers(object : GraphQLQueryResolver { + fun pet(): Pet? = null + }) + .dictionary(Cat::class) + .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + .build() + .makeExecutableSchema() + } + + @Test + fun `parser should throw descriptive exception when input type is only used by an unused interface`() { + val error = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + |type Query { + | name: String + |} + | + |interface Searchable { + | search(criteria: Criteria): String + |} + | + |input Criteria { + | text: String + |} + """.trimMargin()) + .resolvers(object : GraphQLQueryResolver { + fun name(): String? = null + }) + .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Type 'Criteria' used by argument 'criteria' of field 'Searchable.search' at line 6, column 22 is defined in the schema, " + + "but was not reached while scanning the resolvers and the classes they use, so it wasn't built. " + + "Use it in an argument of a resolver method, directly or through another input type. " + + "SchemaParserOptions.includeUnusedTypes doesn't include input types.") + } + + interface Pet { + val friend: Cat? + } + + class Cat { + var name: String? = null } @Test @@ -356,6 +478,123 @@ class SchemaParserTest { .makeExecutableSchema() } + @Test + fun `support enum types used in input Map if their class is found elsewhere`() { + SchemaParser.newParser() + .schemaString( + """ + type Query { + save(input: SaveInput!): Boolean + type: EnumType + } + + input SaveInput { + type: EnumType! + } + + enum EnumType { + TEST + } + """) + .resolvers(object : GraphQLQueryResolver { + fun save(input: Map<*, *>): Boolean = false + fun type(): EnumType? = null + }) + .build() + .makeExecutableSchema() + } + + @Test + fun `parser should throw descriptive exception when enum is only used in input Map without a class`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + save(input: SaveInput!): Boolean + } + + input SaveInput { + type: EnumType! + } + + enum EnumType { + TEST + } + """) + .resolvers(object : GraphQLQueryResolver { + fun save(input: Map<*, *>): Boolean = false + }) + .build() + } + + assertEquals(error.message, "Enum type 'EnumType' is used by field 'type' of input type 'SaveInput', but its Java enum can't be determined " + + "because 'SaveInput' is bound to java.util.Map, which has no type information for its fields. Pass the enum class for type " + + "'EnumType' in the parser's dictionary, or use a class for 'SaveInput' with a public getter or field 'type'.") + } + + @Test + fun `parser should throw descriptive exception when enum is only used in input Map subclass without a getter for it`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + save(input: SaveInput!): Boolean + } + + input SaveInput { + type: EnumType! + } + + enum EnumType { + TEST + } + """) + .resolvers(object : GraphQLQueryResolver { + fun save(input: SaveInputMap): Boolean = false + }) + .build() + } + + assertEquals(error.message, "Enum type 'EnumType' is used by field 'type' of input type 'SaveInput', but its Java enum can't be determined " + + "because ${SaveInputMap::class.java.name} has no public getter or field named 'type'. Add a public getter or field 'type' to the class " + + "used for 'SaveInput', or pass the enum class for type 'EnumType' in the parser's dictionary.") + } + + @Test + fun `parser should throw descriptive exception when enum is only used in a nested input without a class`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + save(input: SaveInput!): Boolean + } + + input SaveInput { + nested: NestedInput + } + + input NestedInput { + type: EnumType! + } + + enum EnumType { + TEST + } + """) + .resolvers(object : GraphQLQueryResolver { + fun save(input: Map<*, *>): Boolean = false + }) + .build() + } + + assertEquals(error.message, "Enum type 'EnumType' is used by field 'type' of input type 'NestedInput', but its Java enum can't be determined " + + "because no class was found for input type 'NestedInput'. Pass the enum class for type 'EnumType' in the parser's dictionary, " + + "or a class for 'NestedInput' with a public getter or field 'type'.") + } + @Test fun `allow circular relations in input objects`() { SchemaParser.newParser() @@ -682,6 +921,8 @@ class SchemaParserTest { TEST } + class SaveInputMap : HashMap() + class QueryWithIdResolver : GraphQLQueryResolver { fun getId(): String? = null } diff --git a/src/test/resources/UnreachedType.graphqls b/src/test/resources/UnreachedType.graphqls new file mode 100644 index 00000000..d6fe977f --- /dev/null +++ b/src/test/resources/UnreachedType.graphqls @@ -0,0 +1,11 @@ +type Query { + pet: Pet +} + +interface Pet { + friend: Cat +} + +type Cat { + name: String +}