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..d250b5c7 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 directiveEnumsWithoutClass = mutableSetOf() private val queue = linkedSetOf() private val fieldResolversByType = mutableMapOf>() @@ -171,15 +173,52 @@ 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 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)) { + return + } + + val initialEntry = initialDictionary[type.name] + if (initialEntry != null) { + handleFoundType(type, initialEntry.get(), DictionaryReference()) + } else { + unvalidatedTypes.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 } @@ -246,7 +285,14 @@ internal class SchemaClassScanner( val definitions = observedDefinitions + extensionDefinitions + inputExtensionDefinitions + directiveDefinitions - return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions) + // 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) } 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..07a607aa 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,295 @@ 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?) + + class ObjectHolder(val purpose: Any?) + + @Test + 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 + + type Query { hello: String } + + type ObjectHolder { purpose: Purpose } + """) + .resolvers(object : GraphQLQueryResolver { fun hello(): String = "hello" }) + .dictionary(ObjectHolder::class) + .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + .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 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() + } + + assertEquals(error.message, "Expected enum with name 'Purpose' but found none!") + } + + enum class Purpose { SECURITY, EXECUTION } }