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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,7 +19,8 @@ internal data class ScannedSchemaObjects(
val customScalars: CustomScalarMap,
val rootInfo: RootTypeInfo,
val fieldResolversByType: Map<ObjectTypeDefinition, MutableMap<FieldDefinition, FieldResolver>>,
val unusedDefinitions: Set<TypeDefinition<*>>
val unusedDefinitions: Set<TypeDefinition<*>>,
val directiveOnlyEnums: Set<EnumTypeDefinition>
)

internal typealias TypeClassDictionary = BiMap<TypeDefinition<*>, JavaType>
Expand Down
50 changes: 46 additions & 4 deletions src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<TypeDefinition<*>>): Set<String> {
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 }
Expand Down Expand Up @@ -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<EnumTypeDefinition>().filter { it.name !in typeNamesUsedByFields }.toSet()

return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions, directiveOnlyEnums)
}

private fun validateRootResolversWereUsed(rootType: RootType?, fieldResolvers: List<FieldResolver>) {
Expand Down
16 changes: 11 additions & 5 deletions src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<ObjectTypeExtensionDefinition>()
private val inputExtensionDefinitions = definitions.filterIsInstance<InputObjectTypeExtensionDefinition>()
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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.*

Expand Down Expand Up @@ -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<Any>("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<String>() }, 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, Any>): 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?)
}
Loading