Skip to content

Commit e3bf5cc

Browse files
authored
Merge pull request #824 from graphql-java-kickstart/bugfix/717
Support directives on the schema
2 parents fc4376f + c8b67b7 commit e3bf5cc

7 files changed

Lines changed: 301 additions & 15 deletions

File tree

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

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package graphql.kickstart.tools
22

33
import graphql.language.Description
4+
import graphql.language.Directive
45
import graphql.language.SchemaDefinition
6+
import graphql.language.SchemaExtensionDefinition
57
import graphql.language.TypeName
68

79
/**
@@ -11,27 +13,38 @@ internal class RootTypeInfo private constructor(
1113
private val queryType: TypeName?,
1214
private val mutationType: TypeName?,
1315
private val subscriptionType: TypeName?,
14-
private val description: Description?
16+
private val description: Description?,
17+
private val directives: List<Directive>
1518
) {
1619
companion object {
1720
const val DEFAULT_QUERY_NAME = "Query"
1821
const val DEFAULT_MUTATION_NAME = "Mutation"
1922
const val DEFAULT_SUBSCRIPTION_NAME = "Subscription"
2023

2124
fun fromSchemaDefinitions(definitions: List<SchemaDefinition>): RootTypeInfo {
22-
val queryType = definitions.lastOrNull()?.operationTypeDefinitions?.find { it.name == "query" }?.typeName
23-
val mutationType = definitions.lastOrNull()?.operationTypeDefinitions?.find { it.name == "mutation" }?.typeName
24-
val subscriptionType = definitions.lastOrNull()?.operationTypeDefinitions?.find { it.name == "subscription" }?.typeName
25-
val description = definitions.lastOrNull()?.description
25+
// SchemaExtensionDefinition is a subclass of SchemaDefinition, so `definitions` contains the extensions too
26+
val schemaDefinition = definitions.lastOrNull { it !is SchemaExtensionDefinition }
27+
val extensionDefinitions = definitions.filterIsInstance<SchemaExtensionDefinition>()
28+
// the schema definition comes first, then its extensions
29+
val allDefinitions = listOfNotNull(schemaDefinition) + extensionDefinitions
2630

27-
return RootTypeInfo(queryType, mutationType, subscriptionType, description)
31+
val operationTypes = allDefinitions.flatMap { it.operationTypeDefinitions }.associate { it.name to it.typeName }
32+
33+
return RootTypeInfo(
34+
operationTypes["query"],
35+
operationTypes["mutation"],
36+
operationTypes["subscription"],
37+
schemaDefinition?.description,
38+
allDefinitions.flatMap { it.directives }
39+
)
2840
}
2941
}
3042

3143
fun getQueryName() = queryType?.name ?: DEFAULT_QUERY_NAME
3244
fun getMutationName() = mutationType?.name ?: DEFAULT_MUTATION_NAME
3345
fun getSubscriptionName() = subscriptionType?.name ?: DEFAULT_SUBSCRIPTION_NAME
3446
fun getDescription() = description?.content
47+
fun getDirectives() = directives
3548

3649
fun isMutationRequired() = mutationType != null
3750
fun isSubscriptionRequired() = subscriptionType != null

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,38 @@ import graphql.schema.*
55
/**
66
* @author Andrew Potter
77
*/
8-
data class SchemaObjects(
8+
data class SchemaObjects @JvmOverloads constructor(
99
val query: GraphQLObjectType,
1010
val mutation: GraphQLObjectType?,
1111
val subscription: GraphQLObjectType?,
1212
val dictionary: Set<GraphQLType>,
1313
val directives: Set<GraphQLDirective>,
1414
val codeRegistryBuilder: GraphQLCodeRegistry.Builder,
15-
val description: String?
15+
val description: String?,
16+
// directives applied to the schema itself, e.g. `extend schema @link(...)`
17+
val schemaAppliedDirectives: List<GraphQLAppliedDirective> = emptyList()
1618
) {
19+
// keeps the copy() signature from before schemaAppliedDirectives was added, for binary compatibility
20+
@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN)
21+
fun copy(
22+
query: GraphQLObjectType = this.query,
23+
mutation: GraphQLObjectType? = this.mutation,
24+
subscription: GraphQLObjectType? = this.subscription,
25+
dictionary: Set<GraphQLType> = this.dictionary,
26+
directives: Set<GraphQLDirective> = this.directives,
27+
codeRegistryBuilder: GraphQLCodeRegistry.Builder = this.codeRegistryBuilder,
28+
description: String? = this.description
29+
) = copy(
30+
query = query,
31+
mutation = mutation,
32+
subscription = subscription,
33+
dictionary = dictionary,
34+
directives = directives,
35+
codeRegistryBuilder = codeRegistryBuilder,
36+
description = description,
37+
schemaAppliedDirectives = schemaAppliedDirectives
38+
)
39+
1740
// TODO change dictionary to Set<GraphQLNamedType> in the next major version and remove this cast
1841
@Suppress("UNCHECKED_CAST")
1942
private fun namedDictionary(): Set<GraphQLNamedType> = dictionary as Set<GraphQLNamedType>
@@ -29,6 +52,7 @@ data class SchemaObjects(
2952
.subscription(subscription)
3053
.additionalTypes(namedDictionary())
3154
.additionalDirectives(directives)
55+
.withSchemaAppliedDirectives(schemaAppliedDirectives)
3256
.codeRegistry(codeRegistryBuilder.build())
3357
.build()
3458
}

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

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package graphql.kickstart.tools
22

33
import graphql.Directives
4+
import graphql.Scalars
45
import graphql.introspection.Introspection
56
import graphql.introspection.Introspection.DirectiveLocation.INPUT_FIELD_DEFINITION
67
import graphql.kickstart.tools.directive.DirectiveWiringHelper
@@ -104,7 +105,10 @@ class SchemaParser internal constructor(
104105
val additionalObjects = objects.filter { o -> o != query && o != subscription && o != mutation }
105106

106107
val types = (additionalObjects.toSet() as Set<GraphQLType>) + inputObjects + enums + interfaces + unions
107-
return SchemaObjects(query, mutation, subscription, types, schemaDirectives, codeRegistryBuilder, rootInfo.getDescription())
108+
// Directives applied to the schema itself, e.g. `extend schema @link(...)`. Only GraphQLAppliedDirectives are built here, not
109+
// the deprecated GraphQLDirective ones: graphql-java doesn't resolve type references in those, so enum arguments would fail.
110+
val schemaAppliedDirectives = buildAppliedDirectives(rootInfo.getDirectives()).toList()
111+
return SchemaObjects(query, mutation, subscription, types, schemaDirectives, codeRegistryBuilder, rootInfo.getDescription(), schemaAppliedDirectives)
108112
}
109113

110114
/**
@@ -352,7 +356,8 @@ class SchemaParser internal constructor(
352356
return directives.map { directive ->
353357
val graphQLDirective = schemaDirectives.find { d -> d.name == directive.name }
354358
?: BUILT_IN_DIRECTIVES[directive.name]
355-
?: throw SchemaError("Found applied directive ${directive.name} without corresponding directive definition.")
359+
?: if (options.allowUndeclaredDirectives) return@map buildUndeclaredAppliedDirective(directive)
360+
else throw SchemaError("Found applied directive ${directive.name} without corresponding directive definition.")
356361
val graphQLArguments = graphQLDirective.arguments.associateBy { it.name }
357362

358363
GraphQLAppliedDirective.newDirective()
@@ -394,6 +399,36 @@ class SchemaParser internal constructor(
394399
}.toTypedArray()
395400
}
396401

402+
private fun buildUndeclaredAppliedDirective(directive: Directive): GraphQLAppliedDirective {
403+
return GraphQLAppliedDirective.newDirective()
404+
.name(directive.name)
405+
.definition(directive)
406+
.comparatorRegistry(runtimeWiring.comparatorRegistry)
407+
.apply {
408+
directive.arguments.forEach { arg ->
409+
argument(GraphQLAppliedDirectiveArgument.newArgument()
410+
.name(arg.name)
411+
.type(guessDirectiveArgumentType(directive, arg.name, arg.value))
412+
.valueLiteral(arg.value)
413+
.build()
414+
)
415+
}
416+
}
417+
.build()
418+
}
419+
420+
// there's no directive definition to look the type up in, so it's guessed from the value
421+
private fun guessDirectiveArgumentType(directive: Directive, argumentName: String, value: Value<*>): GraphQLInputType {
422+
return when (value) {
423+
is StringValue, is NullValue -> Scalars.GraphQLString
424+
is IntValue -> Scalars.GraphQLInt
425+
is FloatValue -> Scalars.GraphQLFloat
426+
is BooleanValue -> Scalars.GraphQLBoolean
427+
is ArrayValue -> GraphQLList(value.values.firstOrNull()?.let { guessDirectiveArgumentType(directive, argumentName, it) } ?: Scalars.GraphQLString)
428+
else -> throw SchemaError("Can't guess the type of argument ${directive.name}#$argumentName of undeclared directive ${directive.name}, please declare the directive.")
429+
}
430+
}
431+
397432
// TODO remove this once directives are fully replaced with applied directives
398433
private fun buildDirectives(
399434
directives: List<Directive>,
@@ -408,7 +443,9 @@ class SchemaParser internal constructor(
408443
names.add(directive.name)
409444
val graphQLDirective = this.schemaDirectives.find { d -> d.name == directive.name }
410445
?: BUILT_IN_DIRECTIVES[directive.name]
411-
?: throw SchemaError("Found applied directive ${directive.name} without corresponding directive definition.")
446+
// graphql-java rejects legacy directives without a definition, so undeclared ones are only kept as applied directives
447+
?: if (options.allowUndeclaredDirectives) continue
448+
else throw SchemaError("Found applied directive ${directive.name} without corresponding directive definition.")
412449
val graphQLArguments = graphQLDirective.arguments.associateBy { it.name }
413450
output.add(
414451
GraphQLDirective.newDirective()

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ data class SchemaParserOptions internal constructor(
3636
val typeDefinitionFactories: List<TypeDefinitionFactory>,
3737
val fieldVisibility: GraphqlFieldVisibility?,
3838
val includeUnusedTypes: Boolean,
39-
val useCommentsForDescriptions: Boolean
39+
val useCommentsForDescriptions: Boolean,
40+
val allowUndeclaredDirectives: Boolean
4041
) {
4142
companion object {
4243
@JvmStatic
@@ -67,6 +68,7 @@ data class SchemaParserOptions internal constructor(
6768
private var fieldVisibility: GraphqlFieldVisibility? = null
6869
private var includeUnusedTypes = false
6970
private var useCommentsForDescriptions = true
71+
private var allowUndeclaredDirectives = false
7072

7173
fun contextClass(contextClass: Class<*>) = this.apply {
7274
this.contextClass = contextClass
@@ -153,6 +155,14 @@ data class SchemaParserOptions internal constructor(
153155
this.useCommentsForDescriptions = useCommentsForDescriptions
154156
}
155157

158+
/**
159+
* Allows applying directives that have no definition in the schema, e.g. the Apollo Federation directives imported through `@link`.
160+
* They're only added as applied directives, with argument types guessed from their values.
161+
*/
162+
fun allowUndeclaredDirectives(allowUndeclaredDirectives: Boolean) = this.apply {
163+
this.allowUndeclaredDirectives = allowUndeclaredDirectives
164+
}
165+
156166
@ExperimentalCoroutinesApi
157167
fun build(): SchemaParserOptions {
158168
val coroutineContextProvider = coroutineContextProvider
@@ -194,7 +204,8 @@ data class SchemaParserOptions internal constructor(
194204
typeDefinitionFactories,
195205
fieldVisibility,
196206
includeUnusedTypes,
197-
useCommentsForDescriptions
207+
useCommentsForDescriptions,
208+
allowUndeclaredDirectives
198209
)
199210
}
200211
}

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

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import graphql.relay.SimpleListConnection
77
import graphql.schema.*
88
import graphql.schema.idl.SchemaDirectiveWiring
99
import graphql.schema.idl.SchemaDirectiveWiringEnvironment
10+
import graphql.schema.idl.SchemaPrinter
11+
import org.junit.Assert.assertThrows
1012
import org.junit.Test
1113

1214
class DirectiveTest {
@@ -304,6 +306,149 @@ class DirectiveTest {
304306
assert((schema.getType("Book") as GraphQLObjectType).getField("name").isDeprecated)
305307
}
306308

309+
@Test
310+
fun `should apply directives on the schema and its extensions`() {
311+
val schema = SchemaParser.newParser()
312+
.schemaString(
313+
"""
314+
directive @link(url: String!, for: LinkPurpose, import: [String]) repeatable on SCHEMA
315+
directive @contact(name: String!) on SCHEMA
316+
317+
enum LinkPurpose {
318+
SECURITY
319+
EXECUTION
320+
}
321+
322+
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@shareable"])
323+
324+
schema @contact(name: "books-team") {
325+
query: Query
326+
}
327+
328+
extend schema @link(url: "https://specs.apollo.dev/link/v1.0", for: SECURITY)
329+
330+
type Query {
331+
books: [Book!]
332+
}
333+
334+
type Book {
335+
id: Int!
336+
name: String!
337+
}
338+
""")
339+
.resolvers(QueryResolver())
340+
.dictionary(LinkPurpose::class)
341+
.build()
342+
.makeExecutableSchema()
343+
344+
assertEquals(schema.schemaAppliedDirectives.map { it.name }, listOf("contact", "link", "link"))
345+
assertEquals(
346+
schema.getSchemaAppliedDirectives("link").map { it.getArgument("url")?.getValue<String>() },
347+
listOf("https://specs.apollo.dev/federation/v2.3", "https://specs.apollo.dev/link/v1.0")
348+
)
349+
assertEquals(
350+
schema.getSchemaAppliedDirectives("link").first().getArgument("import")?.getValue<List<String>>(),
351+
listOf("@key", "@shareable")
352+
)
353+
assertEquals(schema.getSchemaAppliedDirectives("link").last().getArgument("for")?.getValue<LinkPurpose>(), LinkPurpose.SECURITY)
354+
355+
val printed = SchemaPrinter(SchemaPrinter.Options.defaultOptions().includeSchemaDefinition(true)).print(schema)
356+
assert(printed.contains("""schema @contact(name : "books-team") @link(import : ["@key", "@shareable"], url : "https://specs.apollo.dev/federation/v2.3") @link(for : SECURITY, url : "https://specs.apollo.dev/link/v1.0"){""")) {
357+
printed
358+
}
359+
}
360+
361+
@Test
362+
fun `should fail on undeclared schema directive`() {
363+
val error = assertThrows(SchemaError::class.java) {
364+
SchemaParser.newParser()
365+
.schemaString(
366+
"""
367+
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3")
368+
369+
type Query {
370+
books: [Book!]
371+
}
372+
373+
type Book {
374+
id: Int!
375+
name: String!
376+
}
377+
""")
378+
.resolvers(QueryResolver())
379+
.build()
380+
.makeExecutableSchema()
381+
}
382+
383+
assertEquals(error.message, "Found applied directive link without corresponding directive definition.")
384+
}
385+
386+
@Test
387+
fun `should allow undeclared directives when enabled`() {
388+
val schema = SchemaParser.newParser()
389+
.schemaString(
390+
"""
391+
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@shareable"])
392+
393+
type Query {
394+
books: [Book!] @shareable
395+
}
396+
397+
type Book @key(fields: "id", resolvable: true) @custom(weight: 1.5, limit: 3, tag: null) {
398+
id: Int!
399+
name: String!
400+
}
401+
""")
402+
.resolvers(QueryResolver())
403+
.options(SchemaParserOptions.newOptions().allowUndeclaredDirectives(true).build())
404+
.build()
405+
.makeExecutableSchema()
406+
407+
val link = schema.getSchemaAppliedDirective("link")
408+
assertEquals(link.getArgument("import")?.getValue<List<String>>(), listOf("@key", "@shareable"))
409+
assertNotNull(schema.queryType.getField("books").getAppliedDirective("shareable"))
410+
411+
val book = schema.getObjectType("Book")!!
412+
val key = book.getAppliedDirective("key")
413+
assertEquals(key.getArgument("fields")?.getValue<String>(), "id")
414+
assertEquals(key.getArgument("resolvable")?.getValue<Boolean>(), true)
415+
val custom = book.getAppliedDirective("custom")
416+
assertEquals(custom.getArgument("weight")?.getValue<Double>(), 1.5)
417+
assertEquals(custom.getArgument("limit")?.getValue<Int>(), 3)
418+
assertNull(custom.getArgument("tag")?.getValue<String>())
419+
// graphql-java rejects legacy directives without a definition
420+
assert(book.directives.isEmpty())
421+
422+
val printed = SchemaPrinter(SchemaPrinter.Options.defaultOptions().includeSchemaDefinition(true)).print(schema)
423+
assert(printed.contains("""type Book @custom(limit : 3, tag : null, weight : 1.5) @key(fields : "id", resolvable : true) {""")) {
424+
printed
425+
}
426+
}
427+
428+
@Test
429+
fun `should fail on undeclared directive with an argument whose type can't be guessed`() {
430+
val error = assertThrows(SchemaError::class.java) {
431+
SchemaParser.newParser()
432+
.schemaString(
433+
"""
434+
type Query {
435+
books: [Book!] @policy(purpose: SECURITY)
436+
}
437+
438+
type Book {
439+
id: Int!
440+
name: String!
441+
}
442+
""")
443+
.resolvers(QueryResolver())
444+
.options(SchemaParserOptions.newOptions().allowUndeclaredDirectives(true).build())
445+
.build()
446+
.makeExecutableSchema()
447+
}
448+
449+
assertEquals(error.message, "Can't guess the type of argument policy#purpose of undeclared directive policy, please declare the directive.")
450+
}
451+
307452
private class BookQueryResolver : GraphQLQueryResolver {
308453
fun book(filter: BookFilter): Book? = null
309454
}
@@ -324,6 +469,11 @@ class DirectiveTest {
324469
val name: String
325470
)
326471

472+
private enum class LinkPurpose {
473+
SECURITY,
474+
EXECUTION
475+
}
476+
327477
private enum class AllowedState {
328478
ALLOWED,
329479
DISALLOWED

0 commit comments

Comments
 (0)