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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 57 additions & 28 deletions src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class SchemaParser internal constructor(
private val codeRegistryBuilder = GraphQLCodeRegistry.newCodeRegistry()
private val directiveWiringHelper = DirectiveWiringHelper(runtimeWiring, codeRegistryBuilder)

private lateinit var schemaDirectives : Set<GraphQLDirective>
private lateinit var schemaDirectives: SchemaDirectives

/**
* Parses the given schema with respect to the given dictionary and returns GraphQL objects.
Expand Down Expand Up @@ -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)
}

/**
Expand Down Expand Up @@ -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()
)
}
Expand Down Expand Up @@ -323,47 +323,76 @@ class SchemaParser internal constructor(
}

private fun createDirectives(inputObjects: MutableList<GraphQLInputObjectType>) {
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<GraphQLInputObjectType>): 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<GraphQLInputObjectType>) {
// 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<String>()

val all: Set<GraphQLDirective> 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()
}

private fun buildAppliedDirectives(directives: List<Directive>): Array<GraphQLAppliedDirective> {
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.")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -535,8 +564,8 @@ class SchemaParser internal constructor(
inputObjects: List<GraphQLInputObjectType>,
referencingInputObjects: MutableSet<String>): 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)
Expand Down
116 changes: 115 additions & 1 deletion src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -419,6 +420,83 @@ class DirectiveTest {
assertEquals(enumValue.getAppliedDirective("owner").getArgument("team")?.getValue<String>(), "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<String>()
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>): 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<Map<String, Int>>(), mapOf("max" to 10))
}
}

@Test
fun `should apply directives on the schema and its extensions`() {
val schema = SchemaParser.newParser()
Expand Down Expand Up @@ -626,6 +704,11 @@ class DirectiveTest {
return environment.element
}

override fun onInputObjectField(environment: SchemaDirectiveWiringEnvironment<GraphQLInputObjectField>): GraphQLInputObjectField {
recordMessage(environment)
return environment.element
}

private fun recordMessage(environment: SchemaDirectiveWiringEnvironment<*>) {
val name = environment.element.name
appliedMessages[name] = environment.appliedDirective.getArgument("message")?.getValue<String>()
Expand All @@ -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<String, List<String>>()

override fun onField(environment: SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition>) = record(environment)

override fun onInputObjectType(environment: SchemaDirectiveWiringEnvironment<GraphQLInputObjectType>) = record(environment)

override fun onInputObjectField(environment: SchemaDirectiveWiringEnvironment<GraphQLInputObjectField>) = record(environment)

private fun <T : GraphQLDirectiveContainer> record(environment: SchemaDirectiveWiringEnvironment<T>): T {
argumentTypes[environment.element.name] =
environment.appliedDirective.arguments.map { GraphQLTypeUtil.unwrapAllAs<GraphQLType>(it.type).javaClass.simpleName }
return environment.element
}
}

private class UppercaseDirective : SchemaDirectiveWiring {
override fun onObject(environment: SchemaDirectiveWiringEnvironment<GraphQLObjectType>): GraphQLObjectType {
val objectType = environment.element
Expand Down
52 changes: 52 additions & 0 deletions src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>()
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>): GraphQLInputObjectField {
val type = GraphQLTypeUtil.unwrapAllAs<GraphQLType>(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()
Expand Down Expand Up @@ -756,6 +802,12 @@ class SchemaParserTest {
fun filter(): String? = null
}

data class NestedFilter(val subs: List<SubFilter>?, val other: OtherFilter)

data class SubFilter(val parent: NestedFilter?)

data class OtherFilter(val name: String?)

class CustomGenericWrapper<T, V>

class Obj {
Expand Down
Loading