diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 3e2a2115..068c21d2 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -62,7 +62,7 @@ class SchemaParser internal constructor( private val codeRegistryBuilder = GraphQLCodeRegistry.newCodeRegistry() private val directiveWiringHelper = DirectiveWiringHelper(runtimeWiring, codeRegistryBuilder) - private lateinit var schemaDirectives : Set + private lateinit var schemaDirectives: SchemaDirectives /** * Parses the given schema with respect to the given dictionary and returns GraphQL objects. @@ -112,7 +112,7 @@ class SchemaParser internal constructor( // Directives applied to the schema itself, e.g. `extend schema @link(...)`. Only GraphQLAppliedDirectives are built here, not // the deprecated GraphQLDirective ones: graphql-java doesn't resolve type references in those, so enum arguments would fail. val schemaAppliedDirectives = buildAppliedDirectives(rootInfo.getDirectives()).toList() - return SchemaObjects(query, mutation, subscription, types, schemaDirectives, codeRegistryBuilder, rootInfo.getDescription(), schemaAppliedDirectives) + return SchemaObjects(query, mutation, subscription, types, schemaDirectives.all, codeRegistryBuilder, rootInfo.getDescription(), schemaAppliedDirectives) } /** @@ -187,7 +187,7 @@ class SchemaParser internal constructor( .apply { getDeprecated(fieldDefinition.directives)?.let { deprecate(it) } } .type(determineInputType(fieldDefinition.type, inputObjects, referencingInputObjects)) .withAppliedDirectives(*buildAppliedDirectives(fieldDefinition.directives)) - .withDirectives(*buildDirectives(definition.directives, INPUT_FIELD_DEFINITION)) + .withDirectives(*buildDirectives(fieldDefinition.directives, INPUT_FIELD_DEFINITION)) .build() ) } @@ -323,39 +323,68 @@ class SchemaParser internal constructor( } private fun createDirectives(inputObjects: MutableList) { - schemaDirectives = directiveDefinitions.map { definition -> - val locations = definition.directiveLocations.map { Introspection.DirectiveLocation.valueOf(it.name) }.toTypedArray() - - GraphQLDirective.newDirective() - .name(definition.name) - .description(getDocumentation(definition, options)) - .definition(definition) - .comparatorRegistry(runtimeWiring.comparatorRegistry) - .validLocations(*locations) - .repeatable(definition.isRepeatable) - .apply { - definition.inputValueDefinitions.forEach { argumentDefinition -> - argument(createDirectiveArgument(argumentDefinition, inputObjects)) - } - } - .build() - }.toSet() + schemaDirectives = SchemaDirectives(inputObjects) + directiveDefinitions.forEach { schemaDirectives.find(it.name) } // because the arguments can have directives too, we attach them only after the directives themselves are created - schemaDirectives = schemaDirectives.map { d -> + schemaDirectives.update { d -> val arguments = d.arguments.map { a -> a.transform { it.withAppliedDirectives(*buildAppliedDirectives(a.definition!!.directives)) .withDirectives(*buildDirectives(a.definition!!.directives, Introspection.DirectiveLocation.OBJECT)) } } d.transform { it.replaceArguments(arguments) } - }.toSet() + } } - private fun createDirectiveArgument(definition: InputValueDefinition, inputObjects: List): GraphQLArgument { + /** + * The directive definitions. The input objects used by their arguments need the directives applied to them, so each directive + * is first built with type references to input objects, then rebuilt with the input objects the first time it's looked up. + * Only a directive applied within its own argument types gets its first build there. + */ + private inner class SchemaDirectives(private val inputObjects: MutableList) { + // by name, in the order they're declared + private val byName = directiveDefinitions.associateTo(linkedMapOf()) { definition -> + definition.name to createDirective(definition) { determineType(GraphQLInputType::class, it, permittedTypesForInputObject, inputObjects) as GraphQLInputType } + } + private val unfinished = directiveDefinitions.associateByTo(mutableMapOf()) { it.name } + // shared, so that an input object that's still being built isn't built again for another directive + private val referencingInputObjects = mutableSetOf() + + val all: Set get() = byName.values.toSet() + + fun find(name: String): GraphQLDirective? { + unfinished.remove(name)?.let { definition -> + byName[name] = createDirective(definition) { determineInputType(it, inputObjects, referencingInputObjects) } + } + return byName[name] + } + + fun update(transform: (GraphQLDirective) -> GraphQLDirective) = byName.replaceAll { _, directive -> transform(directive) } + } + + private fun createDirective(definition: DirectiveDefinition, determineArgumentType: (Type<*>) -> GraphQLInputType): GraphQLDirective { + val locations = definition.directiveLocations.map { Introspection.DirectiveLocation.valueOf(it.name) }.toTypedArray() + + return GraphQLDirective.newDirective() + .name(definition.name) + .description(getDocumentation(definition, options)) + .definition(definition) + .comparatorRegistry(runtimeWiring.comparatorRegistry) + .validLocations(*locations) + .repeatable(definition.isRepeatable) + .apply { + definition.inputValueDefinitions.forEach { argumentDefinition -> + argument(createDirectiveArgument(argumentDefinition, determineArgumentType)) + } + } + .build() + } + + private fun createDirectiveArgument(definition: InputValueDefinition, determineArgumentType: (Type<*>) -> GraphQLInputType): GraphQLArgument { return GraphQLArgument.newArgument() .name(definition.name) .definition(definition) .description(getDocumentation(definition, options)) - .type(determineInputType(definition.type, inputObjects, mutableSetOf())) + .type(determineArgumentType(definition.type)) .apply { getDeprecated(definition.directives)?.let { deprecate(it) } } .apply { definition.defaultValue?.let { defaultValueLiteral(it) } } .build() @@ -363,7 +392,7 @@ class SchemaParser internal constructor( private fun buildAppliedDirectives(directives: List): Array { return directives.map { directive -> - val graphQLDirective = schemaDirectives.find { d -> d.name == directive.name } + val graphQLDirective = schemaDirectives.find(directive.name) ?: BUILT_IN_DIRECTIVES[directive.name] ?: if (options.allowUndeclaredDirectives) return@map buildUndeclaredAppliedDirective(directive) else throw SchemaError("Found applied directive ${directive.name} without corresponding directive definition.") @@ -448,7 +477,7 @@ class SchemaParser internal constructor( val repeatable = directiveDefinitions.find { it.name.equals(directive.name) }?.isRepeatable ?: false if (repeatable || !names.contains(directive.name)) { names.add(directive.name) - val graphQLDirective = this.schemaDirectives.find { d -> d.name == directive.name } + val graphQLDirective = schemaDirectives.find(directive.name) ?: BUILT_IN_DIRECTIVES[directive.name] // graphql-java rejects legacy directives without a definition, so undeclared ones are only kept as applied directives ?: if (options.allowUndeclaredDirectives) continue @@ -535,8 +564,8 @@ class SchemaParser internal constructor( inputObjects: List, referencingInputObjects: MutableSet): GraphQLInputType = when (typeDefinition) { - is ListType -> GraphQLList(determineType(expectedType, typeDefinition.type, allowedTypeReferences, inputObjects)) - is NonNullType -> GraphQLNonNull(determineType(expectedType, typeDefinition.type, allowedTypeReferences, inputObjects)) + is ListType -> GraphQLList(determineInputType(expectedType, typeDefinition.type, allowedTypeReferences, inputObjects, referencingInputObjects)) + is NonNullType -> GraphQLNonNull(determineInputType(expectedType, typeDefinition.type, allowedTypeReferences, inputObjects, referencingInputObjects)) is InputObjectTypeDefinition -> { log.info("Create input object") createInputObject(typeDefinition, inputObjects, referencingInputObjects) diff --git a/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt b/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt index 1d9417b3..b6503e34 100644 --- a/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt @@ -408,7 +408,8 @@ class DirectiveTest { val expectedMessages = mapOf( "contactEmail" to "{path} must be a valid email", "primaryEmail" to "{path} must be a valid email", - "backupEmail" to "invalid backup email" + "backupEmail" to "invalid backup email", + "email" to "{path} must be a valid email" ) assertEquals(emailDirective.appliedMessages, expectedMessages) assertEquals(emailDirective.legacyMessages, expectedMessages) @@ -419,6 +420,83 @@ class DirectiveTest { assertEquals(enumValue.getAppliedDirective("owner").getArgument("team")?.getValue(), "books-team") } + @Test + fun `should build input objects used by directive arguments before wiring`() { + val meta = "directive @meta(info: ConfigInput!) on FIELD_DEFINITION" + val option = "directive @option(value: OptionInput) on INPUT_OBJECT | INPUT_FIELD_DEFINITION" + // ConfigInput is built for @meta and needs @option, whichever is declared first + listOf("$meta\n$option", "$option\n$meta").forEach { directives -> + val recorder = ArgumentTypeRecorder() + SchemaParser.newParser() + .schemaString( + """ + $directives + + type Query { + name: String @meta(info: { level: 1 }) + } + + input ConfigInput @option(value: { label: "type" }) { + level: Int @option(value: { label: "field" }) + } + + input OptionInput { + label: String + } + """) + .resolvers(object : GraphQLQueryResolver { + fun name(): String? = null + }) + .dictionary(ConfigInput::class, OptionInput::class) + .directive("meta", recorder) + .directive("option", recorder) + .build() + .makeExecutableSchema() + + val inputObject = listOf("GraphQLInputObjectType") + val expectedTypes = mapOf("name" to inputObject, "ConfigInput" to inputObject, "level" to inputObject) + assert(recorder.argumentTypes == expectedTypes) { "${recorder.argumentTypes} with directives declared as $directives" } + } + } + + @Test + fun `should wire input objects only once when a directive is applied within its own argument types`() { + val limit = "directive @limit(bound: BoundInput!) on INPUT_FIELD_DEFINITION" + val limits = "directive @limits(bounds: [BoundInput!]) on FIELD_DEFINITION" + listOf("$limits\n$limit", "$limit\n$limits").forEach { directives -> + val wiredFields = mutableListOf() + val schema = SchemaParser.newParser() + .schemaString( + """ + $directives + + type Query { + name: String @limits(bounds: [{ max: 1 }]) + } + + input BoundInput { + max: Int @limit(bound: { max: 10 }) + } + """) + .resolvers(object : GraphQLQueryResolver { + fun name(): String? = null + }) + .dictionary(BoundInput::class) + .directive("limit", object : SchemaDirectiveWiring { + override fun onInputObjectField(environment: SchemaDirectiveWiringEnvironment): GraphQLInputObjectField { + wiredFields.add(environment.element.name) + return environment.element + } + }) + .build() + .makeExecutableSchema() + + assert(wiredFields == listOf("max")) { "$wiredFields with directives declared as $directives" } + val limit = (schema.getType("BoundInput") as GraphQLInputObjectType).getField("max").getAppliedDirective("limit") + assertEquals(limit.getArgument("bound")?.getValue>(), mapOf("max" to 10)) + } + } + @Test fun `should apply directives on the schema and its extensions`() { val schema = SchemaParser.newParser() @@ -626,6 +704,11 @@ class DirectiveTest { return environment.element } + override fun onInputObjectField(environment: SchemaDirectiveWiringEnvironment): GraphQLInputObjectField { + recordMessage(environment) + return environment.element + } + private fun recordMessage(environment: SchemaDirectiveWiringEnvironment<*>) { val name = environment.element.name appliedMessages[name] = environment.appliedDirective.getArgument("message")?.getValue() @@ -634,6 +717,37 @@ class DirectiveTest { } } + private data class ConfigInput( + val level: Int? + ) + + private data class OptionInput( + val label: String? + ) + + private data class BoundInput( + val max: Int? + ) + + /** + * Records the unwrapped types of the arguments of each applied directive, by the name of the element it's applied to. + */ + private class ArgumentTypeRecorder : SchemaDirectiveWiring { + val argumentTypes = mutableMapOf>() + + override fun onField(environment: SchemaDirectiveWiringEnvironment) = record(environment) + + override fun onInputObjectType(environment: SchemaDirectiveWiringEnvironment) = record(environment) + + override fun onInputObjectField(environment: SchemaDirectiveWiringEnvironment) = record(environment) + + private fun record(environment: SchemaDirectiveWiringEnvironment): T { + argumentTypes[environment.element.name] = + environment.appliedDirective.arguments.map { GraphQLTypeUtil.unwrapAllAs(it.type).javaClass.simpleName } + return environment.element + } + } + private class UppercaseDirective : SchemaDirectiveWiring { override fun onObject(environment: SchemaDirectiveWiringEnvironment): GraphQLObjectType { val objectType = environment.element diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt index 4017ba52..cfcd88f0 100644 --- a/src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt @@ -591,6 +591,52 @@ class SchemaParserTest { assert(testNullableArgument.type is GraphQLInputObjectType) } + @Test + fun `input objects behind list and non-null input fields should resolve to GraphQLInputObjectType during directive wiring`() { + val wiredFieldTypes = mutableMapOf() + SchemaParser.newParser() + .schemaString( + """ + type Query { + test(filter: NestedFilter): Boolean + } + + input NestedFilter { + subs: [SubFilter!] + other: OtherFilter! + } + + input SubFilter { + parent: NestedFilter + } + + input OtherFilter { + name: String + } + """) + .resolvers(object : GraphQLQueryResolver { + fun test(filter: NestedFilter?): Boolean = false + }) + .directiveWiring(object : SchemaDirectiveWiring { + override fun onInputObjectField(environment: SchemaDirectiveWiringEnvironment): GraphQLInputObjectField { + val type = GraphQLTypeUtil.unwrapAllAs(environment.element.type) + if (type !is GraphQLScalarType) { + wiredFieldTypes[environment.element.name] = type.javaClass.simpleName + } + return environment.element + } + }) + .build() + .makeExecutableSchema() + + // parent points back at NestedFilter while it's still being built, so it stays a type reference until the schema is built + assertEquals(wiredFieldTypes, mapOf( + "subs" to "GraphQLInputObjectType", + "other" to "GraphQLInputObjectType", + "parent" to "GraphQLTypeReference" + )) + } + @Test fun `parser should use comments for descriptions`() { val schema = SchemaParser.newParser() @@ -756,6 +802,12 @@ class SchemaParserTest { fun filter(): String? = null } + data class NestedFilter(val subs: List?, val other: OtherFilter) + + data class SubFilter(val parent: NestedFilter?) + + data class OtherFilter(val name: String?) + class CustomGenericWrapper class Obj {