diff --git a/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt b/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt index 6874504f..208d7f8c 100644 --- a/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt +++ b/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt @@ -15,6 +15,7 @@ import graphql.schema.GraphQLScalarType internal data class ScannedSchemaObjects( val dictionary: TypeClassDictionary, val definitions: Set>, + val typeExtensions: TypeExtensions, val customScalars: CustomScalarMap, val rootInfo: RootTypeInfo, val fieldResolversByType: Map>, @@ -23,3 +24,5 @@ internal data class ScannedSchemaObjects( internal typealias TypeClassDictionary = BiMap, JavaType> internal typealias CustomScalarMap = Map +// the extensions of each type by type name, in the order they're declared +internal typealias TypeExtensions = Map>> diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 8844ba6b..a44c64d8 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -36,10 +36,21 @@ internal class SchemaClassScanner( private val directiveDefinitions = allDefinitions.filterIsInstance() private val scalarDefinitions = allDefinitions.filterIsInstance() - private val definitionsByName = (allDefinitions.filterIsInstance>() - extensionDefinitions - inputExtensionDefinitions).associateBy { it.name } + private val typeDefinitions = allDefinitions.filterIsInstance>() + private val baseTypeNames = typeDefinitions.filter { it !is SDLExtensionDefinition }.map { it.name }.toSet() + + // The spec doesn't allow extending a type that isn't defined. Union, enum, interface and scalar extensions are subclasses of + // their definitions though, so one without a definition used to be built as the type itself. To keep those schemas working the + // first one still is, and the others extend it. Object and input extensions without a definition are still ignored. + private val loneExtensions = typeDefinitions + .filter { it is SDLExtensionDefinition && it !is ObjectTypeDefinition && it !is InputObjectTypeDefinition && it.name !in baseTypeNames } + .distinctBy { it.name } + + private val definitionsByName = typeDefinitions.filter { it !is SDLExtensionDefinition || it in loneExtensions }.associateBy { it.name } + private val typeExtensions: TypeExtensions = typeDefinitions.filter { it is SDLExtensionDefinition && it !in loneExtensions }.groupBy { it.name } private val objectDefinitions = (allDefinitions.filterIsInstance() - extensionDefinitions) private val objectDefinitionsByName = objectDefinitions.associateBy { it.name } - private val interfaceDefinitionsByName = allDefinitions.filterIsInstance().associateBy { it.name } + private val interfaceDefinitionsByName = definitionsByName.values.filterIsInstance().associateBy { it.name } private val fieldResolverScanner = FieldResolverScanner(options) private val typeClassMatcher = TypeClassMatcher(definitionsByName) @@ -56,6 +67,13 @@ internal class SchemaClassScanner( } } + typeExtensions.values.flatten().forEach { extension -> + val definition = definitionsByName[extension.name] + if (definition != null && definition.kind() != extension.kind()) { + throw SchemaClassScannerError("Type '${extension.name}' is defined as ${definition.kind()}, but extended as ${extension.kind()}.") + } + } + if (options.allowUnimplementedResolvers) { log.warn("Option 'allowUnimplementedResolvers' should only be set to true during development, as it can cause schema errors to be moved to query time instead of schema creation time. Make sure this is turned off in production.") } @@ -222,6 +240,7 @@ internal class SchemaClassScanner( .description(getDocumentation(definition, options) ?: provided.description) .coercing(provided.coercing) .definition(definition) + .extensionDefinitions(typeExtensions.extensionsOf(definition.name)) .build() }.associateBy { it.name } @@ -246,7 +265,7 @@ internal class SchemaClassScanner( val definitions = observedDefinitions + extensionDefinitions + inputExtensionDefinitions + directiveDefinitions - return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions) + return ScannedSchemaObjects(dictionary, definitions, typeExtensions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions) } private fun validateRootResolversWereUsed(rootType: RootType?, fieldResolvers: List) { @@ -264,16 +283,18 @@ internal class SchemaClassScanner( private fun getAllObjectTypesImplementingDiscoveredInterfaces(): List { return dictionary.keys.filterIsInstance().map { iface -> - objectDefinitions.filter { obj -> obj.implements.filterIsInstance().any { it.name == iface.name } } + objectDefinitions.filter { obj -> obj.getExtendedImplements().filterIsInstance().any { it.name == iface.name } } }.flatten().distinctBy { it.name } } private fun getAllObjectTypeMembersOfDiscoveredUnions(): List { val unionTypeNames = dictionary.keys.filterIsInstance().map { union -> union.name }.toSet() return dictionary.keys.filterIsInstance().map { union -> - union.memberTypes.filterIsInstance().filter { !unionTypeNames.contains(it.name) }.map { - objectDefinitionsByName[it.name] - ?: throw SchemaClassScannerError("No object type found with name '${it.name}' for union: $union") + (listOf(union) + typeExtensions.extensionsOf(union.name)).flatMap { definition -> + definition.memberTypes.filterIsInstance().filter { !unionTypeNames.contains(it.name) }.map { + objectDefinitionsByName[it.name] + ?: throw SchemaClassScannerError("No object type found with name '${it.name}' for union: $definition") + } } }.flatten().distinct() } @@ -284,7 +305,8 @@ internal class SchemaClassScanner( */ private fun handleInterfaceFieldTypes(): Boolean { val newTypes = dictionary.keys.filterIsInstance().flatMap { iface -> - iface.fieldDefinitions.mapNotNull { field -> + val fieldDefinitions = iface.fieldDefinitions + typeExtensions.extensionsOf(iface.name).flatMap { it.fieldDefinitions } + fieldDefinitions.mapNotNull { field -> val type = definitionsByName[(field.type.unwrap() as TypeName).name] if ((type is UnionTypeDefinition || type is InterfaceTypeDefinition) && !dictionary.containsKey(type)) { type to FieldTypeReference("${iface.name}.${field.name}") @@ -441,7 +463,7 @@ internal class SchemaClassScanner( } private fun scanInterfacesOfType(graphQLType: ObjectTypeDefinition) { - graphQLType.implements.forEach { + graphQLType.getExtendedImplements().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)) @@ -449,6 +471,19 @@ internal class SchemaClassScanner( } } + private fun ObjectTypeDefinition.getExtendedImplements(): List> = + implements + typeExtensions.extensionsOf(name).flatMap { it.implements } + + private fun TypeDefinition<*>.kind(): String = when (this) { + is ObjectTypeDefinition -> "an object type" + is InterfaceTypeDefinition -> "an interface" + is UnionTypeDefinition -> "a union" + is EnumTypeDefinition -> "an enum" + is InputObjectTypeDefinition -> "an input object type" + is ScalarTypeDefinition -> "a scalar" + else -> javaClass.simpleName + } + private fun enqueue(graphQLType: ObjectTypeDefinition, javaType: JavaType) { queue.add(QueueItem(graphQLType, javaType)) } diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 7417e54c..4bb9dd2b 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -5,6 +5,7 @@ import graphql.Scalars import graphql.introspection.Introspection import graphql.introspection.Introspection.DirectiveLocation.INPUT_FIELD_DEFINITION import graphql.kickstart.tools.directive.DirectiveWiringHelper +import graphql.kickstart.tools.util.extensionsOf import graphql.kickstart.tools.util.getDocumentation import graphql.kickstart.tools.util.getExtendedFieldDefinitions import graphql.kickstart.tools.util.unwrap @@ -35,6 +36,7 @@ class SchemaParser internal constructor( private val dictionary = scanResult.dictionary private val definitions = scanResult.definitions + private val typeExtensions = scanResult.typeExtensions private val customScalars = scanResult.customScalars private val rootInfo = scanResult.rootInfo private val fieldResolversByType = scanResult.fieldResolversByType @@ -124,21 +126,24 @@ 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 builder = GraphQLObjectType.newObject() .name(name) .definition(objectDefinition) + .extensionDefinitions(extensions) .description(getDocumentation(objectDefinition, options)) - .withAppliedDirectives(*buildAppliedDirectives(objectDefinition.directives)) - .withDirectives(*buildDirectives(objectDefinition.directives, Introspection.DirectiveLocation.OBJECT)) + .withAppliedDirectives(*buildAppliedDirectives(directives)) + .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.OBJECT)) .apply { - objectDefinition.implements.forEach { implementsDefinition -> + (objectDefinition.implements + extensions.flatMap { it.implements }).forEach { implementsDefinition -> val interfaceName = (implementsDefinition as TypeName).name withInterface(interfaces.find { it.name == interfaceName } ?: throw SchemaError("Expected interface type with name '$interfaceName' but found none!")) } } .apply { - objectDefinition.getExtendedFieldDefinitions(extensionDefinitions).forEach { fieldDefinition -> + objectDefinition.getExtendedFieldDefinitions(extensions).forEach { fieldDefinition -> field { field -> createField(field, fieldDefinition, inputObjects) codeRegistryBuilder.dataFetcher( @@ -160,7 +165,8 @@ class SchemaParser internal constructor( private fun createInputObject(definition: InputObjectTypeDefinition, inputObjects: List, referencingInputObjects: MutableSet): GraphQLInputObjectType { - val extensionDefinitions = inputExtensionDefinitions.filter { it.name == definition.name } + val extensionDefinitions = typeExtensions.extensionsOf(definition.name) + val directives = directivesOf(definition, extensionDefinitions) referencingInputObjects.add(definition.name) @@ -169,9 +175,10 @@ class SchemaParser internal constructor( .definition(definition) .extensionDefinitions(extensionDefinitions) .description(getDocumentation(definition, options)) - .withAppliedDirectives(*buildAppliedDirectives(definition.directives)) - .withDirectives(*buildDirectives(definition.directives, Introspection.DirectiveLocation.INPUT_OBJECT)) + .withAppliedDirectives(*buildAppliedDirectives(directives)) + .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.INPUT_OBJECT)) .apply { + // unlike graphql-java the fields of the extensions come first, which is kept so the order of the fields doesn't change (extensionDefinitions + definition).forEach { typeDefinition -> typeDefinition.inputValueDefinitions.forEach { fieldDefinition -> field( @@ -199,14 +206,19 @@ class SchemaParser internal constructor( ?: 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!") + val extensions = typeExtensions.extensionsOf(name) + val directives = directivesOf(definition, extensions) val builder = GraphQLEnumType.newEnum() .name(name) .definition(definition) + .extensionDefinitions(extensions) .description(getDocumentation(definition, options)) - .withAppliedDirectives(*buildAppliedDirectives(definition.directives)) - .withDirectives(*buildDirectives(definition.directives, Introspection.DirectiveLocation.ENUM)) + .withAppliedDirectives(*buildAppliedDirectives(directives)) + .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.ENUM)) .apply { - definition.enumValueDefinitions.forEach { valueDefinition -> + // graphql-java rejects a value that an extension repeats. Here it's kept once, with its first definition, so schemas + // that repeated the values of the enum in an extension to work around #552 keep building. + (definition.enumValueDefinitions + extensions.flatMap { it.enumValueDefinitions }).distinctBy { it.name }.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!") @@ -229,19 +241,24 @@ class SchemaParser internal constructor( } private fun createInterfaceObject(interfaceDefinition: InterfaceTypeDefinition, inputObjects: List): GraphQLInterfaceType { + val extensions = typeExtensions.extensionsOf(interfaceDefinition.name) + val directives = directivesOf(interfaceDefinition, extensions) val builder = GraphQLInterfaceType.newInterface() .name(interfaceDefinition.name) .definition(interfaceDefinition) + .extensionDefinitions(extensions) .description(getDocumentation(interfaceDefinition, options)) - .withAppliedDirectives(*buildAppliedDirectives(interfaceDefinition.directives)) - .withDirectives(*buildDirectives(interfaceDefinition.directives, Introspection.DirectiveLocation.INTERFACE)) + .withAppliedDirectives(*buildAppliedDirectives(directives)) + .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.INTERFACE)) .apply { - interfaceDefinition.fieldDefinitions.forEach { fieldDefinition -> + // graphql-java rejects a field that an extension repeats. Here it's kept once, with its first definition, so schemas + // that repeated the fields of the interface in an extension to work around #552 keep building. + (interfaceDefinition.fieldDefinitions + extensions.flatMap { it.fieldDefinitions }).distinctBy { it.name }.forEach { fieldDefinition -> field { field -> createField(field, fieldDefinition, inputObjects) } } } .apply { - interfaceDefinition.implements.forEach { implementsDefinition -> + (interfaceDefinition.implements + extensions.flatMap { it.implements }).forEach { implementsDefinition -> val interfaceName = (implementsDefinition as TypeName).name withInterface(GraphQLTypeReference(interfaceName)) } @@ -251,12 +268,15 @@ class SchemaParser internal constructor( } private fun createUnionObject(definition: UnionTypeDefinition, types: List): GraphQLUnionType { + val extensions = typeExtensions.extensionsOf(definition.name) + val directives = directivesOf(definition, extensions) val builder = GraphQLUnionType.newUnionType() .name(definition.name) .definition(definition) + .extensionDefinitions(extensions) .description(getDocumentation(definition, options)) - .withAppliedDirectives(*buildAppliedDirectives(definition.directives)) - .withDirectives(*buildDirectives(definition.directives, Introspection.DirectiveLocation.UNION)) + .withAppliedDirectives(*buildAppliedDirectives(directives)) + .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.UNION)) .apply { getLeafUnionObjects(definition, types).forEach { possibleType(it) } } @@ -268,7 +288,7 @@ class SchemaParser internal constructor( val name = definition.name val leafObjects = mutableListOf() - definition.memberTypes.forEach { + (definition.memberTypes + typeExtensions.extensionsOf(name).flatMap { it.memberTypes }).forEach { val typeName = (it as TypeName).name // Is this a nested union? If so, expand @@ -284,6 +304,23 @@ class SchemaParser internal constructor( return leafObjects } + // Like graphql-java, the directives of the extensions follow those of the type. graphql-java rejects a non-repeatable directive + // that an extension applies again. Here the repeat is dropped and the first one kept, so those schemas still build, as they did + // when extension directives were ignored, and the applied directives agree with the legacy ones (see buildDirectives). + // Undeclared directives may be repeatable, so they're all kept. + 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 && isNonRepeatable(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.") } + kept.mapTo(applied) { it.name } + kept + } + } + + private fun isNonRepeatable(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) diff --git a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt index ba22062b..a1535baf 100644 --- a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt +++ b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt @@ -2,6 +2,7 @@ package graphql.kickstart.tools.util import graphql.kickstart.tools.GraphQLResolver import graphql.kickstart.tools.SchemaParserOptions +import graphql.kickstart.tools.TypeExtensions import graphql.language.* import graphql.schema.DataFetchingEnvironment import kotlinx.coroutines.CoroutineScope @@ -38,6 +39,9 @@ internal fun ObjectTypeDefinition.getExtendedFieldDefinitions(extensions: List> TypeExtensions.extensionsOf(name: String): List = + this[name].orEmpty().filterIsInstance() + internal fun JavaType.unwrap(): Class = if (this is ParameterizedType) { this.rawType as Class<*> diff --git a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt new file mode 100644 index 00000000..000f7c97 --- /dev/null +++ b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt @@ -0,0 +1,947 @@ +package graphql.kickstart.tools + +import graphql.GraphQL +import graphql.Scalars +import graphql.language.EnumTypeDefinition +import graphql.language.InterfaceTypeDefinition +import graphql.language.ObjectTypeDefinition +import graphql.language.ScalarTypeDefinition +import graphql.language.UnionTypeDefinition +import graphql.schema.* +import graphql.schema.idl.SchemaDirectiveWiring +import graphql.schema.idl.SchemaDirectiveWiringEnvironment +import graphql.schema.idl.SchemaPrinter +import graphql.schema.validation.InvalidSchemaException +import org.junit.Assert.assertThrows +import org.junit.Test +import kotlin.reflect.KClass + +class TypeExtensionTest { + + @Test + fun `union extensions add members to the union`() { + val schema = buildUnionSchema( + """ + union FooBar = Foo | Bar + + extend union FooBar = Baz + """) + + val fooBar = schema.getType("FooBar") as GraphQLUnionType + assertEquals(fooBar.types.map { it.name }, listOf("Foo", "Bar", "Baz")) + assertEquals(definitionClass(fooBar), UnionTypeDefinition::class.java) + assertEquals(fooBar.extensionDefinitions.size, 1) + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + fooBar { + __typename + } + } + """ + } + + assertEquals(data["fooBar"], mapOf("__typename" to "Baz")) + } + + @Test + fun `union extensions declared before the union add members to it`() { + val schema = buildUnionSchema( + """ + extend union FooBar = Baz + + union FooBar = Foo | Bar + """) + + assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) + } + + @Test + fun `scanner requires classes for members of the union and its extensions`() { + val baseMemberError = assertThrows(SchemaClassScannerError::class.java) { + buildUnionSchema( + """ + union FooBar = Foo | Bar + + extend union FooBar = Baz + """, + UnionQuery.Bar::class, UnionQuery.Baz::class) + } + + assertEquals(baseMemberError.message, "Object type 'Foo' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'Foo' in the parser's dictionary.") + + val extensionMemberError = assertThrows(SchemaClassScannerError::class.java) { + buildUnionSchema( + """ + extend union FooBar = Baz + + union FooBar = Foo | Bar + """, + UnionQuery.Foo::class, UnionQuery.Bar::class) + } + + assertEquals(extensionMemberError.message, "Object type 'Baz' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'Baz' in the parser's dictionary.") + } + + private fun buildUnionSchema(union: String, vararg dictionary: KClass<*> = arrayOf(UnionQuery.Foo::class, UnionQuery.Bar::class, UnionQuery.Baz::class)) = + SchemaParser.newParser() + .schemaString( + """ + type Query { + fooBar: FooBar + } + + $union + + type Foo { + foo: String + } + + type Bar { + bar: String + } + + type Baz { + baz: String + } + """) + .resolvers(UnionQuery()) + .dictionary(*dictionary) + .build() + .makeExecutableSchema() + + class UnionQuery : GraphQLQueryResolver { + fun fooBar(): FooBar = Baz("baz") + + interface FooBar + + class Foo(val foo: String?) : FooBar + + class Bar(val bar: String?) : FooBar + + class Baz(val baz: String?) : FooBar + } + + @Test + fun `union extensions can add nested unions`() { + // Audio is also returned by a field, since the scanner only expands nested unions it found some other way. That's the + // same for the members of the union itself. + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + media: Media + audio: Audio + } + + union Media = Book + + extend union Media = Audio + + union Audio = Song | Podcast + + type Book { + title: String + } + + type Song { + title: String + } + + type Podcast { + title: String + } + """) + .resolvers(MediaQuery()) + .dictionary(MediaQuery.Book::class, MediaQuery.Song::class, MediaQuery.Podcast::class) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Media") as GraphQLUnionType).types.map { it.name }, listOf("Book", "Song", "Podcast")) + } + + class MediaQuery : GraphQLQueryResolver { + fun media(): Media? = null + fun audio(): Audio? = null + + interface Media + + interface Audio + + class Book(val title: String?) : Media + + class Song(val title: String?) : Audio + + class Podcast(val title: String?) : Audio + } + + private val colorSchema = """ + type Query { + color: Color + } + + enum Color { + RED + } + + extend enum Color { + GREEN + } + """ + + @Test + fun `enum extensions add values to the enum`() { + val schema = SchemaParser.newParser() + .schemaString(colorSchema) + .resolvers(ColorQuery()) + .build() + .makeExecutableSchema() + + val color = schema.getType("Color") as GraphQLEnumType + assertEquals(color.values.map { it.name }, listOf("RED", "GREEN")) + assertEquals(definitionClass(color), EnumTypeDefinition::class.java) + assertEquals(color.extensionDefinitions.size, 1) + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + color + } + """ + } + + assertEquals(data["color"], "GREEN") + } + + @Test + fun `parser requires enum constants for the values of the enum and its extensions`() { + val baseValueError = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString(colorSchema) + .resolvers(object : GraphQLQueryResolver { + fun color(): GreenOnly? = null + }) + .build() + .makeExecutableSchema() + } + + assertEquals(baseValueError.message, "Expected value for name 'RED' in enum 'GreenOnly' but found none!") + + val extensionValueError = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString(colorSchema) + .resolvers(object : GraphQLQueryResolver { + fun color(): RedOnly? = null + }) + .build() + .makeExecutableSchema() + } + + assertEquals(extensionValueError.message, "Expected value for name 'GREEN' in enum 'RedOnly' but found none!") + } + + class ColorQuery : GraphQLQueryResolver { + fun color(): Color = Color.GREEN + + enum class Color { RED, GREEN } + } + + enum class GreenOnly { GREEN } + + enum class RedOnly { RED } + + @Test + fun `interface extensions add fields and interfaces to the interface`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + node: Node + } + + interface Entity { + id: ID! + } + + interface Node { + id: ID! + } + + extend interface Node implements Entity { + name: String + } + + type Item implements Node & Entity { + id: ID! + name: String + } + """) + .resolvers(NodeQuery()) + .dictionary(NodeQuery.Item::class) + .build() + .makeExecutableSchema() + + val node = schema.getType("Node") as GraphQLInterfaceType + assertEquals(node.fieldDefinitions.map { it.name }, listOf("id", "name")) + assertEquals(node.interfaces.map { it.name }, listOf("Entity")) + assertEquals(definitionClass(node), InterfaceTypeDefinition::class.java) + assertEquals(node.extensionDefinitions.size, 1) + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + node { + id + name + } + } + """ + } + + assertEquals(data["node"], mapOf("id" to "1", "name" to "item")) + } + + @Test + fun `implementations need the fields of the interface and not only those of its extensions`() { + val error = assertThrows(InvalidSchemaException::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + node: Node + } + + interface Node { + id: ID! + } + + extend interface Node { + name: String + } + + type Item implements Node { + name: String + } + """) + .resolvers(NodeQuery()) + .dictionary(NodeQuery.Item::class) + .build() + .makeExecutableSchema() + } + + assert(error.message!!.contains("object type 'Item' does not implement interface 'Node' because field 'id' is missing")) { error.message!! } + } + + class NodeQuery : GraphQLQueryResolver { + fun node(): Node = Item("1", "item") + + interface Node + + class Item(val id: String, val name: String?) : Node + } + + @Test + fun `scanner handles unions used only by fields of interface extensions`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + item: Item + } + + interface Item { + id: ID! + } + + extend interface Item { + content: Content + } + + type Book implements Item { + id: ID! + content: Text + } + + type Text { + text: String + } + + type Image { + url: String + } + + union Content = Text | Image + """) + .resolvers(InterfaceExtensionUnion()) + .dictionary(InterfaceExtensionUnion.Book::class, InterfaceExtensionUnion.Image::class) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Item") as GraphQLInterfaceType).fieldDefinitions.map { it.name }, listOf("id", "content")) + assertEquals((schema.getType("Content") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Text", "Image")) + } + + class InterfaceExtensionUnion : GraphQLQueryResolver { + fun item(): Item? = null + + interface Item + + class Book(val id: String, val content: Text?) : Item + + class Text(val text: String?) + + class Image(val url: String?) + } + + @Test + fun `scalar extensions keep the description of the scalar`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @format(pattern: String!) on SCALAR + + type Query { + today: Date + } + + "A date" + scalar Date + + extend scalar Date @format(pattern: "yyyy-MM-dd") + """) + .resolvers(object : GraphQLQueryResolver { + fun today(): String = "2026-10-12" + }) + .scalars(GraphQLScalarType.newScalar().name("Date").coercing(Scalars.GraphQLString.coercing).build()) + .build() + .makeExecutableSchema() + + val date = schema.getType("Date") as GraphQLScalarType + assertEquals(date.description, "A date") + assertEquals(definitionClass(date), ScalarTypeDefinition::class.java) + assertEquals(date.extensionDefinitions.size, 1) + } + + @Test + fun `object extensions apply their directives`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @key(fields: String!) repeatable on OBJECT | INTERFACE + + type Query { + product: Product + } + + type Product @key(fields: "upc") { + upc: String + } + + extend type Product @key(fields: "name") { + name: String + } + """) + .resolvers(ProductQuery()) + .build() + .makeExecutableSchema() + + val product = schema.getObjectType("Product")!! + assertEquals(product.appliedDirectives.map { it.getArgument("fields")?.getValue() }, listOf("upc", "name")) + assertEquals(product.directives.map { it.name }, listOf("key", "key")) + assertEquals(product.fieldDefinitions.map { it.name }, listOf("upc", "name")) + assertEquals(definitionClass(product), ObjectTypeDefinition::class.java) + assertEquals(product.extensionDefinitions.size, 1) + + val printed = SchemaPrinter().print(schema) + assert(printed.contains("""type Product @key(fields : "upc") @key(fields : "name") {""")) { printed } + } + + @Test + fun `non-repeatable directives that an extension applies again keep the first one`() { + val tagDirective = TagDirective() + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @tag(name: String!) on OBJECT + + type Query { + product: Product + } + + type Product @tag(name: "catalog") { + upc: String + } + + extend type Product @tag(name: "pricing") + """) + .resolvers(ProductQuery()) + .directive("tag", tagDirective) + .build() + .makeExecutableSchema() + + // graphql-java's SchemaGenerator rejects this schema + val product = schema.getObjectType("Product")!! + assertEquals(product.appliedDirectives.map { it.getArgument("name")?.getValue() }, listOf("catalog")) + assertEquals(product.directives.map { it.name }, listOf("tag")) + assertEquals(tagDirective.tagged, listOf("Product=catalog")) + + val printed = SchemaPrinter().print(schema) + assert(printed.contains("""type Product @tag(name : "catalog") {""")) { printed } + } + + @Test + fun `input object extensions apply their directives`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @a on INPUT_OBJECT + directive @b on INPUT_OBJECT + + type Query { + search(filter: Filter): String + } + + input Filter @a { + id: ID + } + + extend input Filter @b + """) + .resolvers(object : GraphQLQueryResolver { + fun search(filter: TaggedQuery.Filter?): String? = null + }) + .build() + .makeExecutableSchema() + + val filter = schema.getType("Filter") as GraphQLInputObjectType + assertEquals(filter.appliedDirectives.map { it.name }, listOf("a", "b")) + assertEquals(filter.directives.map { it.name }, listOf("a", "b")) + assertEquals(filter.extensionDefinitions.size, 1) + } + + @Test + fun `undeclared directives on type extensions are rejected unless allowed`() { + val schemaString = """ + type Query { + product: Product + } + + type Product { + upc: String + } + + extend type Product @key(fields: "upc") + """ + + val error = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString(schemaString) + .resolvers(ProductQuery()) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Found applied directive key without corresponding directive definition.") + + val schema = SchemaParser.newParser() + .schemaString(schemaString) + .resolvers(ProductQuery()) + .options(SchemaParserOptions.newOptions().allowUndeclaredDirectives(true).build()) + .build() + .makeExecutableSchema() + + assertEquals(schema.getObjectType("Product")!!.getAppliedDirective("key")?.getArgument("fields")?.getValue(), "upc") + } + + @Test + fun `undeclared directives that an extension applies again are all kept`() { + // e.g. federation's @key, which is repeatable + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + product: Product + } + + type Product @key(fields: "upc") { + upc: String + } + + extend type Product @key(fields: "name") { + name: String + } + """) + .resolvers(ProductQuery()) + .options(SchemaParserOptions.newOptions().allowUndeclaredDirectives(true).build()) + .build() + .makeExecutableSchema() + + assertEquals(schema.getObjectType("Product")!!.appliedDirectives.map { it.getArgument("fields")?.getValue() }, listOf("upc", "name")) + } + + class ProductQuery : GraphQLQueryResolver { + fun product(): Product? = null + + class Product(val upc: String?, val name: String?) + } + + @Test + fun `directives on extensions of every kind of type are wired`() { + val tagDirective = TagDirective() + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @tag(name: String!) on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + + type Query { + node(filter: Filter): Node + result: Result + color: Color + } + + interface Node { + id: ID! + } + + extend interface Node @tag(name: "node") + + type Item implements Node { + id: ID! + } + + extend type Item @tag(name: "item") + + union Result = Item + + extend union Result @tag(name: "result") + + enum Color { + RED + } + + extend enum Color @tag(name: "color") + + input Filter { + id: ID + } + + extend input Filter @tag(name: "filter") + """) + .resolvers(TaggedQuery()) + .dictionary(TaggedQuery.Item::class) + .directive("tag", tagDirective) + .build() + .makeExecutableSchema() + + listOf("Node", "Item", "Result", "Color", "Filter").forEach { name -> + assertEquals((schema.getType(name) as GraphQLDirectiveContainer).appliedDirectives.map { it.name }, listOf("tag")) + } + assertEquals(tagDirective.tagged.toSet(), setOf("Node=node", "Item=item", "Result=result", "Color=color", "Filter=filter")) + } + + class TaggedQuery : GraphQLQueryResolver { + fun node(filter: Filter?): Node? = null + fun result(): Result? = null + fun color(): Color? = null + + interface Node + + interface Result + + enum class Color { RED } + + class Filter(val id: String?) + + class Item(val id: String) : Node, Result + } + + private class TagDirective : SchemaDirectiveWiring { + val tagged = mutableListOf() + + override fun onObject(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + + override fun onInterface(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + + override fun onUnion(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + + override fun onEnum(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + + override fun onInputObjectType(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + + private fun tag(environment: SchemaDirectiveWiringEnvironment): T { + tagged.add("${environment.element.name}=${environment.appliedDirective.getArgument("name")?.getValue()}") + return environment.element + } + } + + @Test + fun `object extensions add interfaces to the object`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + item: Item + } + + interface Node { + id: ID! + } + + interface Named { + name: String + } + + type Item implements Node { + id: ID! + name: String + } + + extend type Item implements Named + """) + .resolvers(object : GraphQLQueryResolver { + fun item(): NodeQuery.Item? = null + }) + .build() + .makeExecutableSchema() + + assertEquals(schema.getObjectType("Item")!!.interfaces.map { it.name }, listOf("Node", "Named")) + assertEquals(schema.getImplementations(schema.getType("Named") as GraphQLInterfaceType)!!.map { it.name }, listOf("Item")) + } + + @Test + fun `scanner requires classes for objects that implement an interface through an extension`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + node: Node + } + + interface Node { + id: ID! + } + + type Item implements Node { + id: ID! + } + + type Other { + id: ID! + } + + extend type Other implements Node + """) + .resolvers(NodeQuery()) + .dictionary(NodeQuery.Item::class) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Object type 'Other' implements a known interface, but no class could be found for that type name. Please pass a class for type 'Other' in the parser's dictionary.") + } + + private val unusedUnionSchema = """ + type Query { + name: String + } + + union Unused = A | B + + extend union Unused = C + + type A { + a: String + } + + type B { + b: String + } + + type C { + c: String + } + """ + + @Test + fun `unused unions get the members of their extensions when including unused types`() { + val schema = SchemaParser.newParser() + .schemaString(unusedUnionSchema) + .resolvers(UnusedUnion()) + .dictionary(UnusedUnion.A::class, UnusedUnion.B::class, UnusedUnion.C::class) + .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Unused") as GraphQLUnionType).types.map { it.name }, listOf("A", "B", "C")) + } + + @Test + fun `unused definitions hold the definition of an extended type`() { + val parser = SchemaParser.newParser() + .schemaString(unusedUnionSchema) + .resolvers(UnusedUnion()) + .build() + + assertEquals(parser.getUnusedDefinitions().map { it.javaClass.simpleName to it.name }.toSet(), setOf( + "UnionTypeDefinition" to "Unused", + "ObjectTypeDefinition" to "A", + "ObjectTypeDefinition" to "B", + "ObjectTypeDefinition" to "C" + )) + } + + class UnusedUnion : GraphQLQueryResolver { + fun name(): String? = null + + class A(val a: String?) + + class B(val b: String?) + + class C(val c: String?) + } + + @Test + fun `values, fields and members repeated in an extension keep their first definition`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + color: Color + node: Node + fooBar: FooBar + } + + enum Color { + "The base red" + RED + } + + extend enum Color { + RED + GREEN + } + + interface Node { + "The base id" + id: ID! + } + + extend interface Node { + id: ID! + name: String + } + + type Item implements Node { + id: ID! + name: String + } + + union FooBar = Foo | Bar + + extend union FooBar = Foo | Bar | Baz + + type Foo { + foo: String + } + + type Bar { + bar: String + } + + type Baz { + baz: String + } + """) + .resolvers(ColorQuery(), NodeQuery(), UnionQuery()) + .dictionary(NodeQuery.Item::class, UnionQuery.Foo::class, UnionQuery.Bar::class, UnionQuery.Baz::class) + .build() + .makeExecutableSchema() + + val color = schema.getType("Color") as GraphQLEnumType + assertEquals(color.values.map { it.name }, listOf("RED", "GREEN")) + assertEquals(color.getValue("RED")?.description, "The base red") + + val node = schema.getType("Node") as GraphQLInterfaceType + assertEquals(node.fieldDefinitions.map { it.name }, listOf("id", "name")) + assertEquals(node.getFieldDefinition("id").description, "The base id") + + assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) + } + + @Test + fun `enums and unions defined only by extensions are built from all of them`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + color: Color + fooBar: FooBar + } + + extend enum Color { + RED + } + + extend enum Color { + GREEN + } + + extend union FooBar = Foo | Bar + + extend union FooBar = Baz + + type Foo { + foo: String + } + + type Bar { + bar: String + } + + type Baz { + baz: String + } + """) + .resolvers(ColorQuery(), UnionQuery()) + .dictionary(UnionQuery.Foo::class, UnionQuery.Bar::class, UnionQuery.Baz::class) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Color") as GraphQLEnumType).values.map { it.name }, listOf("RED", "GREEN")) + assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) + } + + @Test + fun `extensions of a different kind than the type are rejected`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + foo: Foo + } + + type Foo { + foo: String + } + + type Bar { + bar: String + } + + extend union Foo = Bar + """) + .resolvers(object : GraphQLQueryResolver { + fun foo(): UnionQuery.Foo? = null + }) + .build() + } + + assertEquals(error.message, "Type 'Foo' is defined as an object type, but extended as a union.") + } + + // through the interface, since graphql-java 25 and 26 disagree on which types have a nullable definition + private fun definitionClass(type: GraphQLNamedSchemaElement) = type.definition?.javaClass +}