Skip to content

Commit d206de6

Browse files
oryan-blockclaude
andcommitted
Apply directives declared on the schema and its extensions
Directives on `schema @foo { ... }` and `extend schema @foo` were never read, so e.g. Apollo Federation v2's `extend schema @link(...)` was missing from the GraphQLSchema and from federation-jvm's _service.sdl. They're now added as schema applied directives, the schema definition's first, then its extensions'. RootTypeInfo also took the last SchemaDefinition, which can be an extension, so `schema { query: MyQuery }` followed by `extend schema @foo` lost the custom root type. Operation types are now merged across the definition and its extensions. Only applied directives are set on the schema: graphql-java doesn't resolve type references in the deprecated legacy schema directives, so an enum argument would fail validation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 349d56a commit d206de6

6 files changed

Lines changed: 186 additions & 11 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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,10 @@ class SchemaParser internal constructor(
104104
val additionalObjects = objects.filter { o -> o != query && o != subscription && o != mutation }
105105

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

110113
/**

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

Lines changed: 84 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,83 @@ 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+
307386
private class BookQueryResolver : GraphQLQueryResolver {
308387
fun book(filter: BookFilter): Book? = null
309388
}
@@ -324,6 +403,11 @@ class DirectiveTest {
324403
val name: String
325404
)
326405

406+
private enum class LinkPurpose {
407+
SECURITY,
408+
EXECUTION
409+
}
410+
327411
private enum class AllowedState {
328412
ALLOWED,
329413
DISALLOWED

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ class SchemaClassScannerTest {
436436
directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
437437
directive @extends on OBJECT | INTERFACE
438438
directive @external on FIELD_DEFINITION | OBJECT
439-
directive @link(url: String!, as: String, for: link__Purpose) repeatable on SCHEMA
439+
directive @link(url: String!, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA
440440
441441
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@shareable"])
442442
@@ -465,7 +465,7 @@ class SchemaClassScannerTest {
465465
.options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build())
466466
.dictionary(User::class)
467467
.dictionary("link__Purpose", LinkPurpose::class)
468-
.scalars(fieldSetScalar)
468+
.scalars(fieldSetScalar, linkImportScalar)
469469
.build()
470470
.makeExecutableSchema()
471471

@@ -488,6 +488,16 @@ class SchemaClassScannerTest {
488488
})
489489
.build()
490490

491+
private val linkImportScalar: GraphQLScalarType = GraphQLScalarType.newScalar()
492+
.name("link__Import")
493+
.coercing(object : Coercing<String, String> {
494+
override fun serialize(input: Any, context: GraphQLContext, locale: Locale) = input.toString()
495+
override fun parseValue(input: Any, context: GraphQLContext, locale: Locale) = input.toString()
496+
override fun parseLiteral(input: Value<*>, variables: CoercedVariables, context: GraphQLContext, locale: Locale) =
497+
input.toString()
498+
})
499+
.build()
500+
491501
class Product {
492502
var name: String? = null
493503
}

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,47 @@ class SchemaParserTest {
628628
assertNull(schema.description)
629629
}
630630

631+
@Test
632+
fun `parser should use root types declared in schema extensions`() {
633+
val schema = SchemaParser.newParser()
634+
.schemaString(
635+
"""
636+
"This is a schema level description"
637+
schema {
638+
query: SubstituteQuery
639+
}
640+
641+
extend schema {
642+
mutation: SubstituteMutation
643+
}
644+
645+
directive @contact(name: String!) on SCHEMA
646+
647+
extend schema @contact(name: "books-team")
648+
649+
type SubstituteQuery {
650+
query: String
651+
}
652+
653+
type SubstituteMutation {
654+
mutation: String
655+
}
656+
""")
657+
.resolvers(
658+
object : GraphQLQueryResolver {
659+
fun query(): String? = null
660+
},
661+
object : GraphQLMutationResolver {
662+
fun mutation(): String? = null
663+
})
664+
.build()
665+
.makeExecutableSchema()
666+
667+
assertEquals(schema.queryType.name, "SubstituteQuery")
668+
assertEquals(schema.mutationType?.name, "SubstituteMutation")
669+
assertEquals(schema.description, "This is a schema level description")
670+
}
671+
631672
enum class EnumType {
632673
TEST
633674
}

0 commit comments

Comments
 (0)