From 1e916434167e62556fda1c522cfcbcaaebeb5215 Mon Sep 17 00:00:00 2001 From: Oryan Date: Mon, 12 Oct 2026 15:27:13 -0400 Subject: [PATCH 1/2] Build enums used only by directives from their value names An enum used only as a directive argument, such as Federation 2's @link(for: Purpose), failed the schema build with "Enum type 'Purpose' is used in a directive, but no class could be found" unless a Java enum was passed in the dictionary. That happened even when the directive was never applied, since the scanner checks the arguments of every directive definition, and createEnumObject could only build an enum from a class. Directive argument values are never passed to resolver methods, so such an enum doesn't need a class. When the scanner finds no class for it, the enum is now built with its value names as values, which is what graphql-java does without an EnumValuesProvider. Applied directives and SchemaDirectiveWiring implementations then get "SECURITY" from getValue() rather than an enum constant. Passing the enum class in the dictionary still gives enum constants. The decision is made once scanning is done, so a class found later, for example through an unused type with includeUnusedTypes, is still used. Enums that fields or arguments use keep their class as before, and an enum matched to String, Object or a Map still fails in the parser. Federation's bundled definitions still need their custom scalars, such as Import and FieldSet, passed to the parser. One case does change for resolvers. An enum that a directive uses and that is also used by an input object field without a Java property, e.g. an input bound to a Map, now builds with the value names, so the Map gets "SECURITY". Without the directive that schema still fails. A test pins this. Fixes #823 Co-Authored-By: Claude Opus 5.5 --- .../kickstart/tools/ScannedSchemaObjects.kt | 4 +- .../kickstart/tools/SchemaClassScanner.kt | 30 ++- .../graphql/kickstart/tools/SchemaParser.kt | 14 +- .../tools/SchemaClassScannerDirectiveTest.kt | 251 ++++++++++++++++++ 4 files changed, 290 insertions(+), 9 deletions(-) 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..e9027d1b 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -45,6 +45,8 @@ internal class SchemaClassScanner( private val typeClassMatcher = TypeClassMatcher(definitionsByName) private val dictionary = mutableMapOf, DictionaryEntry>() private val unvalidatedTypes = mutableSetOf>() + // enums that had no class when the directives using them were scanned, they're in unvalidatedTypes too + private val directiveOnlyEnums = mutableSetOf() private val queue = linkedSetOf() private val fieldResolversByType = mutableMapOf>() @@ -171,15 +173,31 @@ 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 are never passed to resolver methods, so an enum used by directives doesn't need a class. Unless + * one is found for it, it's built with its value names as values, like graphql-java does. + */ + private fun handleDirectiveEnum(type: EnumTypeDefinition) { + if (dictionary.containsKey(type) || unvalidatedTypes.contains(type)) { + return + } + + val initialEntry = initialDictionary[type.name] + if (initialEntry != null) { + handleFoundType(type, initialEntry.get(), DictionaryReference()) + } else { + unvalidatedTypes.add(type) + directiveOnlyEnums.add(type) + } + } + private fun validateAndCreateResult(rootTypeHolder: RootTypesHolder): ScannedSchemaObjects { initialDictionary .filter { !it.value.accessed } @@ -246,7 +264,11 @@ internal class SchemaClassScanner( val definitions = observedDefinitions + extensionDefinitions + inputExtensionDefinitions + directiveDefinitions - return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions) + // a class found for an enum after its directives were scanned, e.g. through an unused type, is used after all. This checks the + // scanner's dictionary rather than the bimap, so an enum matched to Object or a Map is still rejected by the parser + val directiveOnlyEnums = directiveOnlyEnums.filter { it !in this.dictionary }.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..b0dd26c1 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 + // enums only used by directives can do without a class, see SchemaClassScanner.handleDirectiveEnum 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!") + if (type == null && definition !in directiveOnlyEnums) throw SchemaError("Expected enum with name '$name' but found none!") + if (type != null && !type.unwrap().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 (type == null) { + enumName + } else { + type.unwrap().enumConstants.find { (it as Enum<*>).name == enumName } + ?: throw SchemaError("Expected value for name '$enumName' in enum '${type.unwrap().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..130d0329 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,250 @@ 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( + """ + enum Purpose { SECURITY EXECUTION } + + directive @link(url: String!, for: Purpose) repeatable on SCHEMA + + type Query { hello: String } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(): String = "hello" }) + .build() + val schema = parser.makeExecutableSchema() + + val purpose = schema.getType("Purpose") as GraphQLEnumType + assertEquals(purpose.values.map { it.name }, listOf("SECURITY", "EXECUTION")) + 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 + directive @purpose(for: Purpose!) on FIELD_DEFINITION + + 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 @purpose(for: SECURITY) + } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(): String = "hello" }) + .build() + .makeExecutableSchema() + + assertEquals(schema.getSchemaAppliedDirectives("link").map { it.getArgument("for")?.getValue() }, listOf("EXECUTION", "SECURITY")) + assertEquals(schema.queryType.getField("hello").getAppliedDirective("purpose").getArgument("for")?.getValue(), "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 builds the federation link directive without a class for its enum`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @link( + url: String!, + as: String, + import: [Import], + for: Purpose) + repeatable on SCHEMA + + scalar Import + + enum Purpose { + SECURITY + EXECUTION + } + + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) + + type Query { hello: String } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(): String = "hello" }) + .scalars(importScalar) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Purpose") as GraphQLEnumType).values.map { it.value }, listOf("SECURITY", "EXECUTION")) + assertEquals(schema.getSchemaAppliedDirective("link").getArgument("import")?.getValue>(), listOf("@key")) + } + + private val importScalar: GraphQLScalarType = GraphQLScalarType.newScalar() + .name("Import") + .coercing(object : Coercing { + override fun serialize(input: Any, context: GraphQLContext, locale: Locale) = input.toString() + override fun parseValue(input: Any, context: GraphQLContext, locale: Locale) = input.toString() + override fun parseLiteral(input: Value<*>, variables: CoercedVariables, context: GraphQLContext, locale: Locale) = + (input as StringValue).value + }) + .build() + + @Test + fun `scanner uses the class of enums used by both directives and resolvers`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + enum Purpose { SECURITY EXECUTION } + + directive @purpose(for: Purpose!) on FIELD_DEFINITION + + type Query { + hello(purpose: Purpose!): String @purpose(for: SECURITY) + } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(purpose: Purpose): String = purpose.name }) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Purpose") as GraphQLEnumType).values.map { it.value }, Purpose.entries) + assertEquals(schema.queryType.getField("hello").getAppliedDirective("purpose").getArgument("for")?.getValue(), Purpose.SECURITY) + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + hello(purpose: EXECUTION) + } + """ + } + + assertEquals(data["hello"], "EXECUTION") + } + + @Test + fun `scanner requires a Java enum for enums used by directives and resolver parameters of another class`() { + val error = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + enum Purpose { SECURITY EXECUTION } + + directive @purpose(for: Purpose!) on FIELD_DEFINITION + + type Query { + hello(purpose: Purpose!): String + } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(purpose: String): String = purpose }) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Type 'Purpose' is declared as an enum in the GraphQL schema but is not a Java enum!") + } + + @Test + fun `scanner requires a Java enum for enums used by directives and resolver parameters of type Object`() { + val error = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + enum Purpose { SECURITY EXECUTION } + + directive @purpose(for: Purpose!) on FIELD_DEFINITION + + type Query { + hello(purpose: Purpose!): String + } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(purpose: Any): String = purpose.toString() }) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Expected enum with name 'Purpose' but found none!") + } + + @Test + fun `scanner uses the dictionary class of enums used only by directives`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + enum Purpose { SECURITY EXECUTION } + + directive @link(url: String!, for: Purpose) repeatable on SCHEMA + + type Query { hello: String } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(): String = "hello" }) + .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( + """ + enum Purpose { SECURITY EXECUTION } + + directive @link(url: String!, for: Purpose) repeatable on SCHEMA + + type Query { hello: String } + + type Holder { purpose: Purpose } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(): String = "hello" }) + .dictionary(Holder::class) + .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Purpose") as GraphQLEnumType).values.map { it.value }, Purpose.entries) + } + + class Holder(val purpose: Purpose?) + + @Test + fun `scanner builds enums used by directives and by input fields without a property from their names`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + enum Purpose { SECURITY EXECUTION } + + directive @link(url: String!, for: Purpose) repeatable on SCHEMA + + input Filter { purpose: Purpose } + + type Query { + hello(filter: Filter!): String + } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(filter: Map): String = filter["purpose"] as String }) + .build() + .makeExecutableSchema() + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + hello(filter: { purpose: SECURITY }) + } + """ + } + + // a Map has no property for the field to find the enum's class through, so the resolver gets the value's name + assertEquals(data["hello"], "SECURITY") + } + + enum class Purpose { SECURITY, EXECUTION } } From a07f4bc0cecdd084205a0b837d216ed6d49ddd59 Mon Sep 17 00:00:00 2001 From: Oryan Date: Mon, 12 Oct 2026 15:36:05 -0400 Subject: [PATCH 2/2] Only build enums from their value names when no field uses them The previous commit built an enum from its value names whenever a directive used it and the scanner found no class for it. That also covered enums whose values do reach user code without going through a resolver parameter the scanner can match: input fields with no Java property, such as an input bound to a Map, and arguments of unimplemented fields served by a missing resolver data fetcher. Those schemas fail without the directive, but declaring an unrelated directive made them build, and the Map or the data fetcher got "SECURITY" instead of an enum constant. Now an enum is only built from its value names if no field, argument or input field of a type in the schema uses it, including fields added by extensions and types found later through includeUnusedTypes. Otherwise it needs a class as before, and the parser fails with "Expected enum with name 'Purpose' but found none!". This replaces the check against the scanner's dictionary, since any enum the scanner matched to a class was matched through such a field. The test that pinned the Map-bound input case now expects the error, and new tests cover an unimplemented field's argument added by an extension and an unused type's Object property. Co-Authored-By: Claude Opus 5.5 --- .../kickstart/tools/SchemaClassScanner.kt | 38 ++++++-- .../tools/SchemaClassScannerDirectiveTest.kt | 89 ++++++++++++++----- 2 files changed, 98 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index e9027d1b..d250b5c7 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -46,7 +46,7 @@ internal class SchemaClassScanner( private val dictionary = mutableMapOf, DictionaryEntry>() private val unvalidatedTypes = mutableSetOf>() // enums that had no class when the directives using them were scanned, they're in unvalidatedTypes too - private val directiveOnlyEnums = mutableSetOf() + private val directiveEnumsWithoutClass = mutableSetOf() private val queue = linkedSetOf() private val fieldResolversByType = mutableMapOf>() @@ -181,8 +181,9 @@ internal class SchemaClassScanner( } /** - * Directive argument values are never passed to resolver methods, so an enum used by directives doesn't need a class. Unless - * one is found for it, it's built with its value names as values, like graphql-java does. + * Directive argument values are never passed to resolver methods, so an enum that only directives use doesn't need a class. + * If no field, argument or input field uses it either, it's built with its value names as values, like graphql-java does, + * see [validateAndCreateResult]. */ private fun handleDirectiveEnum(type: EnumTypeDefinition) { if (dictionary.containsKey(type) || unvalidatedTypes.contains(type)) { @@ -194,10 +195,30 @@ internal class SchemaClassScanner( handleFoundType(type, initialEntry.get(), DictionaryReference()) } else { unvalidatedTypes.add(type) - directiveOnlyEnums.add(type) + directiveEnumsWithoutClass.add(type) } } + /** + * The names of the types that the fields, arguments and input fields of [definitions] use, including those 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 -> + listOf(field.type) + field.inputValueDefinitions.map { it.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 } @@ -264,9 +285,12 @@ internal class SchemaClassScanner( val definitions = observedDefinitions + extensionDefinitions + inputExtensionDefinitions + directiveDefinitions - // a class found for an enum after its directives were scanned, e.g. through an unused type, is used after all. This checks the - // scanner's dictionary rather than the bimap, so an enum matched to Object or a Map is still rejected by the parser - val directiveOnlyEnums = directiveOnlyEnums.filter { it !in this.dictionary }.toSet() + // Only enums that no field, argument or input field uses can do without a class. Any such use needs one, even where the + // scanner has no parameter or property to find it through, since the values then reach user code, e.g. through a + // Map-bound input or an unimplemented field's argument. Types found after the directives were scanned, e.g. unused types, + // count too: a class found through them is used, and an enum matched to Object or a Map is rejected by the parser. + val typeNamesUsedByFields = typeNamesUsedByFields(observedDefinitions) + val directiveOnlyEnums = directiveEnumsWithoutClass.filter { it.name !in typeNamesUsedByFields }.toSet() return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions, directiveOnlyEnums) } diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerDirectiveTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerDirectiveTest.kt index 130d0329..07a607aa 100644 --- a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerDirectiveTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerDirectiveTest.kt @@ -352,35 +352,80 @@ class SchemaClassScannerDirectiveTest { class Holder(val purpose: Purpose?) + class ObjectHolder(val purpose: Any?) + @Test - fun `scanner builds enums used by directives and by input fields without a property from their names`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - enum Purpose { SECURITY EXECUTION } + fun `scanner requires a Java enum for enums used by directives and properties of type Object of unused types`() { + val error = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + enum Purpose { SECURITY EXECUTION } - directive @link(url: String!, for: Purpose) repeatable on SCHEMA + directive @link(url: String!, for: Purpose) repeatable on SCHEMA - input Filter { purpose: Purpose } + type Query { hello: String } - type Query { - hello(filter: Filter!): String - } - """) - .resolvers(object : GraphQLQueryResolver { fun hello(filter: Map): String = filter["purpose"] as String }) - .build() - .makeExecutableSchema() + type ObjectHolder { purpose: Purpose } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(): String = "hello" }) + .dictionary(ObjectHolder::class) + .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + .build() + .makeExecutableSchema() + } - val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { - """ - query { - hello(filter: { purpose: SECURITY }) - } - """ + assertEquals(error.message, "Expected enum with name 'Purpose' but found none!") + } + + @Test + fun `scanner requires a class for enums used by directives and by input fields without a property`() { + // the resolver would get the enum's values through the Map, so the directive doesn't make the class optional + val error = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + enum Purpose { SECURITY EXECUTION } + + directive @link(url: String!, for: Purpose) repeatable on SCHEMA + + input Filter { purpose: Purpose } + + type Query { + hello(filter: Filter!): String + } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(filter: Map): String = filter.toString() }) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Expected enum with name 'Purpose' but found none!") + } + + @Test + fun `scanner requires a class for enums used by directives and by arguments of unimplemented fields in extensions`() { + val error = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + enum Purpose { SECURITY EXECUTION } + + directive @link(url: String!, for: Purpose) repeatable on SCHEMA + + type Query { hello: String } + + extend type Query { + unimplemented(purpose: Purpose): String + } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(): String = "hello" }) + .options(SchemaParserOptions.newOptions().allowUnimplementedResolvers(true).build()) + .build() + .makeExecutableSchema() } - // a Map has no property for the field to find the enum's class through, so the resolver gets the value's name - assertEquals(data["hello"], "SECURITY") + assertEquals(error.message, "Expected enum with name 'Purpose' but found none!") } enum class Purpose { SECURITY, EXECUTION }