From 562ed22d85ddf6fc11884ae01f5f8afcfa010682 Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 13:23:53 -0400 Subject: [PATCH 1/7] Read the scan dictionary's entries through an interface The scanner's type dictionary moves into its own class in the next commit, and the scanner keeps reading it. Two reads need a shape that doesn't reach into the mutable map: - The enum-without-class check at the start of validateAndCreateResult becomes throwIfEnumWithoutClass(), so it can move with enumsWithoutClass. - DictionaryEntry implements a new EntryView with javaType and typeClass(), the only parts the rest of the scanner reads. setTypeIfMissing and addReference aren't on it. No behaviour change. --- .../kickstart/tools/SchemaClassScanner.kt | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 55da96b4..c1c4f8c2 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -251,9 +251,7 @@ internal class SchemaClassScanner( } private fun validateAndCreateResult(rootTypeHolder: RootTypesHolder): ScannedSchemaObjects { - enumsWithoutClass.entries - .find { dictionary[it.key]?.javaType == null } - ?.let { throw SchemaClassScannerError(it.value) } + throwIfEnumWithoutClass() initialDictionary .filter { !it.value.accessed } @@ -685,6 +683,12 @@ internal class SchemaClassScanner( "Pass the enum class for type '${enumType.name}' in the parser's dictionary, or use a class for '${graphQLType.name}' with a public getter or field '$field'." } + private fun throwIfEnumWithoutClass() { + enumsWithoutClass.entries + .find { dictionary[it.key]?.javaType == null } + ?.let { throw SchemaClassScannerError(it.value) } + } + private fun scanInterfacesOfType(graphQLType: ObjectTypeDefinition) { graphQLType.withExtensions(typeExtensions).flatMap { it.implements }.forEach { if (it is TypeName) { @@ -732,9 +736,14 @@ internal class SchemaClassScanner( private data class QueueItem(val type: ObjectTypeDefinition, val clazz: JavaType) - private class DictionaryEntry { + interface EntryView { + val javaType: JavaType? + fun typeClass(): Class? + } + + private class DictionaryEntry : EntryView { private val references = mutableListOf() - var javaType: JavaType? = null + override var javaType: JavaType? = null private set fun setTypeIfMissing(javaType: JavaType): Boolean { @@ -748,7 +757,7 @@ internal class SchemaClassScanner( // fun type(): JavaType? = classEntry?.type - fun typeClass(): Class? = javaType?.unwrap() + override fun typeClass(): Class? = javaType?.unwrap() fun addReference(reference: Reference) { references.add(reference) From 7522a46e82cdf19de61244ad60d69d8e21aa1ddd Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 13:23:53 -0400 Subject: [PATCH 2/7] Move the type dictionary out of SchemaClassScanner Moves the dictionary map, enumsWithoutClass, interfaceDefinitionsByName, both handleFoundType functions, handleNewType, scanInterfacesOfType, throwIfEnumWithoutClass, the input object field walk (handleInputObjectFields, handleInputValueWithoutJavaType, isPropertyMap, isLibraryClass, enumWithoutClassMessage, findInputValueTypeInType), EntryView, DictionaryEntry and InitialDictionaryEntry to ScanDictionary.kt. ScanDictionary is the only writer of the map. The scanner reads it as a Map, EntryView>, so the only way to add a type is handleFoundType, which enters the type before it follows the type's class to more types. Edits to compile: - ScanDictionary's header. It delegates Map to the map it owns and takes the scanner's logger, so its warnings keep the SchemaClassScanner logger name. - The scanner passes its enqueue and handleFoundScalarType in as functions, so the queue and unvalidatedTypes keep one owner and their order. - private dropped on both handleFoundType functions, scanInterfacesOfType, throwIfEnumWithoutClass, DictionaryEntry and InitialDictionaryEntry, and for this commit only on InterfaceReference and InputObjectReference. - The scanner calls these through dictionary. - Imports. --- .../graphql/kickstart/tools/ScanDictionary.kt | 230 +++++++++++++++++ .../kickstart/tools/SchemaClassScanner.kt | 231 ++---------------- 2 files changed, 244 insertions(+), 217 deletions(-) create mode 100644 src/main/kotlin/graphql/kickstart/tools/ScanDictionary.kt diff --git a/src/main/kotlin/graphql/kickstart/tools/ScanDictionary.kt b/src/main/kotlin/graphql/kickstart/tools/ScanDictionary.kt new file mode 100644 index 00000000..f46c0e78 --- /dev/null +++ b/src/main/kotlin/graphql/kickstart/tools/ScanDictionary.kt @@ -0,0 +1,230 @@ +package graphql.kickstart.tools + +import com.fasterxml.jackson.databind.JsonNode +import graphql.kickstart.tools.SchemaClassScanner.InputObjectReference +import graphql.kickstart.tools.SchemaClassScanner.InterfaceReference +import graphql.kickstart.tools.SchemaClassScanner.Reference +import graphql.kickstart.tools.SchemaClassScanner.ReturnValueReference +import graphql.kickstart.tools.util.JavaType +import graphql.kickstart.tools.util.unwrap +import graphql.kickstart.tools.util.withExtensions +import graphql.language.* +import graphql.schema.idl.ScalarInfo +import org.slf4j.Logger +import java.lang.reflect.Member + +internal class ScanDictionary( + private val options: SchemaParserOptions, + private val log: Logger, + private val typeExtensions: TypeExtensions, + private val definitionsByName: Map>, + private val typeClassMatcher: TypeClassMatcher, + private val initialDictionary: Map, + private val enqueue: (ObjectTypeDefinition, JavaType) -> Unit, + private val handleFoundScalarType: (ScalarTypeDefinition) -> Unit, + private val dictionary: MutableMap, DictionaryEntry> = mutableMapOf(), +) : Map, ScanDictionary.EntryView> by dictionary { + private val interfaceDefinitionsByName = definitionsByName.values.filterIsInstance().associateBy { it.name } + private val enumsWithoutClass = mutableMapOf() + + fun handleFoundType(match: TypeClassMatcher.Match) = when (match) { + is TypeClassMatcher.ScalarMatch -> handleFoundScalarType(match.type) + is TypeClassMatcher.ValidMatch -> handleFoundType(match.type, match.javaType, match.reference) + } + + /** + * Enter a found type into the dictionary if it doesn't exist yet, add a reference pointing back to where it was discovered. + */ + fun handleFoundType(type: TypeDefinition<*>, javaType: JavaType?, reference: Reference) { + val realEntry = dictionary.getOrPut(type) { DictionaryEntry() } + var typeWasSet = false + + if (javaType != null) { + typeWasSet = realEntry.setTypeIfMissing(javaType) + + if (realEntry.javaType != javaType) { + if (options.preferGraphQLResolver && realEntry.hasResolverRef()) { + log.warn("The real entry ${realEntry.joinReferences()} is a GraphQLResolver so ignoring this one ${javaType.unwrap()} $reference") + } else { + if (Map::class.java.isAssignableFrom(javaType.unwrap())) { + throw SchemaClassScannerError("Two different property map classes used for type ${type.name}:\n${realEntry.joinReferences()}\n\n- ${javaType}:\n| ${reference.getDescription()}") + } + throw SchemaClassScannerError("Two different classes used for type ${type.name}:\n${realEntry.joinReferences()}\n\n- ${javaType.typeName}:\n| ${reference.getDescription()}") + } + } + } + + realEntry.addReference(reference) + + // Check if we just added the entry... a little odd, but it works (and thread-safe, FWIW) + if (typeWasSet && javaType != null) { + handleNewType(type, javaType) + } + } + + /** + * Handle a newly found type, adding it to the list of actually used types and putting it in the scanning queue if it's an object type. + */ + private fun handleNewType(graphQLType: TypeDefinition<*>, javaType: JavaType) { + when (graphQLType) { + is ObjectTypeDefinition -> { + enqueue(graphQLType, javaType) + scanInterfacesOfType(graphQLType) + } + + 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 clazz = javaType?.unwrap() + graphQLType.withExtensions(typeExtensions) + .flatMap { it.inputValueDefinitions } + .forEach { inputValueDefinition -> + val inputGraphQLType = inputValueDefinition.type.unwrap() + if (ScalarInfo.GRAPHQL_SPECIFICATION_SCALARS_DEFINITIONS.containsKey(inputGraphQLType.name)) { + return@forEach + } + + val propertyType = clazz?.let { findInputValueTypeInType(inputValueDefinition.name, it) } + val inputValueJavaType = propertyType ?: initialDictionary[inputGraphQLType.name]?.get() + if (inputValueJavaType == null) { + handleInputValueWithoutJavaType(graphQLType, inputValueDefinition, inputGraphQLType, clazz) + return@forEach + } + + 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(javaType ?: inputValueJavaType, options), + InputObjectReference(inputValueDefinition) + ))) + } + } + + /** + * 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<*>?) { + 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 -> {} + } + } + + // an input bound to Any gets a Map at runtime too + private fun isPropertyMap(clazz: Class<*>) = + Map::class.java.isAssignableFrom(clazz) || JsonNode::class.java.isAssignableFrom(clazz) || clazz == Any::class.java + + private fun isLibraryClass(clazz: Class<*>) = + listOf("java.", "kotlin.", "com.fasterxml.jackson.").any { clazz.name.startsWith(it) } + + private fun enumWithoutClassMessage(enumType: EnumTypeDefinition, graphQLType: InputObjectTypeDefinition, inputValueDefinition: InputValueDefinition, clazz: Class<*>?): String { + val field = inputValueDefinition.name + val reason = when { + clazz == null -> "no class was found for input type '${graphQLType.name}'" + isPropertyMap(clazz) && isLibraryClass(clazz) -> "'${graphQLType.name}' is bound to ${clazz.name}, which has no type information for its fields" + else -> "${clazz.name} has no public getter or field named '$field'" + } + return "Enum type '${enumType.name}' is used by field '$field' of input type '${graphQLType.name}', but its Java enum can't be determined because $reason. " + + "Pass the enum class for type '${enumType.name}' in the parser's dictionary, or use a class for '${graphQLType.name}' with a public getter or field '$field'." + } + + fun throwIfEnumWithoutClass() { + enumsWithoutClass.entries + .find { dictionary[it.key]?.javaType == null } + ?.let { throw SchemaClassScannerError(it.value) } + } + + fun scanInterfacesOfType(graphQLType: ObjectTypeDefinition) { + graphQLType.withExtensions(typeExtensions).flatMap { it.implements }.forEach { + if (it is TypeName) { + handleFoundType(interfaceDefinitionsByName[it.name] + ?: throw SchemaClassScannerError("Object type ${graphQLType.name} declared interface ${it.name}, but no interface with that name was found in the schema!"), null, InterfaceReference(graphQLType)) + } + } + } + + private fun findInputValueTypeInType(name: String, clazz: Class<*>): JavaType? { + // 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 && isProperty(it) }?.genericType + ?: methodsWithParameters.find { !it.isSynthetic }?.genericReturnType + ?: methodsWithParameters.firstOrNull()?.genericReturnType + } + + interface EntryView { + val javaType: JavaType? + fun typeClass(): Class? + } + + class DictionaryEntry : EntryView { + private val references = mutableListOf() + override var javaType: JavaType? = null + private set + + fun setTypeIfMissing(javaType: JavaType): Boolean { + if (this.javaType == null) { + this.javaType = javaType + return true + } + + return false + } + +// fun type(): JavaType? = classEntry?.type + + override fun typeClass(): Class? = javaType?.unwrap() + + fun addReference(reference: Reference) { + references.add(reference) + } + + fun joinReferences() = "- ${javaType?.typeName}:\n| " + references.joinToString("\n| ") { it.getDescription() } + + fun hasResolverRef(): Boolean { + references.filterIsInstance().forEach { reference -> + if (GraphQLResolver::class.java.isAssignableFrom(reference.getMethod().declaringClass)) { + return true + } + } + return false + } + } + + class InitialDictionaryEntry(private val clazz: Class<*>) { + var accessed = false + private set + + fun get(): Class<*> { + accessed = true + return clazz + } + } +} diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index c1c4f8c2..876591f8 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -1,6 +1,6 @@ package graphql.kickstart.tools -import com.fasterxml.jackson.databind.JsonNode +import graphql.kickstart.tools.ScanDictionary.InitialDictionaryEntry import graphql.kickstart.tools.resolver.FieldResolver import graphql.kickstart.tools.resolver.FieldResolverScanner import graphql.kickstart.tools.resolver.MethodFieldResolver @@ -55,15 +55,13 @@ internal class SchemaClassScanner( .groupBy { it.name } private val objectDefinitions = allDefinitions.filterIsInstance().filter { it !is ObjectTypeExtensionDefinition } private val objectDefinitionsByName = objectDefinitions.associateBy { it.name } - private val interfaceDefinitionsByName = definitionsByName.values.filterIsInstance().associateBy { it.name } private val scalarNames = ScalarInfo.GRAPHQL_SPECIFICATION_SCALARS_DEFINITIONS.keys + scalarDefinitions.map { it.name } private val fieldResolverScanner = FieldResolverScanner(options, scalarNames) private val typeClassMatcher = TypeClassMatcher(definitionsByName) - private val dictionary = mutableMapOf, DictionaryEntry>() private val unvalidatedTypes = mutableSetOf>() - private val enumsWithoutClass = mutableMapOf() private val queue = linkedSetOf() + private val dictionary = ScanDictionary(options, log, typeExtensions, definitionsByName, typeClassMatcher, this.initialDictionary, ::enqueue, ::handleFoundScalarType) private val fieldResolversByType = mutableMapOf>() @@ -158,9 +156,9 @@ internal class SchemaClassScanner( private fun handleUnusedType(type: TypeDefinition<*>) { val initialEntry = initialDictionary[type.name] when { - initialEntry != null -> handleFoundType(type, initialEntry.get(), DictionaryReference()) + initialEntry != null -> dictionary.handleFoundType(type, initialEntry.get(), DictionaryReference()) // Interfaces and unions are resolved through the classes of their implementations, so they don't need one - type is InterfaceTypeDefinition || type is UnionTypeDefinition -> handleFoundType(type, null, UnusedTypeReference()) + type is InterfaceTypeDefinition || type is UnionTypeDefinition -> dictionary.handleFoundType(type, null, UnusedTypeReference()) else -> throw SchemaClassScannerError("Object type '${type.name}' is unused and includeUnusedTypes is true. Please pass a class for type '${type.name}' in the parser's dictionary.") } } @@ -186,7 +184,7 @@ internal class SchemaClassScanner( } unvalidatedTypes.add(rootType.type) - scanInterfacesOfType(rootType.type) + dictionary.scanInterfacesOfType(rootType.type) scanResolverInfoForPotentialMatches(rootType.type, rootType.resolverInfo) } @@ -225,7 +223,7 @@ internal class SchemaClassScanner( val initialEntry = initialDictionary[type.name] if (initialEntry != null) { - handleFoundType(type, initialEntry.get(), DictionaryReference()) + dictionary.handleFoundType(type, initialEntry.get(), DictionaryReference()) } else { unvalidatedTypes.add(type) } @@ -251,7 +249,7 @@ internal class SchemaClassScanner( } private fun validateAndCreateResult(rootTypeHolder: RootTypesHolder): ScannedSchemaObjects { - throwIfEnumWithoutClass() + dictionary.throwIfEnumWithoutClass() initialDictionary .filter { !it.value.accessed } @@ -359,7 +357,7 @@ internal class SchemaClassScanner( .map { it to UnionMemberReference(union) } } - newUnions.forEach { (type, reference) -> handleFoundType(type, null, reference) } + newUnions.forEach { (type, reference) -> dictionary.handleFoundType(type, null, reference) } return newUnions.isNotEmpty() } @@ -379,7 +377,7 @@ internal class SchemaClassScanner( } } - newTypes.forEach { (type, reference) -> handleFoundType(type, null, reference) } + newTypes.forEach { (type, reference) -> dictionary.handleFoundType(type, null, reference) } return newTypes.isNotEmpty() } @@ -390,7 +388,7 @@ internal class SchemaClassScanner( if (!unvalidatedTypes.contains(type) && !dictionaryContainsType) { val initialEntry = initialDictionary[type.name] if (initialEntry != null) { - handleFoundType(type, initialEntry.get(), DictionaryReference()) + dictionary.handleFoundType(type, initialEntry.get(), DictionaryReference()) } else { typesWithoutClass.add(type) } @@ -402,7 +400,7 @@ internal class SchemaClassScanner( typesWithoutClass.firstOrNull()?.let { throw SchemaClassScannerError(failureMessage(it) + sealedSubclassHint(it)) } } - sealedMatches.forEach { handleFoundType(it) } + sealedMatches.forEach { dictionary.handleFoundType(it) } } private fun sealedSubclassHint(type: TypeDefinition<*>): String { @@ -511,7 +509,7 @@ internal class SchemaClassScanner( fieldResolversByType.getOrPut(type) { mutableMapOf() }[fieldResolver.field] = fieldResolver fieldResolver.scanForMatches().forEach { potentialMatch -> - handleFoundType(typeClassMatcher.match(potentialMatch)) + dictionary.handleFoundType(typeClassMatcher.match(potentialMatch)) } warnIfParameterMissing(type, fieldResolver) } @@ -559,145 +557,10 @@ internal class SchemaClassScanner( } } - private fun handleFoundType(match: TypeClassMatcher.Match) = when (match) { - is TypeClassMatcher.ScalarMatch -> handleFoundScalarType(match.type) - is TypeClassMatcher.ValidMatch -> handleFoundType(match.type, match.javaType, match.reference) - } - private fun handleFoundScalarType(type: ScalarTypeDefinition) { unvalidatedTypes.add(type) } - /** - * Enter a found type into the dictionary if it doesn't exist yet, add a reference pointing back to where it was discovered. - */ - private fun handleFoundType(type: TypeDefinition<*>, javaType: JavaType?, reference: Reference) { - val realEntry = dictionary.getOrPut(type) { DictionaryEntry() } - var typeWasSet = false - - if (javaType != null) { - typeWasSet = realEntry.setTypeIfMissing(javaType) - - if (realEntry.javaType != javaType) { - if (options.preferGraphQLResolver && realEntry.hasResolverRef()) { - log.warn("The real entry ${realEntry.joinReferences()} is a GraphQLResolver so ignoring this one ${javaType.unwrap()} $reference") - } else { - if (Map::class.java.isAssignableFrom(javaType.unwrap())) { - throw SchemaClassScannerError("Two different property map classes used for type ${type.name}:\n${realEntry.joinReferences()}\n\n- ${javaType}:\n| ${reference.getDescription()}") - } - throw SchemaClassScannerError("Two different classes used for type ${type.name}:\n${realEntry.joinReferences()}\n\n- ${javaType.typeName}:\n| ${reference.getDescription()}") - } - } - } - - realEntry.addReference(reference) - - // Check if we just added the entry... a little odd, but it works (and thread-safe, FWIW) - if (typeWasSet && javaType != null) { - handleNewType(type, javaType) - } - } - - /** - * Handle a newly found type, adding it to the list of actually used types and putting it in the scanning queue if it's an object type. - */ - private fun handleNewType(graphQLType: TypeDefinition<*>, javaType: JavaType) { - when (graphQLType) { - is ObjectTypeDefinition -> { - enqueue(graphQLType, javaType) - scanInterfacesOfType(graphQLType) - } - - 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 clazz = javaType?.unwrap() - graphQLType.withExtensions(typeExtensions) - .flatMap { it.inputValueDefinitions } - .forEach { inputValueDefinition -> - val inputGraphQLType = inputValueDefinition.type.unwrap() - if (ScalarInfo.GRAPHQL_SPECIFICATION_SCALARS_DEFINITIONS.containsKey(inputGraphQLType.name)) { - return@forEach - } - - val propertyType = clazz?.let { findInputValueTypeInType(inputValueDefinition.name, it) } - val inputValueJavaType = propertyType ?: initialDictionary[inputGraphQLType.name]?.get() - if (inputValueJavaType == null) { - handleInputValueWithoutJavaType(graphQLType, inputValueDefinition, inputGraphQLType, clazz) - return@forEach - } - - 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(javaType ?: inputValueJavaType, options), - InputObjectReference(inputValueDefinition) - ))) - } - } - - /** - * 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<*>?) { - 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 -> {} - } - } - - // an input bound to Any gets a Map at runtime too - private fun isPropertyMap(clazz: Class<*>) = - Map::class.java.isAssignableFrom(clazz) || JsonNode::class.java.isAssignableFrom(clazz) || clazz == Any::class.java - - private fun isLibraryClass(clazz: Class<*>) = - listOf("java.", "kotlin.", "com.fasterxml.jackson.").any { clazz.name.startsWith(it) } - - private fun enumWithoutClassMessage(enumType: EnumTypeDefinition, graphQLType: InputObjectTypeDefinition, inputValueDefinition: InputValueDefinition, clazz: Class<*>?): String { - val field = inputValueDefinition.name - val reason = when { - clazz == null -> "no class was found for input type '${graphQLType.name}'" - isPropertyMap(clazz) && isLibraryClass(clazz) -> "'${graphQLType.name}' is bound to ${clazz.name}, which has no type information for its fields" - else -> "${clazz.name} has no public getter or field named '$field'" - } - return "Enum type '${enumType.name}' is used by field '$field' of input type '${graphQLType.name}', but its Java enum can't be determined because $reason. " + - "Pass the enum class for type '${enumType.name}' in the parser's dictionary, or use a class for '${graphQLType.name}' with a public getter or field '$field'." - } - - private fun throwIfEnumWithoutClass() { - enumsWithoutClass.entries - .find { dictionary[it.key]?.javaType == null } - ?.let { throw SchemaClassScannerError(it.value) } - } - - private fun scanInterfacesOfType(graphQLType: ObjectTypeDefinition) { - graphQLType.withExtensions(typeExtensions).flatMap { it.implements }.forEach { - if (it is TypeName) { - handleFoundType(interfaceDefinitionsByName[it.name] - ?: throw SchemaClassScannerError("Object type ${graphQLType.name} declared interface ${it.name}, but no interface with that name was found in the schema!"), null, InterfaceReference(graphQLType)) - } - } - } - private fun extendsOtherKind(definition: TypeDefinition<*>): Boolean { val type = definitionsByName[definition.name] return definition is SDLExtensionDefinition && type != null && type.kind() != definition.kind() @@ -717,64 +580,8 @@ internal class SchemaClassScanner( queue.add(QueueItem(graphQLType, javaType)) } - private fun findInputValueTypeInType(name: String, clazz: Class<*>): JavaType? { - // 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 && isProperty(it) }?.genericType - ?: methodsWithParameters.find { !it.isSynthetic }?.genericReturnType - ?: methodsWithParameters.firstOrNull()?.genericReturnType - } - private data class QueueItem(val type: ObjectTypeDefinition, val clazz: JavaType) - interface EntryView { - val javaType: JavaType? - fun typeClass(): Class? - } - - private class DictionaryEntry : EntryView { - private val references = mutableListOf() - override var javaType: JavaType? = null - private set - - fun setTypeIfMissing(javaType: JavaType): Boolean { - if (this.javaType == null) { - this.javaType = javaType - return true - } - - return false - } - -// fun type(): JavaType? = classEntry?.type - - override fun typeClass(): Class? = javaType?.unwrap() - - fun addReference(reference: Reference) { - references.add(reference) - } - - fun joinReferences() = "- ${javaType?.typeName}:\n| " + references.joinToString("\n| ") { it.getDescription() } - - fun hasResolverRef(): Boolean { - references.filterIsInstance().forEach { reference -> - if (GraphQLResolver::class.java.isAssignableFrom(reference.getMethod().declaringClass)) { - return true - } - } - return false - } - } - abstract class Reference { abstract fun getDescription(): String override fun toString() = getDescription() @@ -788,7 +595,7 @@ internal class SchemaClassScanner( override fun getDescription() = "unused type" } - private class InterfaceReference(private val type: ObjectTypeDefinition) : Reference() { + class InterfaceReference(private val type: ObjectTypeDefinition) : Reference() { override fun getDescription() = "interface declarations of ${type.name}" } @@ -800,20 +607,10 @@ internal class SchemaClassScanner( override fun getDescription() = "subclass of sealed type ${sealedType.typeName}" } - private class InputObjectReference(private val type: InputValueDefinition) : Reference() { + class InputObjectReference(private val type: InputValueDefinition) : Reference() { override fun getDescription() = "input object $type" } - private class InitialDictionaryEntry(private val clazz: Class<*>) { - var accessed = false - private set - - fun get(): Class<*> { - accessed = true - return clazz - } - } - internal class ReturnValueReference(private val method: Method) : Reference() { fun getMethod() = method override fun getDescription() = "return type of method $method" From c0c5efe0cee1dd0b7540fa9c2a2d0b0119f04ae8 Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 13:23:53 -0400 Subject: [PATCH 3/7] Move the scan references into ScanDictionary Moves Reference and its subclasses next to DictionaryEntry, which stores them and prints them in the "Two different classes" error. TypeClassMatcher and the field resolvers no longer name SchemaClassScanner. Edits to compile: - InterfaceReference and InputObjectReference are private again. - private dropped on DictionaryReference, UnusedTypeReference, UnionMemberReference and SealedSubclassReference, which the scanner creates. - SchemaClassScanner.XReference becomes ScanDictionary.XReference in TypeClassMatcher, MethodFieldResolver, PropertyFieldResolver, MapFieldResolver and NestedGenericWrapperTest. - Imports. --- .../graphql/kickstart/tools/ScanDictionary.kt | 47 ++++++++++++++++-- .../kickstart/tools/SchemaClassScanner.kt | 48 ++----------------- .../kickstart/tools/TypeClassMatcher.kt | 8 ++-- .../tools/resolver/MapFieldResolver.kt | 4 +- .../tools/resolver/MethodFieldResolver.kt | 4 +- .../tools/resolver/PropertyFieldResolver.kt | 4 +- .../tools/NestedGenericWrapperTest.kt | 2 +- 7 files changed, 59 insertions(+), 58 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/ScanDictionary.kt b/src/main/kotlin/graphql/kickstart/tools/ScanDictionary.kt index f46c0e78..187667ec 100644 --- a/src/main/kotlin/graphql/kickstart/tools/ScanDictionary.kt +++ b/src/main/kotlin/graphql/kickstart/tools/ScanDictionary.kt @@ -1,10 +1,6 @@ package graphql.kickstart.tools import com.fasterxml.jackson.databind.JsonNode -import graphql.kickstart.tools.SchemaClassScanner.InputObjectReference -import graphql.kickstart.tools.SchemaClassScanner.InterfaceReference -import graphql.kickstart.tools.SchemaClassScanner.Reference -import graphql.kickstart.tools.SchemaClassScanner.ReturnValueReference import graphql.kickstart.tools.util.JavaType import graphql.kickstart.tools.util.unwrap import graphql.kickstart.tools.util.withExtensions @@ -12,6 +8,7 @@ import graphql.language.* import graphql.schema.idl.ScalarInfo import org.slf4j.Logger import java.lang.reflect.Member +import java.lang.reflect.Method internal class ScanDictionary( private val options: SchemaParserOptions, @@ -227,4 +224,46 @@ internal class ScanDictionary( return clazz } } + + abstract class Reference { + abstract fun getDescription(): String + override fun toString() = getDescription() + } + + class DictionaryReference : Reference() { + override fun getDescription() = "provided dictionary" + } + + class UnusedTypeReference : Reference() { + override fun getDescription() = "unused type" + } + + private class InterfaceReference(private val type: ObjectTypeDefinition) : Reference() { + override fun getDescription() = "interface declarations of ${type.name}" + } + + class UnionMemberReference(private val union: UnionTypeDefinition) : Reference() { + override fun getDescription() = "member of union ${union.name}" + } + + class SealedSubclassReference(private val sealedType: JavaType) : Reference() { + override fun getDescription() = "subclass of sealed type ${sealedType.typeName}" + } + + private class InputObjectReference(private val type: InputValueDefinition) : Reference() { + override fun getDescription() = "input object $type" + } + + internal class ReturnValueReference(private val method: Method) : Reference() { + fun getMethod() = method + override fun getDescription() = "return type of method $method" + } + + internal class MethodParameterReference(private val method: Method, private val index: Int) : Reference() { + override fun getDescription() = "parameter $index of method $method" + } + + internal class FieldTypeReference(private val field: String) : Reference() { + override fun getDescription() = "type of field $field" + } } diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 876591f8..e03808c7 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -1,6 +1,11 @@ package graphql.kickstart.tools +import graphql.kickstart.tools.ScanDictionary.DictionaryReference +import graphql.kickstart.tools.ScanDictionary.FieldTypeReference import graphql.kickstart.tools.ScanDictionary.InitialDictionaryEntry +import graphql.kickstart.tools.ScanDictionary.SealedSubclassReference +import graphql.kickstart.tools.ScanDictionary.UnionMemberReference +import graphql.kickstart.tools.ScanDictionary.UnusedTypeReference import graphql.kickstart.tools.resolver.FieldResolver import graphql.kickstart.tools.resolver.FieldResolverScanner import graphql.kickstart.tools.resolver.MethodFieldResolver @@ -14,7 +19,6 @@ import org.apache.commons.lang3.reflect.TypeUtils import org.slf4j.LoggerFactory import java.lang.reflect.Field import java.lang.reflect.Member -import java.lang.reflect.Method import java.lang.reflect.Modifier import java.lang.reflect.ParameterizedType @@ -582,48 +586,6 @@ internal class SchemaClassScanner( private data class QueueItem(val type: ObjectTypeDefinition, val clazz: JavaType) - abstract class Reference { - abstract fun getDescription(): String - override fun toString() = getDescription() - } - - private class DictionaryReference : Reference() { - override fun getDescription() = "provided dictionary" - } - - private class UnusedTypeReference : Reference() { - override fun getDescription() = "unused type" - } - - class InterfaceReference(private val type: ObjectTypeDefinition) : Reference() { - override fun getDescription() = "interface declarations of ${type.name}" - } - - private class UnionMemberReference(private val union: UnionTypeDefinition) : Reference() { - override fun getDescription() = "member of union ${union.name}" - } - - private class SealedSubclassReference(private val sealedType: JavaType) : Reference() { - override fun getDescription() = "subclass of sealed type ${sealedType.typeName}" - } - - class InputObjectReference(private val type: InputValueDefinition) : Reference() { - override fun getDescription() = "input object $type" - } - - internal class ReturnValueReference(private val method: Method) : Reference() { - fun getMethod() = method - override fun getDescription() = "return type of method $method" - } - - internal class MethodParameterReference(private val method: Method, private val index: Int) : Reference() { - override fun getDescription() = "parameter $index of method $method" - } - - internal class FieldTypeReference(private val field: String) : Reference() { - override fun getDescription() = "type of field $field" - } - private class RootTypesHolder( options: SchemaParserOptions, rootInfo: RootTypeInfo, diff --git a/src/main/kotlin/graphql/kickstart/tools/TypeClassMatcher.kt b/src/main/kotlin/graphql/kickstart/tools/TypeClassMatcher.kt index 1089e5db..a4244214 100644 --- a/src/main/kotlin/graphql/kickstart/tools/TypeClassMatcher.kt +++ b/src/main/kotlin/graphql/kickstart/tools/TypeClassMatcher.kt @@ -95,7 +95,7 @@ internal class TypeClassMatcher(private val definitionsByName: Map, val javaType: JavaType, val reference: SchemaClassScanner.Reference) : Match + internal data class ValidMatch(val type: TypeDefinition<*>, val javaType: JavaType, val reference: ScanDictionary.Reference) : Match internal enum class Location(val prettyName: String) { RETURN_TYPE("return type"), @@ -106,14 +106,14 @@ internal class TypeClassMatcher(private val definitionsByName: Map { - return listOf(TypeClassMatcher.PotentialMatch.returnValue(field.type, mapGenericValue, typeResolver, SchemaClassScanner.FieldTypeReference(field.name))) + return listOf(TypeClassMatcher.PotentialMatch.returnValue(field.type, mapGenericValue, typeResolver, ScanDictionary.FieldTypeReference(field.name))) } override fun toString() = "MapFieldResolver{key=${field.name}}" diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt index b863b882..a1a7cf41 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt @@ -160,10 +160,10 @@ internal class MethodFieldResolver( } override fun scanForMatches(): List { - val returnValueMatch = TypeClassMatcher.PotentialMatch.returnValue(field.type, getResolvedReturnType(), typeResolver, SchemaClassScanner.ReturnValueReference(method)) + val returnValueMatch = TypeClassMatcher.PotentialMatch.returnValue(field.type, getResolvedReturnType(), typeResolver, ScanDictionary.ReturnValueReference(method)) return field.inputValueDefinitions.mapIndexed { i, inputDefinition -> - TypeClassMatcher.PotentialMatch.parameterType(inputDefinition.type, getMethodParameterType(i)!!, typeResolver, SchemaClassScanner.MethodParameterReference(method, i)) + TypeClassMatcher.PotentialMatch.parameterType(inputDefinition.type, getMethodParameterType(i)!!, typeResolver, ScanDictionary.MethodParameterReference(method, i)) } + listOf(returnValueMatch) } diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/PropertyFieldResolver.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/PropertyFieldResolver.kt index 818186e8..1e6cb062 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/PropertyFieldResolver.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/PropertyFieldResolver.kt @@ -1,6 +1,6 @@ package graphql.kickstart.tools.resolver -import graphql.kickstart.tools.SchemaClassScanner +import graphql.kickstart.tools.ScanDictionary import graphql.kickstart.tools.SchemaParserOptions import graphql.kickstart.tools.TypeClassMatcher import graphql.language.FieldDefinition @@ -31,7 +31,7 @@ internal class PropertyFieldResolver( field.type, property.genericType, typeResolver, - SchemaClassScanner.FieldTypeReference(property.toString()) + ScanDictionary.FieldTypeReference(property.toString()) ) ) } diff --git a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt index 2a935dfb..0684b81f 100644 --- a/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/NestedGenericWrapperTest.kt @@ -181,7 +181,7 @@ class NestedGenericWrapperTest { val accepted = javaTypes.flatMap { (shape, javaType) -> graphQLTypes.map { Triple(shape, javaType, it) } }.filter { (_, javaType, graphQLType) -> val match = try { - matcher.match(TypeClassMatcher.PotentialMatch.returnValue(graphQLType, typeResolver.resolve(javaType), typeResolver, SchemaClassScanner.ReturnValueReference(method))) + matcher.match(TypeClassMatcher.PotentialMatch.returnValue(graphQLType, typeResolver.resolve(javaType), typeResolver, ScanDictionary.ReturnValueReference(method))) } catch (e: SchemaClassScannerError) { null } From 661fb59e82aa515944e683f79f822972e9730848 Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 13:23:53 -0400 Subject: [PATCH 4/7] Move sealed subclass matching out of SchemaClassScanner Moves sealedSubclassHint, sealedSubclassMatches, sealedTypesOf, sealedSubclasses and withSealedTypeArguments to SealedSubclassMatcher.kt. It reads the dictionary as a Map, EntryView> and never changes it. Edits to compile: - SealedSubclassMatcher's header. - private dropped on sealedSubclassHint and sealedSubclassMatches. - The scanner holds a sealedSubclassMatcher and calls both through it. - Imports. --- .../kickstart/tools/SchemaClassScanner.kt | 64 +--------------- .../kickstart/tools/SealedSubclassMatcher.kt | 74 +++++++++++++++++++ 2 files changed, 77 insertions(+), 61 deletions(-) create mode 100644 src/main/kotlin/graphql/kickstart/tools/SealedSubclassMatcher.kt diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index e03808c7..7ee3c42a 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -3,7 +3,6 @@ package graphql.kickstart.tools import graphql.kickstart.tools.ScanDictionary.DictionaryReference import graphql.kickstart.tools.ScanDictionary.FieldTypeReference import graphql.kickstart.tools.ScanDictionary.InitialDictionaryEntry -import graphql.kickstart.tools.ScanDictionary.SealedSubclassReference import graphql.kickstart.tools.ScanDictionary.UnionMemberReference import graphql.kickstart.tools.ScanDictionary.UnusedTypeReference import graphql.kickstart.tools.resolver.FieldResolver @@ -15,12 +14,10 @@ import graphql.language.* import graphql.schema.idl.ScalarInfo import graphql.schema.idl.TypeUtil import org.apache.commons.lang3.ClassUtils -import org.apache.commons.lang3.reflect.TypeUtils import org.slf4j.LoggerFactory import java.lang.reflect.Field import java.lang.reflect.Member import java.lang.reflect.Modifier -import java.lang.reflect.ParameterizedType /** * @author Andrew Potter @@ -66,6 +63,7 @@ internal class SchemaClassScanner( private val unvalidatedTypes = mutableSetOf>() private val queue = linkedSetOf() private val dictionary = ScanDictionary(options, log, typeExtensions, definitionsByName, typeClassMatcher, this.initialDictionary, ::enqueue, ::handleFoundScalarType) + private val sealedSubclassMatcher = SealedSubclassMatcher(dictionary, typeExtensions) private val fieldResolversByType = mutableMapOf>() @@ -399,70 +397,14 @@ internal class SchemaClassScanner( } } - val sealedMatches = typesWithoutClass.filterIsInstance().mapNotNull { sealedSubclassMatches(it).singleOrNull() } + val sealedMatches = typesWithoutClass.filterIsInstance().mapNotNull { sealedSubclassMatcher.sealedSubclassMatches(it).singleOrNull() } if (sealedMatches.isEmpty()) { - typesWithoutClass.firstOrNull()?.let { throw SchemaClassScannerError(failureMessage(it) + sealedSubclassHint(it)) } + typesWithoutClass.firstOrNull()?.let { throw SchemaClassScannerError(failureMessage(it) + sealedSubclassMatcher.sealedSubclassHint(it)) } } sealedMatches.forEach { dictionary.handleFoundType(it) } } - private fun sealedSubclassHint(type: TypeDefinition<*>): String { - if (type !is ObjectTypeDefinition) { - return "" - } - - val sealedTypes = sealedTypesOf(type) - if (sealedTypes.isEmpty()) { - return "" - } - - val sealedTypeNames = sealedTypes.joinToString(" or ") { it.unwrap().name } - val matches = sealedSubclassMatches(type) - return if (matches.isEmpty()) { - " No subclass of sealed type $sealedTypeNames is named '${type.name}'." - } else { - " More than one subclass of sealed type $sealedTypeNames is named '${type.name}': ${matches.joinToString { it.javaType.unwrap().name }}." - } - } - - private fun sealedSubclassMatches(type: ObjectTypeDefinition): List = - sealedTypesOf(type) - .flatMap { sealedType -> - sealedSubclasses(sealedType.unwrap()) - .filter { it.simpleName == type.name } - .map { TypeClassMatcher.ValidMatch(type, withSealedTypeArguments(it, sealedType), SealedSubclassReference(sealedType)) } - } - .distinctBy { it.javaType.unwrap() } - - private fun sealedTypesOf(type: ObjectTypeDefinition): List = - dictionary - .filterKeys { definition -> - when (definition) { - is UnionTypeDefinition -> definition.withExtensions(typeExtensions).flatMap { it.memberTypes }.filterIsInstance().any { it.name == type.name } - is InterfaceTypeDefinition -> type.withExtensions(typeExtensions).flatMap { it.implements }.filterIsInstance().any { it.name == definition.name } - else -> false - } - } - .values.mapNotNull { it.javaType } - .distinct() - .filter { it.unwrap().kotlin.isSealed } - - // kotlin-reflect also returns the permitted subclasses of Java sealed classes - private fun sealedSubclasses(clazz: Class<*>): List> = - clazz.kotlin.sealedSubclasses.flatMap { listOf(it.java) + sealedSubclasses(it.java) } - - private fun withSealedTypeArguments(subclass: Class<*>, sealedType: JavaType): JavaType { - if (sealedType !is ParameterizedType || subclass.typeParameters.isEmpty()) { - return subclass - } - - return TypeUtils.determineTypeArguments(subclass, sealedType) - ?.takeIf { it.keys.containsAll(subclass.typeParameters.asList()) } - ?.let { TypeUtils.parameterize(subclass, it) } - ?: subclass - } - /** * Scan a new object for types that haven't been mapped yet. */ diff --git a/src/main/kotlin/graphql/kickstart/tools/SealedSubclassMatcher.kt b/src/main/kotlin/graphql/kickstart/tools/SealedSubclassMatcher.kt new file mode 100644 index 00000000..671e940d --- /dev/null +++ b/src/main/kotlin/graphql/kickstart/tools/SealedSubclassMatcher.kt @@ -0,0 +1,74 @@ +package graphql.kickstart.tools + +import graphql.kickstart.tools.ScanDictionary.SealedSubclassReference +import graphql.kickstart.tools.util.JavaType +import graphql.kickstart.tools.util.unwrap +import graphql.kickstart.tools.util.withExtensions +import graphql.language.InterfaceTypeDefinition +import graphql.language.ObjectTypeDefinition +import graphql.language.TypeDefinition +import graphql.language.TypeName +import graphql.language.UnionTypeDefinition +import org.apache.commons.lang3.reflect.TypeUtils +import java.lang.reflect.ParameterizedType + +internal class SealedSubclassMatcher( + private val dictionary: Map, ScanDictionary.EntryView>, + private val typeExtensions: TypeExtensions, +) { + fun sealedSubclassHint(type: TypeDefinition<*>): String { + if (type !is ObjectTypeDefinition) { + return "" + } + + val sealedTypes = sealedTypesOf(type) + if (sealedTypes.isEmpty()) { + return "" + } + + val sealedTypeNames = sealedTypes.joinToString(" or ") { it.unwrap().name } + val matches = sealedSubclassMatches(type) + return if (matches.isEmpty()) { + " No subclass of sealed type $sealedTypeNames is named '${type.name}'." + } else { + " More than one subclass of sealed type $sealedTypeNames is named '${type.name}': ${matches.joinToString { it.javaType.unwrap().name }}." + } + } + + fun sealedSubclassMatches(type: ObjectTypeDefinition): List = + sealedTypesOf(type) + .flatMap { sealedType -> + sealedSubclasses(sealedType.unwrap()) + .filter { it.simpleName == type.name } + .map { TypeClassMatcher.ValidMatch(type, withSealedTypeArguments(it, sealedType), SealedSubclassReference(sealedType)) } + } + .distinctBy { it.javaType.unwrap() } + + private fun sealedTypesOf(type: ObjectTypeDefinition): List = + dictionary + .filterKeys { definition -> + when (definition) { + is UnionTypeDefinition -> definition.withExtensions(typeExtensions).flatMap { it.memberTypes }.filterIsInstance().any { it.name == type.name } + is InterfaceTypeDefinition -> type.withExtensions(typeExtensions).flatMap { it.implements }.filterIsInstance().any { it.name == definition.name } + else -> false + } + } + .values.mapNotNull { it.javaType } + .distinct() + .filter { it.unwrap().kotlin.isSealed } + + // kotlin-reflect also returns the permitted subclasses of Java sealed classes + private fun sealedSubclasses(clazz: Class<*>): List> = + clazz.kotlin.sealedSubclasses.flatMap { listOf(it.java) + sealedSubclasses(it.java) } + + private fun withSealedTypeArguments(subclass: Class<*>, sealedType: JavaType): JavaType { + if (sealedType !is ParameterizedType || subclass.typeParameters.isEmpty()) { + return subclass + } + + return TypeUtils.determineTypeArguments(subclass, sealedType) + ?.takeIf { it.keys.containsAll(subclass.typeParameters.asList()) } + ?.let { TypeUtils.parameterize(subclass, it) } + ?: subclass + } +} From 2975b207d73e7ef8148c080b84325f435e2e06eb Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 13:23:54 -0400 Subject: [PATCH 5/7] Move SourceLocation.describe() to util/Utils.kt SchemaParser uses it, and so do the error messages and the directive code that move out of SchemaParser next. Edits to compile: private becomes internal, and SchemaParser imports it. --- src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt | 4 +--- src/main/kotlin/graphql/kickstart/tools/util/Utils.kt | 3 +++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index cb640174..441b3ac5 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -16,6 +16,7 @@ import graphql.introspection.Introspection.DirectiveLocation.SCHEMA import graphql.introspection.Introspection.DirectiveLocation.UNION import graphql.kickstart.tools.directive.DirectiveWiringHelper import graphql.kickstart.tools.resolver.MethodFieldResolver +import graphql.kickstart.tools.util.describe import graphql.kickstart.tools.util.extensionsOf import graphql.kickstart.tools.util.getDocumentation import graphql.kickstart.tools.util.unwrap @@ -734,6 +735,3 @@ private val BUILT_IN_DIRECTIVES = listOf( ).associateBy { it.name } const val DEFAULT_DEPRECATION_MESSAGE = "No longer supported" - -private fun SourceLocation?.describe(): String = - this?.let { " at line $line, column $column" + sourceName?.let { name -> " in $name" }.orEmpty() }.orEmpty() diff --git a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt index 91755b5f..bcd52629 100644 --- a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt +++ b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt @@ -116,3 +116,6 @@ internal fun getDocumentation(node: AbstractNode<*>, options: SchemaParserOption } internal fun String.snakeToCamelCase(): String = split("_").joinToString(separator = "") { it.replaceFirstChar(Char::titlecase) } + +internal fun SourceLocation?.describe(): String = + this?.let { " at line $line, column $column" + sourceName?.let { name -> " in $name" }.orEmpty() }.orEmpty() From cb662e5196fe58c4f10aee257dc0c5d8d8e7c1ab Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 13:23:54 -0400 Subject: [PATCH 6/7] Move the unexpected-type error messages out of SchemaParser Moves unexpectedTypeError, unreachedTypeHint, findUsage and fieldUsages to TypeReferenceErrors.kt. determineType stays in SchemaParser, because for input types it builds missing input objects through createInputObject. Edits to compile: - TypeReferenceErrors' header, with definitions, unusedDefinitions and typeExtensions. - private dropped on unexpectedTypeError. - SchemaParser holds a typeReferenceErrors, and determineType throws through it. - Imports. --- .../graphql/kickstart/tools/SchemaParser.kt | 59 +--------------- .../kickstart/tools/TypeReferenceErrors.kt | 69 +++++++++++++++++++ 2 files changed, 71 insertions(+), 57 deletions(-) create mode 100644 src/main/kotlin/graphql/kickstart/tools/TypeReferenceErrors.kt diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 441b3ac5..ae0a3dc7 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -70,6 +70,7 @@ class SchemaParser internal constructor( unionDefinitions.map { it.name }).toSet() private val permittedTypesForInputObject: Set = (inputObjectDefinitions.map { it.name } + enumDefinitions.map { it.name }).toSet() + private val typeReferenceErrors = TypeReferenceErrors(definitions, unusedDefinitions, typeExtensions) private val codeRegistryBuilder = GraphQLCodeRegistry.newCodeRegistry() private val directiveWiringHelper = DirectiveWiringHelper(runtimeWiring, codeRegistryBuilder) @@ -628,7 +629,7 @@ class SchemaParser internal constructor( scalarType } else { if (!allowedTypeReferences.contains(typeDefinition.name)) { - throw unexpectedTypeError(typeDefinition, expectedType) + throw typeReferenceErrors.unexpectedTypeError(typeDefinition, expectedType) } namedType(typeDefinition) } @@ -648,62 +649,6 @@ class SchemaParser internal constructor( return createInputObject(definition, inputObjects, inputObjectsInProgress).also { (inputObjects as MutableList).add(it) } } - private fun unexpectedTypeError(typeName: TypeName, expectedType: KClass<*>): SchemaError { - val isInput = expectedType == GraphQLInputType::class - val definition = unusedDefinitions.find { it.name == typeName.name } - ?: definitions.filterIsInstance>().find { it.name == typeName.name } - val outputOnlyKind = when (definition) { - is ObjectTypeDefinition -> "an object type" - is InterfaceTypeDefinition -> "an interface" - is UnionTypeDefinition -> "a union" - else -> null - } - - val problem = when { - definition == null -> "is not defined in the schema." - isInput && outputOnlyKind != null -> - "is $outputOnlyKind, which can't be used as an input type. Input types are input objects, enums and scalars." - !isInput && definition is InputObjectTypeDefinition -> - "is an input object type, which can't be used as an output type. Output types are object types, interfaces, unions, enums and scalars." - definition in unusedDefinitions -> - "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 -> "can't be used as ${if (isInput) "an input" else "an output"} type." - } - val usage = findUsage(typeName)?.let { " used by $it" }.orEmpty() - return SchemaError("Type '${typeName.name}'$usage${typeName.sourceLocation.describe()} $problem") - } - - // 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 + typeExtensions.values.flatten()).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/main/kotlin/graphql/kickstart/tools/TypeReferenceErrors.kt b/src/main/kotlin/graphql/kickstart/tools/TypeReferenceErrors.kt new file mode 100644 index 00000000..679cfbe5 --- /dev/null +++ b/src/main/kotlin/graphql/kickstart/tools/TypeReferenceErrors.kt @@ -0,0 +1,69 @@ +package graphql.kickstart.tools + +import graphql.kickstart.tools.util.describe +import graphql.kickstart.tools.util.unwrap +import graphql.language.* +import graphql.schema.GraphQLInputType +import kotlin.reflect.KClass + +internal class TypeReferenceErrors( + private val definitions: Set>, + private val unusedDefinitions: Set>, + private val typeExtensions: TypeExtensions, +) { + fun unexpectedTypeError(typeName: TypeName, expectedType: KClass<*>): SchemaError { + val isInput = expectedType == GraphQLInputType::class + val definition = unusedDefinitions.find { it.name == typeName.name } + ?: definitions.filterIsInstance>().find { it.name == typeName.name } + val outputOnlyKind = when (definition) { + is ObjectTypeDefinition -> "an object type" + is InterfaceTypeDefinition -> "an interface" + is UnionTypeDefinition -> "a union" + else -> null + } + + val problem = when { + definition == null -> "is not defined in the schema." + isInput && outputOnlyKind != null -> + "is $outputOnlyKind, which can't be used as an input type. Input types are input objects, enums and scalars." + !isInput && definition is InputObjectTypeDefinition -> + "is an input object type, which can't be used as an output type. Output types are object types, interfaces, unions, enums and scalars." + definition in unusedDefinitions -> + "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 -> "can't be used as ${if (isInput) "an input" else "an output"} type." + } + val usage = findUsage(typeName)?.let { " used by $it" }.orEmpty() + return SchemaError("Type '${typeName.name}'$usage${typeName.sourceLocation.describe()} $problem") + } + + // 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 + typeExtensions.values.flatten()).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 } + } +} From 3488a31bcf4e7851ae44b7767c10bbf54dd5596e Mon Sep 17 00:00:00 2001 From: Oryan Date: Fri, 23 Oct 2026 13:23:54 -0400 Subject: [PATCH 7/7] Move the SDL directive code out of SchemaParser Moves directivesOf, isNonRepeatableByFirstDeclaration, createDirectives, createDirective, createDirectiveArgument, withSdlDirectives, the functions that resolve and render applied directives, ResolvedDirective, ResolvedArgument, missingArgumentsWithDefault, getDeprecated, the schemaDirectives map and BUILT_IN_DIRECTIVES to SdlDirectives.kt. SdlDirectives builds the schema's directive definitions in its constructor, so nothing can apply a directive before they exist. SchemaParser builds one in parseSchemaObjects() where it used to call createDirectives(), so directive errors are still thrown from there, and every schema it builds gets its own definitions. Edits to compile: - SdlDirectives' header with directiveDefinitions, options, runtimeWiring, SchemaParser's logger (its warnings keep that logger name) and inputType, the type lookup createDirectiveArgument called. The lookup's comment moves with it to SchemaParser. - init { createDirectives() }, and a definitions property for SchemaObjects. - schemaAppliedDirectives, the line SchemaParser used for the schema's own directives, so the resolving functions stay private. - private dropped on directivesOf, withSdlDirectives and getDeprecated. - SchemaParser: lateinit var sdlDirectives replaces the map, directivesOf and getDeprecated are called through it, and a private withSdlDirectives calls the one in SdlDirectives, so the builder chains don't change. - Imports. --- .../graphql/kickstart/tools/SchemaParser.kt | 254 ++---------------- .../graphql/kickstart/tools/SdlDirectives.kt | 252 +++++++++++++++++ 2 files changed, 271 insertions(+), 235 deletions(-) create mode 100644 src/main/kotlin/graphql/kickstart/tools/SdlDirectives.kt diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index ae0a3dc7..acfd9686 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -1,7 +1,6 @@ package graphql.kickstart.tools import graphql.Directives -import graphql.Scalars import graphql.introspection.Introspection import graphql.introspection.Introspection.DirectiveLocation.ARGUMENT_DEFINITION import graphql.introspection.Introspection.DirectiveLocation.ENUM @@ -12,7 +11,6 @@ import graphql.introspection.Introspection.DirectiveLocation.INPUT_OBJECT import graphql.introspection.Introspection.DirectiveLocation.INTERFACE import graphql.introspection.Introspection.DirectiveLocation.OBJECT import graphql.introspection.Introspection.DirectiveLocation.SCALAR -import graphql.introspection.Introspection.DirectiveLocation.SCHEMA import graphql.introspection.Introspection.DirectiveLocation.UNION import graphql.kickstart.tools.directive.DirectiveWiringHelper import graphql.kickstart.tools.resolver.MethodFieldResolver @@ -75,8 +73,7 @@ class SchemaParser internal constructor( private val codeRegistryBuilder = GraphQLCodeRegistry.newCodeRegistry() private val directiveWiringHelper = DirectiveWiringHelper(runtimeWiring, codeRegistryBuilder) - // by name, in the order they're declared - private lateinit var schemaDirectives: MutableMap + private lateinit var sdlDirectives: SdlDirectives /** * Parses the given schema with respect to the given dictionary and returns GraphQL objects. @@ -93,7 +90,10 @@ class SchemaParser internal constructor( // Create GraphQL objects val inputObjects: MutableList = mutableListOf() - createDirectives() + sdlDirectives = SdlDirectives(directiveDefinitions, options, runtimeWiring, log) { + // type references, so that an input object used by a directive applied to it doesn't contain itself + determineType(GraphQLInputType::class, it, permittedTypesForInputObject) as GraphQLInputType + } val scalars = scalarDefinitions.values.map { createScalar(it) } inputObjectDefinitions.forEach { if (inputObjects.none { io -> io.name == it.name }) { @@ -126,8 +126,8 @@ class SchemaParser internal constructor( val types = (additionalObjects.toSet() as Set) + inputObjects + enums + interfaces + unions + scalars // Directives applied to the schema itself, e.g. `extend schema @link(...)`. Only GraphQLAppliedDirectives are built here, not // the deprecated GraphQLDirective ones: graphql-java doesn't resolve type references in those, so enum arguments would fail. - val schemaAppliedDirectives = appliedDirectives(resolveDirectives(rootInfo.getDirectives(), SCHEMA)).toList() - val schemaObjects = SchemaObjects(query, mutation, subscription, types, schemaDirectives.values.toSet(), codeRegistryBuilder, rootInfo.getDescription(), schemaAppliedDirectives) + val schemaAppliedDirectives = sdlDirectives.schemaAppliedDirectives(rootInfo.getDirectives()) + val schemaObjects = SchemaObjects(query, mutation, subscription, types, sdlDirectives.definitions.toSet(), codeRegistryBuilder, rootInfo.getDescription(), schemaAppliedDirectives) // in the order the types were created val wiredObjects = if (directiveWiringHelper.isWiringRequired) wire(schemaObjects, scalars + inputObjects + interfaces + objects + unions + enums) else schemaObjects if (options.unmappedInputFields != UnmappedInputFields.IGNORE) { @@ -170,7 +170,7 @@ class SchemaParser internal constructor( private fun createObject(objectDefinition: ObjectTypeDefinition, interfaces: List, inputObjects: List): GraphQLObjectType { val name = objectDefinition.name val extensions = typeExtensions.extensionsOf(name) - val directives = directivesOf(objectDefinition, extensions) + val directives = sdlDirectives.directivesOf(objectDefinition, extensions) val builder = GraphQLObjectType.newObject() .name(name) .definition(objectDefinition) @@ -208,7 +208,7 @@ class SchemaParser internal constructor( private fun createInputObject(definition: InputObjectTypeDefinition, inputObjects: List, inputObjectsInProgress: MutableSet): GraphQLInputObjectType { val extensionDefinitions = typeExtensions.extensionsOf(definition.name) - val directives = directivesOf(definition, extensionDefinitions) + val directives = sdlDirectives.directivesOf(definition, extensionDefinitions) inputObjectsInProgress.add(definition.name) @@ -227,7 +227,7 @@ class SchemaParser internal constructor( .definition(fieldDefinition) .description(getDocumentation(fieldDefinition, options)) .apply { fieldDefinition.defaultValue?.let { v -> defaultValueLiteral(v) } } - .apply { getDeprecated(fieldDefinition.directives)?.let { deprecate(it) } } + .apply { sdlDirectives.getDeprecated(fieldDefinition.directives)?.let { deprecate(it) } } .type(determineInputType(fieldDefinition.type, inputObjects, inputObjectsInProgress)) .withSdlDirectives(fieldDefinition.directives, INPUT_FIELD_DEFINITION) .build() @@ -247,7 +247,7 @@ class SchemaParser internal constructor( if (enumClass != null && !enumClass.isEnum) throw SchemaError("Type '$name' is declared as an enum in the GraphQL schema but is not a Java enum!") val extensions = typeExtensions.extensionsOf(name) - val directives = directivesOf(definition, extensions) + val directives = sdlDirectives.directivesOf(definition, extensions) val values = definition.withExtensions(typeExtensions).flatMap { it.enumValueDefinitions } warnRepeated(name, "value", values.map { it.name }) val builder = GraphQLEnumType.newEnum() @@ -271,7 +271,7 @@ class SchemaParser internal constructor( .name(enumName) .description(getDocumentation(valueDefinition, options)) .value(enumValue) - .apply { getDeprecated(valueDefinition.directives)?.let { deprecationReason(it) } } + .apply { sdlDirectives.getDeprecated(valueDefinition.directives)?.let { deprecationReason(it) } } .withSdlDirectives(valueDefinition.directives, ENUM_VALUE) .definition(valueDefinition) .build() @@ -286,7 +286,7 @@ class SchemaParser internal constructor( val name = definition.name val provided = customScalars.getValue(name) val extensions = typeExtensions.extensionsOf(name) - val directives = directivesOf(definition, extensions) + val directives = sdlDirectives.directivesOf(definition, extensions) val builder = GraphQLScalarType.newScalar(provided) .definition(definition) .extensionDefinitions(extensions) @@ -306,7 +306,7 @@ class SchemaParser internal constructor( private fun createInterfaceObject(interfaceDefinition: InterfaceTypeDefinition, inputObjects: List): GraphQLInterfaceType { val extensions = typeExtensions.extensionsOf(interfaceDefinition.name) - val directives = directivesOf(interfaceDefinition, extensions) + val directives = sdlDirectives.directivesOf(interfaceDefinition, extensions) val fieldDefinitions = interfaceDefinition.withExtensions(typeExtensions).flatMap { it.fieldDefinitions } warnRepeated(interfaceDefinition.name, "field", fieldDefinitions.map { it.name }) val builder = GraphQLInterfaceType.newInterface() @@ -332,7 +332,7 @@ class SchemaParser internal constructor( private fun createUnionObject(definition: UnionTypeDefinition, types: List): GraphQLUnionType { val extensions = typeExtensions.extensionsOf(definition.name) - val directives = directivesOf(definition, extensions) + val directives = sdlDirectives.directivesOf(definition, extensions) warnRepeated(definition.name, "member", definition.withExtensions(typeExtensions).flatMap { it.memberTypes }.map { (it as TypeName).name }) val builder = GraphQLUnionType.newUnionType() .name(definition.name) @@ -382,25 +382,12 @@ class SchemaParser internal constructor( } } - private fun directivesOf(definition: TypeDefinition<*>, extensions: List>): List { - val applied = definition.directives.mapTo(mutableSetOf()) { it.name } - return definition.directives + extensions.flatMap { extension -> - val (repeated, kept) = extension.directives.partition { it.name in applied && isNonRepeatableByFirstDeclaration(it.name) } - repeated.forEach { log.warn("Directive @${it.name} isn't repeatable but an extension of type '${definition.name}' applies it again, only the first one is kept.") } - applied += kept.map { it.name } - kept - } - } - - private fun isNonRepeatableByFirstDeclaration(directiveName: String): Boolean = - directiveDefinitions.find { it.name == directiveName }?.let { !it.isRepeatable } ?: BUILT_IN_DIRECTIVES.containsKey(directiveName) - private fun createField(field: GraphQLFieldDefinition.Builder, fieldDefinition: FieldDefinition, inputObjects: List): GraphQLFieldDefinition.Builder { return field .name(fieldDefinition.name) .description(getDocumentation(fieldDefinition, options)) .definition(fieldDefinition) - .apply { getDeprecated(fieldDefinition.directives)?.let { deprecate(it) } } + .apply { sdlDirectives.getDeprecated(fieldDefinition.directives)?.let { deprecate(it) } } .type(determineOutputType(fieldDefinition.type)) .withSdlDirectives(fieldDefinition.directives, FIELD_DEFINITION) .apply { @@ -416,193 +403,14 @@ class SchemaParser internal constructor( .definition(definition) .description(getDocumentation(definition, options)) .type(determineInputType(definition.type, inputObjects, mutableSetOf())) - .apply { getDeprecated(definition.directives)?.let { deprecate(it) } } + .apply { sdlDirectives.getDeprecated(definition.directives)?.let { deprecate(it) } } .apply { definition.defaultValue?.let { defaultValueLiteral(it) } } .withSdlDirectives(definition.directives, ARGUMENT_DEFINITION) .build() } - private fun createDirectives() { - schemaDirectives = directiveDefinitions.associateTo(linkedMapOf()) { it.name to createDirective(it) } - // because the arguments can have directives too, we attach them only after the directives themselves are created - schemaDirectives.replaceAll { _, d -> - val arguments = d.arguments.map { a -> a.transform { - it.withSdlDirectives(a.definition!!.directives, ARGUMENT_DEFINITION) - } } - d.transform { it.replaceArguments(arguments) } - } - } - - private fun createDirective(definition: DirectiveDefinition): GraphQLDirective { - val locations = definition.directiveLocations.map { Introspection.DirectiveLocation.valueOf(it.name) }.toTypedArray() - - return GraphQLDirective.newDirective() - .name(definition.name) - .description(getDocumentation(definition, options)) - .definition(definition) - .comparatorRegistry(runtimeWiring.comparatorRegistry) - .validLocations(*locations) - .repeatable(definition.isRepeatable) - .apply { - definition.inputValueDefinitions.forEach { argumentDefinition -> - argument(createDirectiveArgument(argumentDefinition)) - } - } - .build() - } - - private fun createDirectiveArgument(definition: InputValueDefinition): GraphQLArgument { - return GraphQLArgument.newArgument() - .name(definition.name) - .definition(definition) - .description(getDocumentation(definition, options)) - // type references, so that an input object used by a directive applied to it doesn't contain itself - .type(determineType(GraphQLInputType::class, definition.type, permittedTypesForInputObject) as GraphQLInputType) - .apply { getDeprecated(definition.directives)?.let { deprecate(it) } } - .apply { definition.defaultValue?.let { defaultValueLiteral(it) } } - .build() - } - - @Suppress("DEPRECATION") - private fun > B.withSdlDirectives(directives: List, location: Introspection.DirectiveLocation): B { - if (directives.isEmpty()) return this - val resolved = resolveDirectives(directives, location) - val applied = appliedDirectives(resolved) - val legacy = legacyDirectives(resolved, location) - return withAppliedDirectives(*applied).withDirectives(*legacy) - } - - private fun resolveDirectives(directives: List, location: Introspection.DirectiveLocation): List { - // TODO throw a SchemaError instead in 15.0.0 (#884), like graphql-java does - directiveProblems(directives, location).forEach { log.warn(it) } - return directives.map { directive -> - val definition = findDirective(directive.name) - when { - definition != null -> ResolvedDirective(directive, declaredArguments(directive, definition), declared = true) - options.allowUndeclaredDirectives -> ResolvedDirective(directive, undeclaredArguments(directive), declared = false) - else -> throw SchemaError("Found applied directive ${directive.name} without corresponding directive definition.") - } - } - } - - // missing arguments get their defaults like in graphql-java, which gives a bare @deprecated the "reason" SchemaPrinter needs - private fun declaredArguments(directive: Directive, definition: GraphQLDirective): List { - val supplied = directive.arguments.map { arg -> - val argument = definition.getArgument(arg.name) - ?: throw SchemaError("Found an unexpected directive argument ${directive.name}#${arg.name} .") - ResolvedArgument(arg.name, argument.type, InputValueWithState.newLiteralValue(arg.value), legacyDescription = null) - } - val defaulted = missingArgumentsWithDefault(directive, definition).map { - ResolvedArgument(it.name, it.type, it.argumentDefaultValue, legacyDescription = it.description) - } - return supplied + defaulted - } - - private fun undeclaredArguments(directive: Directive): List = - directive.arguments.map { arg -> - ResolvedArgument(arg.name, guessDirectiveArgumentType(directive, arg.name, arg.value), InputValueWithState.newLiteralValue(arg.value), legacyDescription = null) - } - - private fun directiveProblems(directives: List, location: Introspection.DirectiveLocation): List = - directives.groupBy { it.name }.flatMap { (name, applied) -> - // undeclared directives have no definition to check against - val definition = findDirective(name) ?: return@flatMap emptyList() - buildList { - if (location !in definition.validLocations()) { - add("Directive @$name${applied.first().sourceLocation.describe()} is applied to $location, but it's declared on ${definition.validLocations().joinToString(" | ")}.") - } - if (definition.isNonRepeatable && applied.size > 1) { - add("Directive @$name${applied[1].sourceLocation.describe()} isn't repeatable but is applied more than once.") - } - applied.forEach { directive -> - definition.arguments - .filter { it.type is GraphQLNonNull && !it.hasSetDefaultValue() && directive.getArgument(it.name) == null } - .forEach { add("Directive @$name${directive.sourceLocation.describe()} is missing its required argument '${it.name}'.") } - } - } - } - - private fun findDirective(name: String): GraphQLDirective? = schemaDirectives[name] ?: BUILT_IN_DIRECTIVES[name] - - // there's no directive definition to look the type up in, so it's guessed from the value - private fun guessDirectiveArgumentType(directive: Directive, argumentName: String, value: Value<*>): GraphQLInputType { - return when (value) { - is StringValue, is NullValue -> Scalars.GraphQLString - is IntValue -> Scalars.GraphQLInt - is FloatValue -> Scalars.GraphQLFloat - is BooleanValue -> Scalars.GraphQLBoolean - is ArrayValue -> GraphQLList(value.values.firstOrNull()?.let { guessDirectiveArgumentType(directive, argumentName, it) } ?: Scalars.GraphQLString) - else -> throw SchemaError("Can't guess the type of argument ${directive.name}#$argumentName of undeclared directive ${directive.name}, please declare the directive.") - } - } - - // graphql-java's applied directives and their arguments have no description - private fun appliedDirectives(directives: List): Array = - directives.map { directive -> - GraphQLAppliedDirective.newDirective() - .name(directive.usage.name) - .definition(directive.usage) - .comparatorRegistry(runtimeWiring.comparatorRegistry) - .apply { - directive.arguments.forEach { arg -> - argument(GraphQLAppliedDirectiveArgument.newArgument() - .name(arg.name) - .type(arg.type) - .inputValueWithState(arg.value) - .build() - ) - } - } - .build() - }.toTypedArray() - - // TODO remove this once directives are fully replaced with applied directives - @Suppress("DEPRECATION") // legacy directive argument values - private fun legacyDirectives(directives: List, location: Introspection.DirectiveLocation): Array { - val seen = mutableSetOf() - return directives.mapNotNull { directive -> - // graphql-java rejects legacy directives without a definition, so undeclared ones are only kept as applied directives - if (!directive.declared) return@mapNotNull null - val name = directive.usage.name - val nonRepeatable = isNonRepeatableByFirstDeclaration(name) - // graphql-java rejects a non-repeatable legacy directive that is applied more than once - if (!seen.add(name) && nonRepeatable) return@mapNotNull null - GraphQLDirective.newDirective() - .name(name) - .description(getDocumentation(directive.usage, options)) - .comparatorRegistry(runtimeWiring.comparatorRegistry) - .validLocation(location) - .repeatable(!nonRepeatable) - .apply { - directive.arguments.forEach { arg -> - argument(GraphQLArgument.newArgument() - .name(arg.name) - .type(arg.type) - .description(arg.legacyDescription) - // there's no InputValueWithState setter. only a default can be programmatic, like @deprecated's reason - .apply { if (arg.value.isLiteral) valueLiteral(arg.value.value as Value<*>) else valueProgrammatic(arg.value.value) } - .build()) - } - } - .build() - }.toTypedArray() - } - - private class ResolvedDirective( - val usage: Directive, - val arguments: List, - val declared: Boolean - ) - - private class ResolvedArgument( - val name: String, - val type: GraphQLInputType, - val value: InputValueWithState, - val legacyDescription: String? - ) - - private fun missingArgumentsWithDefault(directive: Directive, graphQLDirective: GraphQLDirective): List = - graphQLDirective.arguments.filter { it.hasSetDefaultValue() && directive.getArgument(it.name) == null } + private fun > B.withSdlDirectives(directives: List, location: Introspection.DirectiveLocation): B = + with(sdlDirectives) { withSdlDirectives(directives, location) } private fun getSpecifiedByUrl(directives: List): String? = (directives.find { it.name == Directives.SpecifiedByDirective.name }?.getArgument("url")?.value as? StringValue)?.value @@ -649,34 +457,10 @@ class SchemaParser internal constructor( return createInputObject(definition, inputObjects, inputObjectsInProgress).also { (inputObjects as MutableList).add(it) } } - /** - * Returns an optional [String] describing a deprecated field/enum. - * If a deprecation directive was defined using the @deprecated directive, - * then a String containing either the contents of the 'reason' argument, if present, or a default - * message defined in [DEFAULT_DEPRECATION_MESSAGE] will be returned. Otherwise, `null` will be returned - * indicating no deprecation directive was found within the directives list. - */ - private fun getDeprecated(directives: List): String? = - directives.find { it.name == "deprecated" }?.let { directive -> - (directive.arguments.find { it.name == "reason" }?.value as? StringValue)?.value - ?: DEFAULT_DEPRECATION_MESSAGE - } } class SchemaError(message: String, cause: Throwable? = null) : RuntimeException(message, cause) val GRAPHQL_SCALARS = ScalarInfo.GRAPHQL_SPECIFICATION_SCALARS.associateBy { it.name } -// Built from the individual constants rather than Directives.BUILT_IN_DIRECTIVES_MAP (added in graphql-java 26) -// so that the library keeps working with graphql-java 25. TODO replace with Directives.BUILT_IN_DIRECTIVES_MAP once we deploy a major version -private val BUILT_IN_DIRECTIVES = listOf( - Directives.IncludeDirective, - Directives.SkipDirective, - Directives.DeprecatedDirective, - Directives.SpecifiedByDirective, - Directives.OneOfDirective, - Directives.DeferDirective, - Directives.ExperimentalDisableErrorPropagationDirective -).associateBy { it.name } - const val DEFAULT_DEPRECATION_MESSAGE = "No longer supported" diff --git a/src/main/kotlin/graphql/kickstart/tools/SdlDirectives.kt b/src/main/kotlin/graphql/kickstart/tools/SdlDirectives.kt new file mode 100644 index 00000000..6f0f6d7f --- /dev/null +++ b/src/main/kotlin/graphql/kickstart/tools/SdlDirectives.kt @@ -0,0 +1,252 @@ +package graphql.kickstart.tools + +import graphql.Directives +import graphql.Scalars +import graphql.introspection.Introspection +import graphql.introspection.Introspection.DirectiveLocation.ARGUMENT_DEFINITION +import graphql.introspection.Introspection.DirectiveLocation.SCHEMA +import graphql.kickstart.tools.util.describe +import graphql.kickstart.tools.util.getDocumentation +import graphql.language.* +import graphql.schema.* +import graphql.schema.idl.RuntimeWiring +import org.slf4j.Logger + +internal class SdlDirectives( + private val directiveDefinitions: List, + private val options: SchemaParserOptions, + private val runtimeWiring: RuntimeWiring, + private val log: Logger, + private val inputType: (Type<*>) -> GraphQLInputType +) { + // by name, in the order they're declared + private lateinit var schemaDirectives: MutableMap + + val definitions: Collection get() = schemaDirectives.values + + init { + createDirectives() + } + + fun directivesOf(definition: TypeDefinition<*>, extensions: List>): List { + val applied = definition.directives.mapTo(mutableSetOf()) { it.name } + return definition.directives + extensions.flatMap { extension -> + val (repeated, kept) = extension.directives.partition { it.name in applied && isNonRepeatableByFirstDeclaration(it.name) } + repeated.forEach { log.warn("Directive @${it.name} isn't repeatable but an extension of type '${definition.name}' applies it again, only the first one is kept.") } + applied += kept.map { it.name } + kept + } + } + + private fun isNonRepeatableByFirstDeclaration(directiveName: String): Boolean = + directiveDefinitions.find { it.name == directiveName }?.let { !it.isRepeatable } ?: BUILT_IN_DIRECTIVES.containsKey(directiveName) + + private fun createDirectives() { + schemaDirectives = directiveDefinitions.associateTo(linkedMapOf()) { it.name to createDirective(it) } + // because the arguments can have directives too, we attach them only after the directives themselves are created + schemaDirectives.replaceAll { _, d -> + val arguments = d.arguments.map { a -> a.transform { + it.withSdlDirectives(a.definition!!.directives, ARGUMENT_DEFINITION) + } } + d.transform { it.replaceArguments(arguments) } + } + } + + private fun createDirective(definition: DirectiveDefinition): GraphQLDirective { + val locations = definition.directiveLocations.map { Introspection.DirectiveLocation.valueOf(it.name) }.toTypedArray() + + return GraphQLDirective.newDirective() + .name(definition.name) + .description(getDocumentation(definition, options)) + .definition(definition) + .comparatorRegistry(runtimeWiring.comparatorRegistry) + .validLocations(*locations) + .repeatable(definition.isRepeatable) + .apply { + definition.inputValueDefinitions.forEach { argumentDefinition -> + argument(createDirectiveArgument(argumentDefinition)) + } + } + .build() + } + + private fun createDirectiveArgument(definition: InputValueDefinition): GraphQLArgument { + return GraphQLArgument.newArgument() + .name(definition.name) + .definition(definition) + .description(getDocumentation(definition, options)) + .type(inputType(definition.type)) + .apply { getDeprecated(definition.directives)?.let { deprecate(it) } } + .apply { definition.defaultValue?.let { defaultValueLiteral(it) } } + .build() + } + + @Suppress("DEPRECATION") + fun > B.withSdlDirectives(directives: List, location: Introspection.DirectiveLocation): B { + if (directives.isEmpty()) return this + val resolved = resolveDirectives(directives, location) + val applied = appliedDirectives(resolved) + val legacy = legacyDirectives(resolved, location) + return withAppliedDirectives(*applied).withDirectives(*legacy) + } + + fun schemaAppliedDirectives(directives: List): List = + appliedDirectives(resolveDirectives(directives, SCHEMA)).toList() + + private fun resolveDirectives(directives: List, location: Introspection.DirectiveLocation): List { + // TODO throw a SchemaError instead in 15.0.0 (#884), like graphql-java does + directiveProblems(directives, location).forEach { log.warn(it) } + return directives.map { directive -> + val definition = findDirective(directive.name) + when { + definition != null -> ResolvedDirective(directive, declaredArguments(directive, definition), declared = true) + options.allowUndeclaredDirectives -> ResolvedDirective(directive, undeclaredArguments(directive), declared = false) + else -> throw SchemaError("Found applied directive ${directive.name} without corresponding directive definition.") + } + } + } + + // missing arguments get their defaults like in graphql-java, which gives a bare @deprecated the "reason" SchemaPrinter needs + private fun declaredArguments(directive: Directive, definition: GraphQLDirective): List { + val supplied = directive.arguments.map { arg -> + val argument = definition.getArgument(arg.name) + ?: throw SchemaError("Found an unexpected directive argument ${directive.name}#${arg.name} .") + ResolvedArgument(arg.name, argument.type, InputValueWithState.newLiteralValue(arg.value), legacyDescription = null) + } + val defaulted = missingArgumentsWithDefault(directive, definition).map { + ResolvedArgument(it.name, it.type, it.argumentDefaultValue, legacyDescription = it.description) + } + return supplied + defaulted + } + + private fun undeclaredArguments(directive: Directive): List = + directive.arguments.map { arg -> + ResolvedArgument(arg.name, guessDirectiveArgumentType(directive, arg.name, arg.value), InputValueWithState.newLiteralValue(arg.value), legacyDescription = null) + } + + private fun directiveProblems(directives: List, location: Introspection.DirectiveLocation): List = + directives.groupBy { it.name }.flatMap { (name, applied) -> + // undeclared directives have no definition to check against + val definition = findDirective(name) ?: return@flatMap emptyList() + buildList { + if (location !in definition.validLocations()) { + add("Directive @$name${applied.first().sourceLocation.describe()} is applied to $location, but it's declared on ${definition.validLocations().joinToString(" | ")}.") + } + if (definition.isNonRepeatable && applied.size > 1) { + add("Directive @$name${applied[1].sourceLocation.describe()} isn't repeatable but is applied more than once.") + } + applied.forEach { directive -> + definition.arguments + .filter { it.type is GraphQLNonNull && !it.hasSetDefaultValue() && directive.getArgument(it.name) == null } + .forEach { add("Directive @$name${directive.sourceLocation.describe()} is missing its required argument '${it.name}'.") } + } + } + } + + private fun findDirective(name: String): GraphQLDirective? = schemaDirectives[name] ?: BUILT_IN_DIRECTIVES[name] + + // there's no directive definition to look the type up in, so it's guessed from the value + private fun guessDirectiveArgumentType(directive: Directive, argumentName: String, value: Value<*>): GraphQLInputType { + return when (value) { + is StringValue, is NullValue -> Scalars.GraphQLString + is IntValue -> Scalars.GraphQLInt + is FloatValue -> Scalars.GraphQLFloat + is BooleanValue -> Scalars.GraphQLBoolean + is ArrayValue -> GraphQLList(value.values.firstOrNull()?.let { guessDirectiveArgumentType(directive, argumentName, it) } ?: Scalars.GraphQLString) + else -> throw SchemaError("Can't guess the type of argument ${directive.name}#$argumentName of undeclared directive ${directive.name}, please declare the directive.") + } + } + + // graphql-java's applied directives and their arguments have no description + private fun appliedDirectives(directives: List): Array = + directives.map { directive -> + GraphQLAppliedDirective.newDirective() + .name(directive.usage.name) + .definition(directive.usage) + .comparatorRegistry(runtimeWiring.comparatorRegistry) + .apply { + directive.arguments.forEach { arg -> + argument(GraphQLAppliedDirectiveArgument.newArgument() + .name(arg.name) + .type(arg.type) + .inputValueWithState(arg.value) + .build() + ) + } + } + .build() + }.toTypedArray() + + // TODO remove this once directives are fully replaced with applied directives + @Suppress("DEPRECATION") // legacy directive argument values + private fun legacyDirectives(directives: List, location: Introspection.DirectiveLocation): Array { + val seen = mutableSetOf() + return directives.mapNotNull { directive -> + // graphql-java rejects legacy directives without a definition, so undeclared ones are only kept as applied directives + if (!directive.declared) return@mapNotNull null + val name = directive.usage.name + val nonRepeatable = isNonRepeatableByFirstDeclaration(name) + // graphql-java rejects a non-repeatable legacy directive that is applied more than once + if (!seen.add(name) && nonRepeatable) return@mapNotNull null + GraphQLDirective.newDirective() + .name(name) + .description(getDocumentation(directive.usage, options)) + .comparatorRegistry(runtimeWiring.comparatorRegistry) + .validLocation(location) + .repeatable(!nonRepeatable) + .apply { + directive.arguments.forEach { arg -> + argument(GraphQLArgument.newArgument() + .name(arg.name) + .type(arg.type) + .description(arg.legacyDescription) + // there's no InputValueWithState setter. only a default can be programmatic, like @deprecated's reason + .apply { if (arg.value.isLiteral) valueLiteral(arg.value.value as Value<*>) else valueProgrammatic(arg.value.value) } + .build()) + } + } + .build() + }.toTypedArray() + } + + private class ResolvedDirective( + val usage: Directive, + val arguments: List, + val declared: Boolean + ) + + private class ResolvedArgument( + val name: String, + val type: GraphQLInputType, + val value: InputValueWithState, + val legacyDescription: String? + ) + + private fun missingArgumentsWithDefault(directive: Directive, graphQLDirective: GraphQLDirective): List = + graphQLDirective.arguments.filter { it.hasSetDefaultValue() && directive.getArgument(it.name) == null } + + /** + * Returns an optional [String] describing a deprecated field/enum. + * If a deprecation directive was defined using the @deprecated directive, + * then a String containing either the contents of the 'reason' argument, if present, or a default + * message defined in [DEFAULT_DEPRECATION_MESSAGE] will be returned. Otherwise, `null` will be returned + * indicating no deprecation directive was found within the directives list. + */ + fun getDeprecated(directives: List): String? = + directives.find { it.name == "deprecated" }?.let { directive -> + (directive.arguments.find { it.name == "reason" }?.value as? StringValue)?.value + ?: DEFAULT_DEPRECATION_MESSAGE + } +} + +// Built from the individual constants rather than Directives.BUILT_IN_DIRECTIVES_MAP (added in graphql-java 26) +// so that the library keeps working with graphql-java 25. TODO replace with Directives.BUILT_IN_DIRECTIVES_MAP once we deploy a major version +private val BUILT_IN_DIRECTIVES = listOf( + Directives.IncludeDirective, + Directives.SkipDirective, + Directives.DeprecatedDirective, + Directives.SpecifiedByDirective, + Directives.OneOfDirective, + Directives.DeferDirective, + Directives.ExperimentalDisableErrorPropagationDirective +).associateBy { it.name }