Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 89 additions & 34 deletions src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt
Original file line number Diff line number Diff line change
@@ -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.*
Expand All @@ -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

/**
Expand Down Expand Up @@ -45,6 +47,7 @@ internal class SchemaClassScanner(
private val typeClassMatcher = TypeClassMatcher(definitionsByName)
private val dictionary = mutableMapOf<TypeDefinition<*>, DictionaryEntry>()
private val unvalidatedTypes = mutableSetOf<TypeDefinition<*>>()
private val enumsWithoutClass = mutableMapOf<EnumTypeDefinition, String>()
private val queue = linkedSetOf<QueueItem>()

private val fieldResolversByType = mutableMapOf<ObjectTypeDefinition, MutableMap<FieldDefinition, FieldResolver>>()
Expand Down Expand Up @@ -181,6 +184,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 {
Expand Down Expand Up @@ -412,31 +420,84 @@ 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,
GenericTypeResolver(javaType, options),
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,
GenericTypeResolver(genericContext, options),
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)) {
// not a warning, since Jackson may map the field some other way, e.g. through a setter. The SchemaParser checks that with
// Jackson itself, see UnmappedInputFields
log.debug("Cannot find a public getter or field for field '${inputValueDefinition.name}: ${inputGraphQLType.name}' of input type '${graphQLType.name}' on ${clazz.name}, " +
"so its type is taken from the schema.")
}

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."
}
}

Expand All @@ -453,25 +514,19 @@ internal class SchemaClassScanner(
queue.add(QueueItem(graphQLType, javaType))
}

private fun findInputValueType(name: String, inputGraphQLType: TypeName, clazz: Class<out Any>): 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)}"
}.sortedBy { it.name.length }.partition { it.parameterCount == 0 }

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
}
Expand Down
86 changes: 78 additions & 8 deletions src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.getDocumentation
import graphql.kickstart.tools.util.getExtendedFieldDefinitions
import graphql.kickstart.tools.util.unwrap
Expand Down Expand Up @@ -82,7 +83,10 @@ class SchemaParser internal constructor(
}
}
val interfaces = interfaceDefinitions.map { createInterfaceObject(it, inputObjects) }
val objects = objectDefinitions.map { createObject(it, interfaces, inputObjects) }
val unmappedInputFieldChecker = if (options.unmappedInputFields == UnmappedInputFields.IGNORE) null
else UnmappedInputFieldChecker(inputObjects.associateBy { it.name }, options.unmappedInputFields)
val objects = objectDefinitions.map { createObject(it, interfaces, inputObjects, unmappedInputFieldChecker) }
unmappedInputFieldChecker?.report()
val unions = unionDefinitions.map { createUnionObject(it, objects) }
val enums = enumDefinitions.map { createEnumObject(it) }

Expand Down Expand Up @@ -122,7 +126,12 @@ class SchemaParser internal constructor(
@Suppress("unused")
fun getUnusedDefinitions(): Set<TypeDefinition<*>> = unusedDefinitions

private fun createObject(objectDefinition: ObjectTypeDefinition, interfaces: List<GraphQLInterfaceType>, inputObjects: List<GraphQLInputObjectType>): GraphQLObjectType {
private fun createObject(
objectDefinition: ObjectTypeDefinition,
interfaces: List<GraphQLInterfaceType>,
inputObjects: List<GraphQLInputObjectType>,
unmappedInputFieldChecker: UnmappedInputFieldChecker?
): GraphQLObjectType {
val name = objectDefinition.name
val builder = GraphQLObjectType.newObject()
.name(name)
Expand All @@ -141,11 +150,15 @@ class SchemaParser internal constructor(
objectDefinition.getExtendedFieldDefinitions(extensionDefinitions).forEach { fieldDefinition ->
field { field ->
createField(field, fieldDefinition, inputObjects)
val fieldResolver = fieldResolversByType[objectDefinition]?.get(fieldDefinition)
?: throw SchemaError("No resolver method found for object type '${objectDefinition.name}' and field '${fieldDefinition.name}', this is most likely a bug with graphql-java-tools")
codeRegistryBuilder.dataFetcher(
FieldCoordinates.coordinates(objectDefinition.name, fieldDefinition.name),
fieldResolversByType[objectDefinition]?.get(fieldDefinition)?.createDataFetcher()
?: throw SchemaError("No resolver method found for object type '${objectDefinition.name}' and field '${fieldDefinition.name}', this is most likely a bug with graphql-java-tools")
fieldResolver.createDataFetcher()
)
if (fieldResolver is MethodFieldResolver) {
unmappedInputFieldChecker?.check(objectDefinition.name, fieldResolver)
}

val wiredField = field.build()
GraphQLFieldDefinition.Builder(wiredField)
Expand Down Expand Up @@ -506,8 +519,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)
}
Expand Down Expand Up @@ -538,8 +550,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) {
Expand All @@ -565,6 +576,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<TypeDefinition<*>>().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<FieldDefinition>): List<Pair<String, Type<*>>> = 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,
Expand Down
Loading
Loading