diff --git a/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt b/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt index 6874504f..129e7023 100644 --- a/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt +++ b/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt @@ -3,6 +3,7 @@ package graphql.kickstart.tools import graphql.kickstart.tools.resolver.FieldResolver import graphql.kickstart.tools.util.BiMap import graphql.kickstart.tools.util.JavaType +import graphql.language.EnumTypeDefinition import graphql.language.FieldDefinition import graphql.language.ObjectTypeDefinition import graphql.language.SDLNamedDefinition @@ -18,7 +19,8 @@ internal data class ScannedSchemaObjects( val customScalars: CustomScalarMap, val rootInfo: RootTypeInfo, val fieldResolversByType: Map>, - val unusedDefinitions: Set> + val unusedDefinitions: Set>, + val directiveOnlyEnums: Set ) internal typealias TypeClassDictionary = BiMap, JavaType> diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 8844ba6b..229bac9c 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -171,15 +171,51 @@ internal class SchemaClassScanner( ?: error("No ${TypeDefinition::class.java.simpleName} for type name $inputTypeName") when (typeDefinition) { is ScalarTypeDefinition -> handleFoundScalarType(typeDefinition) - is EnumTypeDefinition -> handleDictionaryTypes(listOf(typeDefinition)) { - "Enum type '${it.name}' is used in a directive, but no class could be found for that type name. Please pass a class for type '${it.name}' in the parser's dictionary." - } + is EnumTypeDefinition -> handleDirectiveEnum(typeDefinition) is InputObjectTypeDefinition -> handleDictionaryTypes(listOf(typeDefinition)) { "Input object type '${it.name}' is used in a directive, but no class could be found for that type name. Please pass a class for type '${it.name}' in the parser's dictionary." } } } + /** + * Directive argument values never reach resolver methods, so an enum used in a directive doesn't need a class. Without one + * it goes into [unvalidatedTypes] and, unless a field uses it too, is built with its value names as values, see + * [validateAndCreateResult]. + */ + private fun handleDirectiveEnum(type: EnumTypeDefinition) { + if (dictionary.containsKey(type)) { + return + } + + val initialEntry = initialDictionary[type.name] + if (initialEntry != null) { + handleFoundType(type, initialEntry.get(), DictionaryReference()) + } else { + unvalidatedTypes.add(type) + } + } + + /** + * The names of the types used by the fields, arguments and input fields of [definitions], including fields added by extensions. + */ + private fun typeNamesUsedByFields(definitions: Set>): Set { + val names = definitions.map { it.name }.toSet() + return (definitions + extensionDefinitions + inputExtensionDefinitions) + .filter { it.name in names } + .flatMap { definition -> + when (definition) { + is ImplementingTypeDefinition<*> -> definition.fieldDefinitions.flatMap { field -> + field.inputValueDefinitions.map { it.type } + field.type + } + is InputObjectTypeDefinition -> definition.inputValueDefinitions.map { it.type } + else -> emptyList() + } + } + .mapNotNull { (it.unwrap() as TypeName).name } + .toSet() + } + private fun validateAndCreateResult(rootTypeHolder: RootTypesHolder): ScannedSchemaObjects { initialDictionary .filter { !it.value.accessed } @@ -246,7 +282,13 @@ internal class SchemaClassScanner( val definitions = observedDefinitions + extensionDefinitions + inputExtensionDefinitions + directiveDefinitions - return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions) + // The enums in unvalidatedTypes come from directives without a class, so they can only be built when no field uses them. + // A field hands the enum's values to user code even when the scanner found no class through it, e.g. an input field bound + // to a Map or an unimplemented field's argument. + val typeNamesUsedByFields = typeNamesUsedByFields(observedDefinitions) + val directiveOnlyEnums = unvalidatedTypes.filterIsInstance().filter { it.name !in typeNamesUsedByFields }.toSet() + + return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions, directiveOnlyEnums) } private fun validateRootResolversWereUsed(rootType: RootType?, fieldResolvers: List) { diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 7417e54c..d2bbc5af 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -39,6 +39,7 @@ class SchemaParser internal constructor( private val rootInfo = scanResult.rootInfo private val fieldResolversByType = scanResult.fieldResolversByType private val unusedDefinitions = scanResult.unusedDefinitions + private val directiveOnlyEnums = scanResult.directiveOnlyEnums private val extensionDefinitions = definitions.filterIsInstance() private val inputExtensionDefinitions = definitions.filterIsInstance() @@ -195,9 +196,10 @@ class SchemaParser internal constructor( private fun createEnumObject(definition: EnumTypeDefinition): GraphQLEnumType { val name = definition.name - val type = dictionary[definition] - ?: throw SchemaError("Expected enum with name '$name' but found none!") - if (!type.unwrap().isEnum) throw SchemaError("Type '$name' is declared as an enum in the GraphQL schema but is not a Java enum!") + // enums only used by directives can do without a class, see SchemaClassScanner.handleDirectiveEnum + val enumClass = dictionary[definition]?.unwrap() + if (enumClass == null && definition !in directiveOnlyEnums) throw SchemaError("Expected enum with name '$name' but found none!") + 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 builder = GraphQLEnumType.newEnum() .name(name) @@ -208,8 +210,12 @@ class SchemaParser internal constructor( .apply { definition.enumValueDefinitions.forEach { valueDefinition -> val enumName = valueDefinition.name - val enumValue = type.unwrap().enumConstants.find { (it as Enum<*>).name == enumName } - ?: throw SchemaError("Expected value for name '$enumName' in enum '${type.unwrap().simpleName}' but found none!") + val enumValue = if (enumClass == null) { + enumName + } else { + enumClass.enumConstants.find { (it as Enum<*>).name == enumName } + ?: throw SchemaError("Expected value for name '$enumName' in enum '${enumClass.simpleName}' but found none!") + } value( GraphQLEnumValueDefinition.newEnumValueDefinition() diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerDirectiveTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerDirectiveTest.kt index f41e8e60..f609d880 100644 --- a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerDirectiveTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerDirectiveTest.kt @@ -1,12 +1,17 @@ package graphql.kickstart.tools +import graphql.GraphQL import graphql.GraphQLContext import graphql.execution.CoercedVariables +import graphql.introspection.IntrospectionQuery import graphql.kickstart.tools.SchemaClassScannerDirectiveTest.CustomEnum.ONE import graphql.language.StringValue import graphql.language.Value import graphql.schema.Coercing +import graphql.schema.GraphQLEnumType import graphql.schema.GraphQLScalarType +import graphql.schema.idl.SchemaPrinter +import org.junit.Assert.assertThrows import org.junit.Test import java.util.* @@ -133,4 +138,161 @@ class SchemaClassScannerDirectiveTest { assertNotNull(schema.directivesByName["doSomething"]?.getArgument("one")?.directivesByName?.get("somethingElse")) assertNotNull(schema.directivesByName["somethingElse"]?.getArgument("two")?.directivesByName?.get("doSomething")) } + + @Test + fun `scanner builds enums used only by directives from their value names`() { + val parser = SchemaParser.newParser() + .schemaString(linkDirective) + .schemaString("type Query { hello: String }") + .resolvers(helloResolver) + .build() + val schema = parser.makeExecutableSchema() + + val purpose = schema.getType("Purpose") as GraphQLEnumType + assertEquals(purpose.values.map { it.value }, listOf("SECURITY", "EXECUTION")) + assertEquals(schema.getDirective("link")?.getArgument("for")?.type, purpose) + assertEquals(parser.getUnusedDefinitions(), emptySet()) + } + + @Test + fun `scanner builds applied directives with enums used only by directives`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + enum Purpose { SECURITY EXECUTION } + + directive @link(url: String!, for: Purpose = EXECUTION) repeatable on SCHEMA + + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0") + @link(url: "https://specs.apollo.dev/link/v1.0", for: SECURITY) + + type Query { hello: String } + """) + .resolvers(helloResolver) + .build() + .makeExecutableSchema() + + assertEquals(schema.getSchemaAppliedDirectives("link").map { it.getArgument("for")?.getValue() }, listOf("EXECUTION", "SECURITY")) + assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { IntrospectionQuery.INTROSPECTION_QUERY } + + val printed = SchemaPrinter(SchemaPrinter.Options.defaultOptions().includeSchemaDefinition(true)).print(schema) + assert(printed.contains("directive @link(for: Purpose = EXECUTION, url: String!) repeatable on SCHEMA")) { printed } + assert(printed.contains("""@link(for : SECURITY, url : "https://specs.apollo.dev/link/v1.0")""")) { printed } + } + + @Test + fun `scanner uses the dictionary class of enums used only by directives`() { + val schema = SchemaParser.newParser() + .schemaString(linkDirective) + .schemaString("type Query { hello: String }") + .resolvers(helloResolver) + .dictionary(Purpose::class) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Purpose") as GraphQLEnumType).values.map { it.value }, Purpose.entries) + } + + @Test + fun `scanner uses enum classes found through unused types for enums used by directives when option is true`() { + val schema = SchemaParser.newParser() + .schemaString(linkDirective) + .schemaString( + """ + type Query { hello: String } + + type Holder { purpose: Purpose } + """) + .resolvers(helloResolver) + .dictionary(Holder::class) + .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Purpose") as GraphQLEnumType).values.map { it.value }, Purpose.entries) + } + + @Test + fun `scanner requires a Java enum for enums used by directives and by fields`() { + val missingEnum = "Expected enum with name 'Purpose' but found none!" + + // a resolver parameter of another class + assertSchemaError( + SchemaParser.newParser() + .schemaString(linkDirective) + .schemaString("type Query { hello(purpose: Purpose!): String }") + .resolvers(object : GraphQLQueryResolver { fun hello(purpose: String): String = purpose }), + "Type 'Purpose' is declared as an enum in the GraphQL schema but is not a Java enum!") + + // a resolver parameter of type Object + assertSchemaError( + SchemaParser.newParser() + .schemaString(linkDirective) + .schemaString("type Query { hello(purpose: Purpose!): String }") + .resolvers(object : GraphQLQueryResolver { fun hello(purpose: Any): String = purpose.toString() }), + missingEnum) + + // a property of type Object on an unused type + assertSchemaError( + SchemaParser.newParser() + .schemaString(linkDirective) + .schemaString( + """ + type Query { hello: String } + + type ObjectHolder { purpose: Purpose } + """) + .resolvers(helloResolver) + .dictionary(ObjectHolder::class) + .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()), + missingEnum) + + // an input field bound to a Map, which would get the enum's values + assertSchemaError( + SchemaParser.newParser() + .schemaString(linkDirective) + .schemaString( + """ + input Filter { purpose: Purpose } + + type Query { hello(filter: Filter!): String } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(filter: Map): String = filter.toString() }), + missingEnum) + + // an argument of an unimplemented field added by an extension + assertSchemaError( + SchemaParser.newParser() + .schemaString(linkDirective) + .schemaString( + """ + type Query { hello: String } + + extend type Query { unimplemented(purpose: Purpose): String } + """) + .resolvers(helloResolver) + .options(SchemaParserOptions.newOptions().allowUnimplementedResolvers(true).build()), + missingEnum) + } + + private fun assertSchemaError(parser: SchemaParserBuilder, message: String) { + val error = assertThrows(SchemaError::class.java) { parser.build().makeExecutableSchema() } + assertEquals(error.message, message) + } + + // the enum and directive from issue #823 + private val linkDirective = """ + enum Purpose { SECURITY EXECUTION } + + directive @link(url: String!, for: Purpose) repeatable on SCHEMA + """ + + private val helloResolver = object : GraphQLQueryResolver { fun hello(): String = "hello" } + + enum class Purpose { SECURITY, EXECUTION } + + class Holder(val purpose: Purpose?) + + class ObjectHolder(val purpose: Any?) }