diff --git a/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt b/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt index 129e7023..ddb5df3e 100644 --- a/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt +++ b/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt @@ -16,6 +16,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>, @@ -25,3 +26,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 006a86f7..edf356bb 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -35,15 +35,25 @@ internal class SchemaClassScanner( private val resolverInfos = resolvers.asSequence().minus(queryResolvers).minus(mutationResolvers).minus(subscriptionResolvers).map { NormalResolverInfo(it, options) }.toList() private val initialDictionary = initialDictionary.mapValues { InitialDictionaryEntry(it.value) } - private val extensionDefinitions = allDefinitions.filterIsInstance() - private val inputExtensionDefinitions = allDefinitions.filterIsInstance() private val directiveDefinitions = allDefinitions.filterIsInstance() private val scalarDefinitions = allDefinitions.filterIsInstance() - private val definitionsByName = (allDefinitions.filterIsInstance>() - extensionDefinitions - inputExtensionDefinitions).associateBy { it.name } - private val objectDefinitions = (allDefinitions.filterIsInstance() - extensionDefinitions) + private val typeDefinitions = allDefinitions.filterIsInstance>() + private val baseTypeNames = typeDefinitions.filter { it !is SDLExtensionDefinition }.map { it.name }.toSet() + + // the first extension of an undefined union, enum, interface or scalar stands in for its definition + 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 } + // without extensions of a different kind than their type, which are ignored with a warning, see init + private val typeExtensions: TypeExtensions = typeDefinitions + .filter { it is SDLExtensionDefinition && it !in loneExtensions && !extendsOtherKind(it) } + .groupBy { it.name } + private val objectDefinitions = allDefinitions.filterIsInstance().filter { it !is ObjectTypeExtensionDefinition } 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) @@ -61,6 +71,11 @@ internal class SchemaClassScanner( } } + typeDefinitions.filter { extendsOtherKind(it) }.forEach { extension -> + val definition = definitionsByName.getValue(extension.name) + log.warn("Type '${extension.name}' is defined as ${definition.kind()}, but extended as ${extension.kind()}. The extension is ignored.") + } + // Input object types never go into the type => class bimap that interface and union type resolution use, so several of // them can share a class, e.g. ObjectNode initialDictionary @@ -216,9 +231,8 @@ internal class SchemaClassScanner( * The names of the types used by the fields, arguments and input fields of [definitions], including fields added by extensions. */ private fun typeNamesUsedByFields(definitions: Set>): Set { - val names = definitions.map { it.name }.toSet() - return (definitions + extensionDefinitions + inputExtensionDefinitions) - .filter { it.name in names } + return definitions + .flatMap { it.withExtensions(typeExtensions) } .flatMap { definition -> when (definition) { is ImplementingTypeDefinition<*> -> definition.fieldDefinitions.flatMap { field -> @@ -278,6 +292,7 @@ internal class SchemaClassScanner( .description(getDocumentation(definition, options) ?: provided.description) .coercing(provided.coercing) .definition(definition) + .extensionDefinitions(typeExtensions.extensionsOf(definition.name)) .build() }.associateBy { it.name } @@ -300,7 +315,7 @@ internal class SchemaClassScanner( validateRootResolversWereUsed(rootTypeHolder.mutation, fieldResolvers) validateRootResolversWereUsed(rootTypeHolder.subscription, fieldResolvers) - val definitions = observedDefinitions + extensionDefinitions + inputExtensionDefinitions + directiveDefinitions + val definitions = observedDefinitions + directiveDefinitions // 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 @@ -308,7 +323,7 @@ internal class SchemaClassScanner( val typeNamesUsedByFields = typeNamesUsedByFields(observedDefinitions) val directiveOnlyEnums = unvalidatedTypes.filterIsInstance().filter { it.name !in typeNamesUsedByFields }.toSet() - return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions, directiveOnlyEnums) + return ScannedSchemaObjects(dictionary, definitions, typeExtensions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions, directiveOnlyEnums) } private fun validateRootResolversWereUsed(rootType: RootType?, fieldResolvers: List) { @@ -326,16 +341,20 @@ 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.withExtensions(typeExtensions).flatMap { it.implements }.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") + union.withExtensions(typeExtensions).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() } @@ -346,7 +365,7 @@ internal class SchemaClassScanner( */ private fun handleInterfaceFieldTypes(): Boolean { val newTypes = dictionary.keys.filterIsInstance().flatMap { iface -> - iface.fieldDefinitions.mapNotNull { field -> + iface.withExtensions(typeExtensions).flatMap { it.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}") @@ -429,8 +448,8 @@ internal class SchemaClassScanner( dictionary .filterKeys { definition -> when (definition) { - is UnionTypeDefinition -> definition.memberTypes.filterIsInstance().any { it.name == type.name } - is InterfaceTypeDefinition -> type.implements.filterIsInstance().any { it.name == definition.name } + is UnionTypeDefinition -> definition.withExtensions(typeExtensions).flatMap { it.memberTypes }.filterIsInstance().any { it.name == type.name } + is InterfaceTypeDefinition -> type.withExtensions(typeExtensions).flatMap { it.implements }.filterIsInstance().any { it.name == definition.name } else -> false } } @@ -503,7 +522,7 @@ internal class SchemaClassScanner( resolverInfo.dataClassType != Any::class.java && resolverInfo.dataClassType.isAssignableFrom(dataClass) private fun scanResolverInfoForPotentialMatches(type: ObjectTypeDefinition, resolverInfo: ResolverInfo) { - type.getExtendedFieldDefinitions(extensionDefinitions).forEach { field -> + type.withExtensions(typeExtensions).flatMap { it.fieldDefinitions }.forEach { field -> val fieldResolver = fieldResolverScanner.findFieldResolver(field, resolverInfo) fieldResolversByType.getOrPut(type) { mutableMapOf() }[fieldResolver.field] = fieldResolver @@ -573,8 +592,7 @@ internal class SchemaClassScanner( */ private fun handleInputObjectFields(graphQLType: InputObjectTypeDefinition, javaType: JavaType?) { val clazz = javaType?.unwrap() - val inputObjectTypes = listOf(graphQLType) + inputExtensionDefinitions.filter { it.name == graphQLType.name } - inputObjectTypes + graphQLType.withExtensions(typeExtensions) .flatMap { it.inputValueDefinitions } .forEach { inputValueDefinition -> val inputGraphQLType = inputValueDefinition.type.unwrap() @@ -645,7 +663,7 @@ internal class SchemaClassScanner( } private fun scanInterfacesOfType(graphQLType: ObjectTypeDefinition) { - graphQLType.implements.forEach { + graphQLType.withExtensions(typeExtensions).flatMap { it.implements }.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)) @@ -653,6 +671,22 @@ internal class SchemaClassScanner( } } + // e.g. `extend union Foo` of `type Foo` + private fun extendsOtherKind(definition: TypeDefinition<*>): Boolean { + val type = definitionsByName[definition.name] + return definition is SDLExtensionDefinition && type != null && type.kind() != definition.kind() + } + + 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 0e6b1970..ce1d4d3b 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -5,9 +5,10 @@ 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 +import graphql.kickstart.tools.util.withExtensions import graphql.language.* import graphql.schema.* import graphql.schema.idl.RuntimeWiring @@ -35,17 +36,15 @@ 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 private val unusedDefinitions = scanResult.unusedDefinitions private val directiveOnlyEnums = scanResult.directiveOnlyEnums - private val extensionDefinitions = definitions.filterIsInstance() - private val inputExtensionDefinitions = definitions.filterIsInstance() - - private val objectDefinitions = (definitions.filterIsInstance() - extensionDefinitions) - private val inputObjectDefinitions = (definitions.filterIsInstance() - inputExtensionDefinitions) + private val objectDefinitions = definitions.filterIsInstance() + private val inputObjectDefinitions = definitions.filterIsInstance() private val enumDefinitions = definitions.filterIsInstance() private val interfaceDefinitions = definitions.filterIsInstance() private val directiveDefinitions = definitions.filterIsInstance() @@ -128,21 +127,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.withExtensions(typeExtensions).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.withExtensions(typeExtensions).flatMap { it.fieldDefinitions }.forEach { fieldDefinition -> field { field -> createField(field, fieldDefinition, inputObjects) codeRegistryBuilder.dataFetcher( @@ -164,7 +166,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) @@ -173,9 +176,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( @@ -204,14 +208,19 @@ class SchemaParser internal constructor( 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 extensions = typeExtensions.extensionsOf(name) + val directives = directivesOf(definition, extensions) + val values = definition.withExtensions(typeExtensions).flatMap { it.enumValueDefinitions } + warnRepeated(name, "value", values.map { it.name }) 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 -> + values.distinctBy { it.name }.forEach { valueDefinition -> val enumName = valueDefinition.name val enumValue = if (enumClass == null) { enumName @@ -238,19 +247,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 fieldDefinitions = interfaceDefinition.withExtensions(typeExtensions).flatMap { it.fieldDefinitions } + warnRepeated(interfaceDefinition.name, "field", fieldDefinitions.map { it.name }) 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 -> + fieldDefinitions.distinctBy { it.name }.forEach { fieldDefinition -> field { field -> createField(field, fieldDefinition, inputObjects) } } } .apply { - interfaceDefinition.implements.forEach { implementsDefinition -> + interfaceDefinition.withExtensions(typeExtensions).flatMap { it.implements }.forEach { implementsDefinition -> val interfaceName = (implementsDefinition as TypeName).name withInterface(GraphQLTypeReference(interfaceName)) } @@ -260,12 +274,16 @@ class SchemaParser internal constructor( } private fun createUnionObject(definition: UnionTypeDefinition, types: List): GraphQLUnionType { + val extensions = typeExtensions.extensionsOf(definition.name) + val directives = directivesOf(definition, extensions) + warnRepeated(definition.name, "member", definition.withExtensions(typeExtensions).flatMap { it.memberTypes }.map { (it as TypeName).name }) 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) } } @@ -277,7 +295,7 @@ class SchemaParser internal constructor( val name = definition.name val leafObjects = mutableListOf() - definition.memberTypes.forEach { + definition.withExtensions(typeExtensions).flatMap { it.memberTypes }.forEach { val typeName = (it as TypeName).name // Is this a nested union? If so, expand @@ -293,6 +311,29 @@ class SchemaParser internal constructor( return leafObjects } + // TODO throw a SchemaError instead in 15.0.0 (#863), like graphql-java does + private fun warnRepeated(typeName: String, kind: String, names: List) { + names.groupingBy { it }.eachCount().filterValues { it > 1 }.keys.forEach { + log.warn("Type '$typeName' declares $kind '$it' more than once, only the first one is kept.") + } + } + + // The directives of the extensions follow those of the type, like in graphql-java. A non-repeatable directive that an extension + // applies again is dropped, where graphql-java rejects the schema, so those schemas keep building and the applied directives + // match 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.") } + applied += kept.map { 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) @@ -604,7 +645,6 @@ class SchemaParser internal constructor( private fun unexpectedTypeError(typeName: TypeName, expectedType: KClass<*>): SchemaError { val isInput = expectedType == GraphQLInputType::class - // unused ones first, since the extensions of an unused type are still among the definitions val definition = unusedDefinitions.find { it.name == typeName.name } ?: definitions.filterIsInstance>().find { it.name == typeName.name } val outputOnlyKind = when (definition) { @@ -647,7 +687,7 @@ class SchemaParser internal constructor( } // determineType only gets the type reference, so the definition using it is looked up for the error message - private fun findUsage(typeName: TypeName): String? = definitions.asSequence().flatMap { definition -> + private fun findUsage(typeName: TypeName): String? = (definitions + typeExtensions.values.flatten()).asSequence().flatMap { definition -> when (definition) { is ObjectTypeDefinition -> fieldUsages(definition.name, definition.fieldDefinitions) is InterfaceTypeDefinition -> fieldUsages(definition.name, definition.fieldDefinitions) diff --git a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt index ba22062b..f12608eb 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 @@ -34,9 +35,13 @@ internal fun Type<*>.unwrap(): Type<*> = when (this) { else -> this } -internal fun ObjectTypeDefinition.getExtendedFieldDefinitions(extensions: List): List { - return this.fieldDefinitions + extensions.filter { it.name == this.name }.flatMap { it.fieldDefinitions } -} +internal inline fun > TypeExtensions.extensionsOf(name: String): List = + this[name].orEmpty().filterIsInstance() + +// The type followed by its extensions, e.g. `union.withExtensions(typeExtensions).flatMap { it.memberTypes }`. Every extension class +// is a subclass of its type's class, e.g. UnionTypeExtensionDefinition of UnionTypeDefinition. +internal inline fun > T.withExtensions(typeExtensions: TypeExtensions): List = + listOf(this) + typeExtensions.extensionsOf(name) internal fun JavaType.unwrap(): Class = if (this is ParameterizedType) { diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt index 20e59f4e..6efac0f5 100644 --- a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt @@ -12,7 +12,9 @@ class SchemaClassScannerSealedTypesTest { pets: [Pet] } - union Pet = NormalPet | WeirdPet + union Pet = NormalPet + + extend union Pet = WeirdPet type NormalPet { name: String @@ -81,10 +83,12 @@ class SchemaClassScannerSealedTypesTest { barks: Boolean } - type Cat implements Animal { + type Cat { name: String lives: Int } + + extend type Cat implements Animal """) .resolvers(SealedInterfaceImplementors()) .build() 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..bcd0bcb4 --- /dev/null +++ b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt @@ -0,0 +1,495 @@ +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 + +class TypeExtensionTest { + + @Test + fun `extensions add to the types they extend`() { + val schema = parser( + """ + directive @format(pattern: String!) on SCALAR + + type Query { + fooBar: FooBar + color: Color + node: Node + today: Date + } + + union FooBar = Foo | Bar + extend union FooBar = Baz + + # the order doesn't matter + extend enum Color { GREEN } + enum Color { RED } + + interface Entity { id: ID! } + interface Node { id: ID! } + extend interface Node implements Entity { name: String } + + type Item implements Node { id: ID! name: String } + extend type Item implements Entity + + "A date" + scalar Date + extend scalar Date @format(pattern: "yyyy-MM-dd") + + $fooBarBaz + """) { + dictionary(Foo::class, Bar::class, Baz::class, Item::class) + scalars(GraphQLScalarType.newScalar().name("Date").coercing(Scalars.GraphQLString.coercing).build()) + }.makeExecutableSchema() + + val fooBar = schema.getType("FooBar") as GraphQLUnionType + val color = schema.getType("Color") as GraphQLEnumType + val node = schema.getType("Node") as GraphQLInterfaceType + val item = schema.getType("Item") as GraphQLObjectType + val date = schema.getType("Date") as GraphQLScalarType + assertEquals(fooBar.types.map { it.name }, listOf("Foo", "Bar", "Baz")) + assertEquals(color.values.map { it.name }, listOf("RED", "GREEN")) + assertEquals(node.fieldDefinitions.map { it.name }, listOf("id", "name")) + assertEquals(node.interfaces.map { it.name }, listOf("Entity")) + assertEquals(item.interfaces.map { it.name }, listOf("Node", "Entity")) + assertEquals(schema.getImplementations(schema.getType("Entity") as GraphQLInterfaceType)?.map { it.name }, listOf("Item")) + assertEquals(date.description, "A date") + + // through GraphQLNamedSchemaElement, since graphql-java 25 and 26 disagree on which types have a nullable definition + assertEquals(listOf(fooBar, color, node, item, date).map { it.definition?.javaClass }, listOf( + UnionTypeDefinition::class.java, + EnumTypeDefinition::class.java, + InterfaceTypeDefinition::class.java, + ObjectTypeDefinition::class.java, + ScalarTypeDefinition::class.java + )) + val extensionDefinitions = listOf(fooBar.extensionDefinitions, color.extensionDefinitions, node.extensionDefinitions, item.extensionDefinitions, date.extensionDefinitions) + assertEquals(extensionDefinitions.map { it.size }, listOf(1, 1, 1, 1, 1)) + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + fooBar { __typename } + color + node { id name } + } + """ + } + + assertEquals(data, mapOf("fooBar" to mapOf("__typename" to "Baz"), "color" to "GREEN", "node" to mapOf("id" to "1", "name" to "item"))) + } + + @Test + fun `union extensions can add nested unions`() { + // BarBaz has a field of its own, since the scanner only expands nested unions it finds some other way + val schema = parser( + """ + type Query { + fooBar: FooBar + barBaz: BarBaz + } + + union FooBar = Foo + extend union FooBar = BarBaz + union BarBaz = Bar | Baz + + $fooBarBaz + """) { + dictionary(Foo::class, Bar::class, Baz::class) + }.makeExecutableSchema() + + assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) + } + + @Test + fun `scanner finds unions used only by fields of interface extensions`() { + val schema = parser( + """ + type Query { + node: Node + } + + interface Node { id: ID! } + extend interface Node { fooBar: FooBar } + + type Item implements Node { id: ID! fooBar: Foo } + + union FooBar = Foo | Bar + + $fooBarBaz + """) { + dictionary(Item::class, Bar::class) + }.makeExecutableSchema() + + assertEquals((schema.getType("Node") as GraphQLInterfaceType).fieldDefinitions.map { it.name }, listOf("id", "fooBar")) + assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Foo", "Bar")) + } + + @Test + fun `scanner requires classes for the members and implementations from the type and its extensions`() { + val baseMemberError = assertThrows(SchemaClassScannerError::class.java) { + parser( + """ + type Query { fooBar: FooBar } + union FooBar = Foo | Bar + extend union FooBar = Baz + $fooBarBaz + """) { + dictionary(Bar::class, 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) { + parser( + """ + type Query { fooBar: FooBar } + extend union FooBar = Baz + union FooBar = Foo | Bar + $fooBarBaz + """) { + dictionary(Foo::class, 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.") + + val implementationError = assertThrows(SchemaClassScannerError::class.java) { + parser( + """ + type Query { node: Node } + interface Node { id: ID! } + type Item implements Node { id: ID! } + type Other { id: ID! } + extend type Other implements Node + """) { + dictionary(Item::class) + } + } + + assertEquals(implementationError.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.") + } + + @Test + fun `parser requires the enum values and interface fields of the type and its extensions`() { + listOf("enum Color { BLUE } extend enum Color { RED }", "enum Color { RED } extend enum Color { BLUE }").forEach { color -> + val error = assertThrows(SchemaError::class.java) { + parser("type Query { color: Color } $color").makeExecutableSchema() + } + + assertEquals(error.message, "Expected value for name 'BLUE' in enum 'Color' but found none!") + } + + val fieldError = assertThrows(InvalidSchemaException::class.java) { + parser( + """ + type Query { node: Node } + interface Node { id: ID! } + extend interface Node { name: String } + type Item implements Node { name: String } + """) { + dictionary(Item::class) + }.makeExecutableSchema() + } + + val message = fieldError.message.orEmpty() + assert(message.contains("object type 'Item' does not implement interface 'Node' because field 'id' is missing")) { message } + } + + @Test + fun `directives of extensions follow those of the type and are wired`() { + val wiring = RecordingWiring() + val schema = parser( + """ + directive @tag(name: String!) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + directive @once(name: String!) on OBJECT | INPUT_OBJECT + + type Query @once(name: "Query") { + node: Node + fooBar: FooBar + color: Color + search(filter: Filter): String + } + + # graphql-java rejects the schema for this, here the extension's @once is dropped + extend type Query @once(name: "Query extension") + + interface Node @tag(name: "Node") { id: ID! } + extend interface Node @tag(name: "Node extension") + + type Item implements Node @tag(name: "Item") { id: ID! } + extend type Item @tag(name: "Item extension") { name: String } + + union FooBar @tag(name: "FooBar") = Foo + extend union FooBar @tag(name: "FooBar extension") + + enum Color @tag(name: "Color") { RED GREEN } + extend enum Color @tag(name: "Color extension") + + input Filter @tag(name: "Filter") { id: ID } + extend input Filter @tag(name: "Filter extension") @once(name: "Filter extension") + + type Foo { foo: String } + """) { + dictionary(Item::class, Foo::class) + directive("tag", wiring) + directive("once", wiring) + }.makeExecutableSchema() + + val applied = listOf("Query", "Node", "Item", "FooBar", "Color", "Filter").associateWith { name -> + (schema.getType(name) as GraphQLDirectiveContainer).appliedDirectives.map { it.describe() } + } + assertEquals(applied, mapOf( + "Query" to listOf("@once(Query)"), + "Node" to listOf("@tag(Node)", "@tag(Node extension)"), + "Item" to listOf("@tag(Item)", "@tag(Item extension)"), + "FooBar" to listOf("@tag(FooBar)", "@tag(FooBar extension)"), + "Color" to listOf("@tag(Color)", "@tag(Color extension)"), + "Filter" to listOf("@tag(Filter)", "@tag(Filter extension)", "@once(Filter extension)") + )) + // each applied directive is wired once + assertEquals(wiring.wired.sorted(), applied.values.flatten().sorted()) + + val item = schema.getType("Item") as GraphQLObjectType + val filter = schema.getType("Filter") as GraphQLInputObjectType + assertEquals(item.directives.map { it.name }, listOf("tag", "tag")) + assertEquals(filter.directives.map { it.name }, listOf("tag", "tag", "once")) + assertEquals(schema.queryType.directives.map { it.name }, listOf("once")) + assertEquals(item.fieldDefinitions.map { it.name }, listOf("id", "name")) + assertEquals(filter.extensionDefinitions.size, 1) + + val printed = SchemaPrinter().print(schema) + assert(printed.contains("""type Item implements Node @tag(name : "Item") @tag(name : "Item extension") {""")) { printed } + assert(printed.contains("""type Query @once(name : "Query") {""")) { printed } + } + + @Test + fun `undeclared directives on extensions need allowUndeclaredDirectives and are all kept`() { + val error = assertThrows(SchemaError::class.java) { + parser( + """ + type Query { today: String } + extend type Query @key(fields: "upc") + """).makeExecutableSchema() + } + + assertEquals(error.message, "Found applied directive key without corresponding directive definition.") + + // they may be repeatable, like federation's @key + val schema = parser( + """ + type Query @key(fields: "upc") { today: String } + extend type Query @key(fields: "name") + """) { + options(SchemaParserOptions.newOptions().allowUndeclaredDirectives(true).build()) + }.makeExecutableSchema() + + assertEquals(schema.queryType.appliedDirectives.map { it.getArgument("fields")?.getValue() }, listOf("upc", "name")) + } + + @Test + fun `unused unions are reported by their definition and get the members of their extensions when included`() { + val unusedUnion = """ + type Query { today: String } + union FooBar = Foo | Bar + extend union FooBar = Baz + $fooBarBaz + """ + + assertEquals(parser(unusedUnion).getUnusedDefinitions().map { it.javaClass.simpleName to it.name }.toSet(), setOf( + "UnionTypeDefinition" to "FooBar", + "ObjectTypeDefinition" to "Foo", + "ObjectTypeDefinition" to "Bar", + "ObjectTypeDefinition" to "Baz" + )) + + val schema = parser(unusedUnion) { + dictionary(Foo::class, Bar::class, Baz::class) + options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + }.makeExecutableSchema() + + assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) + } + + @Test + fun `repeats and extensions without a type keep building`() { + // these built before extensions were merged, so the repeats only log a warning + val schema = parser( + """ + type Query { + color: Color + node: Node + fooBar: FooBar + } + + extend enum Color { + "The first 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 } + + extend union FooBar = Foo | Bar + extend union FooBar = Bar | Baz + + $fooBarBaz + """) { + dictionary(Item::class, Foo::class, Bar::class, Baz::class) + }.makeExecutableSchema() + + val color = schema.getType("Color") as GraphQLEnumType + assertEquals(color.values.map { it.name }, listOf("RED", "GREEN")) + assertEquals(color.getValue("RED")?.description, "The first 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 `extensions of a different kind than the type are ignored`() { + val schema = parser( + """ + type Query { node: Node } + + interface Node { id: ID! } + extend type Node { name: String } + + type Item implements Node { id: ID! } + extend union Item = Foo + + type Foo { foo: String } + """) { + dictionary(Item::class) + }.makeExecutableSchema() + + val node = schema.getType("Node") as GraphQLInterfaceType + val item = schema.getType("Item") as GraphQLObjectType + assertEquals(node.fieldDefinitions.map { it.name }, listOf("id")) + assertEquals(item.interfaces.map { it.name }, listOf("Node")) + assertEquals(node.extensionDefinitions.size + item.extensionDefinitions.size, 0) + } + + @Test + fun `type errors name the fields of interface extensions that use the type`() { + val error = assertThrows(SchemaError::class.java) { + parser( + """ + |type Query { + | today: String + |} + | + |interface Node { + | id: ID! + |} + | + |extend interface Node { + | search(filter: Filter): String + |} + | + |input Filter { + | id: ID + |} + """.trimMargin()) { + options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + }.makeExecutableSchema() + } + + val message = error.message.orEmpty() + assert(message.startsWith("Type 'Filter' used by argument 'filter' of field 'Node.search' at line 10, column 20 is defined")) { message } + } + + @Test + fun `enums used only by directives get the values of their extensions`() { + val schema = parser( + """ + directive @link(for: Purpose) repeatable on SCHEMA + extend schema @link(for: EXECUTION) + + type Query { today: String } + + enum Purpose { SECURITY } + extend enum Purpose { EXECUTION } + """).makeExecutableSchema() + + assertEquals((schema.getType("Purpose") as GraphQLEnumType).values.map { it.value }, listOf("SECURITY", "EXECUTION")) + assertEquals(schema.getSchemaAppliedDirectives("link").map { it.getArgument("for")?.getValue() }, listOf("EXECUTION")) + } + + private val fooBarBaz = """ + type Foo { foo: String } + type Bar { bar: String } + type Baz { baz: String } + """ + + private fun parser(schema: String, configure: SchemaParserBuilder.() -> Unit = {}) = + SchemaParser.newParser().schemaString(schema).resolvers(Query()).apply(configure).build() + + class Query : GraphQLQueryResolver { + fun fooBar(): FooBar = Baz("baz") + fun barBaz(): FooBar? = null + fun color(): Color = Color.GREEN + fun node(): Node = Item("1", "item") + fun search(filter: Filter?): String? = null + fun today(): String = "2026-10-12" + } + + interface FooBar + + class Foo(val foo: String?) : FooBar + + class Bar(val bar: String?) : FooBar + + class Baz(val baz: String?) : FooBar + + interface Node + + class Item(val id: String, val name: String?, val fooBar: Foo? = null) : Node + + class Filter(val id: String?) + + enum class Color { RED, GREEN } + + private class RecordingWiring : SchemaDirectiveWiring { + val wired = mutableListOf() + + override fun onObject(environment: SchemaDirectiveWiringEnvironment) = record(environment) + + override fun onInterface(environment: SchemaDirectiveWiringEnvironment) = record(environment) + + override fun onUnion(environment: SchemaDirectiveWiringEnvironment) = record(environment) + + override fun onEnum(environment: SchemaDirectiveWiringEnvironment) = record(environment) + + override fun onInputObjectType(environment: SchemaDirectiveWiringEnvironment) = record(environment) + + private fun record(environment: SchemaDirectiveWiringEnvironment): T { + wired.add(environment.appliedDirective.describe()) + return environment.element + } + } +} + +private fun GraphQLAppliedDirective.describe() = "@$name(${getArgument("name")?.getValue()})"