Skip to content

Commit 0d3e8e3

Browse files
authored
Merge branch 'master' into bugfix/479
2 parents 910756f + 47cf27c commit 0d3e8e3

3 files changed

Lines changed: 224 additions & 29 deletions

File tree

‎src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt‎

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class SchemaParser internal constructor(
6262
private val codeRegistryBuilder = GraphQLCodeRegistry.newCodeRegistry()
6363
private val directiveWiringHelper = DirectiveWiringHelper(runtimeWiring, codeRegistryBuilder)
6464

65-
private lateinit var schemaDirectives : Set<GraphQLDirective>
65+
private lateinit var schemaDirectives: SchemaDirectives
6666

6767
/**
6868
* Parses the given schema with respect to the given dictionary and returns GraphQL objects.
@@ -112,7 +112,7 @@ class SchemaParser internal constructor(
112112
// Directives applied to the schema itself, e.g. `extend schema @link(...)`. Only GraphQLAppliedDirectives are built here, not
113113
// the deprecated GraphQLDirective ones: graphql-java doesn't resolve type references in those, so enum arguments would fail.
114114
val schemaAppliedDirectives = buildAppliedDirectives(rootInfo.getDirectives()).toList()
115-
return SchemaObjects(query, mutation, subscription, types, schemaDirectives, codeRegistryBuilder, rootInfo.getDescription(), schemaAppliedDirectives)
115+
return SchemaObjects(query, mutation, subscription, types, schemaDirectives.all, codeRegistryBuilder, rootInfo.getDescription(), schemaAppliedDirectives)
116116
}
117117

118118
/**
@@ -187,7 +187,7 @@ class SchemaParser internal constructor(
187187
.apply { getDeprecated(fieldDefinition.directives)?.let { deprecate(it) } }
188188
.type(determineInputType(fieldDefinition.type, inputObjects, referencingInputObjects))
189189
.withAppliedDirectives(*buildAppliedDirectives(fieldDefinition.directives))
190-
.withDirectives(*buildDirectives(definition.directives, INPUT_FIELD_DEFINITION))
190+
.withDirectives(*buildDirectives(fieldDefinition.directives, INPUT_FIELD_DEFINITION))
191191
.build()
192192
)
193193
}
@@ -323,47 +323,76 @@ class SchemaParser internal constructor(
323323
}
324324

325325
private fun createDirectives(inputObjects: MutableList<GraphQLInputObjectType>) {
326-
schemaDirectives = directiveDefinitions.map { definition ->
327-
val locations = definition.directiveLocations.map { Introspection.DirectiveLocation.valueOf(it.name) }.toTypedArray()
328-
329-
GraphQLDirective.newDirective()
330-
.name(definition.name)
331-
.description(getDocumentation(definition, options))
332-
.definition(definition)
333-
.comparatorRegistry(runtimeWiring.comparatorRegistry)
334-
.validLocations(*locations)
335-
.repeatable(definition.isRepeatable)
336-
.apply {
337-
definition.inputValueDefinitions.forEach { argumentDefinition ->
338-
argument(createDirectiveArgument(argumentDefinition, inputObjects))
339-
}
340-
}
341-
.build()
342-
}.toSet()
326+
schemaDirectives = SchemaDirectives(inputObjects)
327+
directiveDefinitions.forEach { schemaDirectives.find(it.name) }
343328
// because the arguments can have directives too, we attach them only after the directives themselves are created
344-
schemaDirectives = schemaDirectives.map { d ->
329+
schemaDirectives.update { d ->
345330
val arguments = d.arguments.map { a -> a.transform {
346331
it.withAppliedDirectives(*buildAppliedDirectives(a.definition!!.directives))
347332
.withDirectives(*buildDirectives(a.definition!!.directives, Introspection.DirectiveLocation.OBJECT))
348333
} }
349334
d.transform { it.replaceArguments(arguments) }
350-
}.toSet()
335+
}
351336
}
352337

353-
private fun createDirectiveArgument(definition: InputValueDefinition, inputObjects: List<GraphQLInputObjectType>): GraphQLArgument {
338+
/**
339+
* The directive definitions. The input objects used by their arguments need the directives applied to them, so each directive
340+
* is first built with type references to input objects, then rebuilt with the input objects the first time it's looked up.
341+
* Only a directive applied within its own argument types gets its first build there.
342+
*/
343+
private inner class SchemaDirectives(private val inputObjects: MutableList<GraphQLInputObjectType>) {
344+
// by name, in the order they're declared
345+
private val byName = directiveDefinitions.associateTo(linkedMapOf()) { definition ->
346+
definition.name to createDirective(definition) { determineType(GraphQLInputType::class, it, permittedTypesForInputObject, inputObjects) as GraphQLInputType }
347+
}
348+
private val unfinished = directiveDefinitions.associateByTo(mutableMapOf()) { it.name }
349+
// shared, so that an input object that's still being built isn't built again for another directive
350+
private val referencingInputObjects = mutableSetOf<String>()
351+
352+
val all: Set<GraphQLDirective> get() = byName.values.toSet()
353+
354+
fun find(name: String): GraphQLDirective? {
355+
unfinished.remove(name)?.let { definition ->
356+
byName[name] = createDirective(definition) { determineInputType(it, inputObjects, referencingInputObjects) }
357+
}
358+
return byName[name]
359+
}
360+
361+
fun update(transform: (GraphQLDirective) -> GraphQLDirective) = byName.replaceAll { _, directive -> transform(directive) }
362+
}
363+
364+
private fun createDirective(definition: DirectiveDefinition, determineArgumentType: (Type<*>) -> GraphQLInputType): GraphQLDirective {
365+
val locations = definition.directiveLocations.map { Introspection.DirectiveLocation.valueOf(it.name) }.toTypedArray()
366+
367+
return GraphQLDirective.newDirective()
368+
.name(definition.name)
369+
.description(getDocumentation(definition, options))
370+
.definition(definition)
371+
.comparatorRegistry(runtimeWiring.comparatorRegistry)
372+
.validLocations(*locations)
373+
.repeatable(definition.isRepeatable)
374+
.apply {
375+
definition.inputValueDefinitions.forEach { argumentDefinition ->
376+
argument(createDirectiveArgument(argumentDefinition, determineArgumentType))
377+
}
378+
}
379+
.build()
380+
}
381+
382+
private fun createDirectiveArgument(definition: InputValueDefinition, determineArgumentType: (Type<*>) -> GraphQLInputType): GraphQLArgument {
354383
return GraphQLArgument.newArgument()
355384
.name(definition.name)
356385
.definition(definition)
357386
.description(getDocumentation(definition, options))
358-
.type(determineInputType(definition.type, inputObjects, mutableSetOf()))
387+
.type(determineArgumentType(definition.type))
359388
.apply { getDeprecated(definition.directives)?.let { deprecate(it) } }
360389
.apply { definition.defaultValue?.let { defaultValueLiteral(it) } }
361390
.build()
362391
}
363392

364393
private fun buildAppliedDirectives(directives: List<Directive>): Array<GraphQLAppliedDirective> {
365394
return directives.map { directive ->
366-
val graphQLDirective = schemaDirectives.find { d -> d.name == directive.name }
395+
val graphQLDirective = schemaDirectives.find(directive.name)
367396
?: BUILT_IN_DIRECTIVES[directive.name]
368397
?: if (options.allowUndeclaredDirectives) return@map buildUndeclaredAppliedDirective(directive)
369398
else throw SchemaError("Found applied directive ${directive.name} without corresponding directive definition.")
@@ -448,7 +477,7 @@ class SchemaParser internal constructor(
448477
val repeatable = directiveDefinitions.find { it.name.equals(directive.name) }?.isRepeatable ?: false
449478
if (repeatable || !names.contains(directive.name)) {
450479
names.add(directive.name)
451-
val graphQLDirective = this.schemaDirectives.find { d -> d.name == directive.name }
480+
val graphQLDirective = schemaDirectives.find(directive.name)
452481
?: BUILT_IN_DIRECTIVES[directive.name]
453482
// graphql-java rejects legacy directives without a definition, so undeclared ones are only kept as applied directives
454483
?: if (options.allowUndeclaredDirectives) continue
@@ -534,8 +563,8 @@ class SchemaParser internal constructor(
534563
inputObjects: List<GraphQLInputObjectType>,
535564
referencingInputObjects: MutableSet<String>): GraphQLInputType =
536565
when (typeDefinition) {
537-
is ListType -> GraphQLList(determineType(expectedType, typeDefinition.type, allowedTypeReferences, inputObjects))
538-
is NonNullType -> GraphQLNonNull(determineType(expectedType, typeDefinition.type, allowedTypeReferences, inputObjects))
566+
is ListType -> GraphQLList(determineInputType(expectedType, typeDefinition.type, allowedTypeReferences, inputObjects, referencingInputObjects))
567+
is NonNullType -> GraphQLNonNull(determineInputType(expectedType, typeDefinition.type, allowedTypeReferences, inputObjects, referencingInputObjects))
539568
is InputObjectTypeDefinition -> {
540569
log.info("Create input object")
541570
createInputObject(typeDefinition, inputObjects, referencingInputObjects)

‎src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt‎

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,8 @@ class DirectiveTest {
408408
val expectedMessages = mapOf(
409409
"contactEmail" to "{path} must be a valid email",
410410
"primaryEmail" to "{path} must be a valid email",
411-
"backupEmail" to "invalid backup email"
411+
"backupEmail" to "invalid backup email",
412+
"email" to "{path} must be a valid email"
412413
)
413414
assertEquals(emailDirective.appliedMessages, expectedMessages)
414415
assertEquals(emailDirective.legacyMessages, expectedMessages)
@@ -419,6 +420,83 @@ class DirectiveTest {
419420
assertEquals(enumValue.getAppliedDirective("owner").getArgument("team")?.getValue<String>(), "books-team")
420421
}
421422

423+
@Test
424+
fun `should build input objects used by directive arguments before wiring`() {
425+
val meta = "directive @meta(info: ConfigInput!) on FIELD_DEFINITION"
426+
val option = "directive @option(value: OptionInput) on INPUT_OBJECT | INPUT_FIELD_DEFINITION"
427+
// ConfigInput is built for @meta and needs @option, whichever is declared first
428+
listOf("$meta\n$option", "$option\n$meta").forEach { directives ->
429+
val recorder = ArgumentTypeRecorder()
430+
SchemaParser.newParser()
431+
.schemaString(
432+
"""
433+
$directives
434+
435+
type Query {
436+
name: String @meta(info: { level: 1 })
437+
}
438+
439+
input ConfigInput @option(value: { label: "type" }) {
440+
level: Int @option(value: { label: "field" })
441+
}
442+
443+
input OptionInput {
444+
label: String
445+
}
446+
""")
447+
.resolvers(object : GraphQLQueryResolver {
448+
fun name(): String? = null
449+
})
450+
.dictionary(ConfigInput::class, OptionInput::class)
451+
.directive("meta", recorder)
452+
.directive("option", recorder)
453+
.build()
454+
.makeExecutableSchema()
455+
456+
val inputObject = listOf("GraphQLInputObjectType")
457+
val expectedTypes = mapOf("name" to inputObject, "ConfigInput" to inputObject, "level" to inputObject)
458+
assert(recorder.argumentTypes == expectedTypes) { "${recorder.argumentTypes} with directives declared as $directives" }
459+
}
460+
}
461+
462+
@Test
463+
fun `should wire input objects only once when a directive is applied within its own argument types`() {
464+
val limit = "directive @limit(bound: BoundInput!) on INPUT_FIELD_DEFINITION"
465+
val limits = "directive @limits(bounds: [BoundInput!]) on FIELD_DEFINITION"
466+
listOf("$limits\n$limit", "$limit\n$limits").forEach { directives ->
467+
val wiredFields = mutableListOf<String>()
468+
val schema = SchemaParser.newParser()
469+
.schemaString(
470+
"""
471+
$directives
472+
473+
type Query {
474+
name: String @limits(bounds: [{ max: 1 }])
475+
}
476+
477+
input BoundInput {
478+
max: Int @limit(bound: { max: 10 })
479+
}
480+
""")
481+
.resolvers(object : GraphQLQueryResolver {
482+
fun name(): String? = null
483+
})
484+
.dictionary(BoundInput::class)
485+
.directive("limit", object : SchemaDirectiveWiring {
486+
override fun onInputObjectField(environment: SchemaDirectiveWiringEnvironment<GraphQLInputObjectField>): GraphQLInputObjectField {
487+
wiredFields.add(environment.element.name)
488+
return environment.element
489+
}
490+
})
491+
.build()
492+
.makeExecutableSchema()
493+
494+
assert(wiredFields == listOf("max")) { "$wiredFields with directives declared as $directives" }
495+
val limit = (schema.getType("BoundInput") as GraphQLInputObjectType).getField("max").getAppliedDirective("limit")
496+
assertEquals(limit.getArgument("bound")?.getValue<Map<String, Int>>(), mapOf("max" to 10))
497+
}
498+
}
499+
422500
@Test
423501
fun `should apply directives on the schema and its extensions`() {
424502
val schema = SchemaParser.newParser()
@@ -626,6 +704,11 @@ class DirectiveTest {
626704
return environment.element
627705
}
628706

707+
override fun onInputObjectField(environment: SchemaDirectiveWiringEnvironment<GraphQLInputObjectField>): GraphQLInputObjectField {
708+
recordMessage(environment)
709+
return environment.element
710+
}
711+
629712
private fun recordMessage(environment: SchemaDirectiveWiringEnvironment<*>) {
630713
val name = environment.element.name
631714
appliedMessages[name] = environment.appliedDirective.getArgument("message")?.getValue<String>()
@@ -634,6 +717,37 @@ class DirectiveTest {
634717
}
635718
}
636719

720+
private data class ConfigInput(
721+
val level: Int?
722+
)
723+
724+
private data class OptionInput(
725+
val label: String?
726+
)
727+
728+
private data class BoundInput(
729+
val max: Int?
730+
)
731+
732+
/**
733+
* Records the unwrapped types of the arguments of each applied directive, by the name of the element it's applied to.
734+
*/
735+
private class ArgumentTypeRecorder : SchemaDirectiveWiring {
736+
val argumentTypes = mutableMapOf<String, List<String>>()
737+
738+
override fun onField(environment: SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition>) = record(environment)
739+
740+
override fun onInputObjectType(environment: SchemaDirectiveWiringEnvironment<GraphQLInputObjectType>) = record(environment)
741+
742+
override fun onInputObjectField(environment: SchemaDirectiveWiringEnvironment<GraphQLInputObjectField>) = record(environment)
743+
744+
private fun <T : GraphQLDirectiveContainer> record(environment: SchemaDirectiveWiringEnvironment<T>): T {
745+
argumentTypes[environment.element.name] =
746+
environment.appliedDirective.arguments.map { GraphQLTypeUtil.unwrapAllAs<GraphQLType>(it.type).javaClass.simpleName }
747+
return environment.element
748+
}
749+
}
750+
637751
private class UppercaseDirective : SchemaDirectiveWiring {
638752
override fun onObject(environment: SchemaDirectiveWiringEnvironment<GraphQLObjectType>): GraphQLObjectType {
639753
val objectType = environment.element

‎src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,52 @@ class SchemaParserTest {
812812
assert(testNullableArgument.type is GraphQLInputObjectType)
813813
}
814814

815+
@Test
816+
fun `input objects behind list and non-null input fields should resolve to GraphQLInputObjectType during directive wiring`() {
817+
val wiredFieldTypes = mutableMapOf<String, String>()
818+
SchemaParser.newParser()
819+
.schemaString(
820+
"""
821+
type Query {
822+
test(filter: NestedFilter): Boolean
823+
}
824+
825+
input NestedFilter {
826+
subs: [SubFilter!]
827+
other: OtherFilter!
828+
}
829+
830+
input SubFilter {
831+
parent: NestedFilter
832+
}
833+
834+
input OtherFilter {
835+
name: String
836+
}
837+
""")
838+
.resolvers(object : GraphQLQueryResolver {
839+
fun test(filter: NestedFilter?): Boolean = false
840+
})
841+
.directiveWiring(object : SchemaDirectiveWiring {
842+
override fun onInputObjectField(environment: SchemaDirectiveWiringEnvironment<GraphQLInputObjectField>): GraphQLInputObjectField {
843+
val type = GraphQLTypeUtil.unwrapAllAs<GraphQLType>(environment.element.type)
844+
if (type !is GraphQLScalarType) {
845+
wiredFieldTypes[environment.element.name] = type.javaClass.simpleName
846+
}
847+
return environment.element
848+
}
849+
})
850+
.build()
851+
.makeExecutableSchema()
852+
853+
// parent points back at NestedFilter while it's still being built, so it stays a type reference until the schema is built
854+
assertEquals(wiredFieldTypes, mapOf(
855+
"subs" to "GraphQLInputObjectType",
856+
"other" to "GraphQLInputObjectType",
857+
"parent" to "GraphQLTypeReference"
858+
))
859+
}
860+
815861
@Test
816862
fun `parser should use comments for descriptions`() {
817863
val schema = SchemaParser.newParser()
@@ -979,6 +1025,12 @@ class SchemaParserTest {
9791025
fun filter(): String? = null
9801026
}
9811027

1028+
data class NestedFilter(val subs: List<SubFilter>?, val other: OtherFilter)
1029+
1030+
data class SubFilter(val parent: NestedFilter?)
1031+
1032+
data class OtherFilter(val name: String?)
1033+
9821034
class CustomGenericWrapper<T, V>
9831035

9841036
class Obj {

0 commit comments

Comments
 (0)