Skip to content

Commit b173ea0

Browse files
authored
feat: support Apollo Federation 2.5 (#1839)
### 📝 Description Adds support for [Apollo Federation v2.5](https://www.apollographql.com/docs/federation/federation-versions#v25). Adds new `willApplyDirective`/`didApplyDirective` hooks that can be used to customize transformation of directive definition to applied directive. JVM does not support nested arrays as annotation arguments so we need to apply custom hooks to generate valid `@requiresScopes` directive. New hooks can also be used to filter out default arguments (#1830). New federation directives - `@authenticated` - indicates that target element is only accessible to the authenticated supergraph users - `@requiresScopes` - indicates that target element is only accessible to the authenticated supergraph users with the appropriate JWT scopes Note: due to the potential conflict on directive names we will no longer auto import federation directives. New directives will be auto-namespaced to the target spec. For backwards compatibility, we will continue auto-importing directives up to Federation version 2.3. ### 🔗 Related Issues #1830
1 parent ff88dad commit b173ea0

File tree

25 files changed

+559
-51
lines changed

25 files changed

+559
-51
lines changed

.github/workflows/federation-integration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ jobs:
9595
run: ./gradlew bootJar graphqlGenerateSDL
9696

9797
- name: Compatibility Test
98-
uses: apollographql/federation-subgraph-compatibility@v1
98+
uses: apollographql/federation-subgraph-compatibility@v2
9999
with:
100100
compose: 'docker-compose.yaml'
101101
schema: 'build/schema.graphql'

examples/federation/supergraph.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
federation_version: =2.4.13
1+
federation_version: =2.5.4
22
subgraphs:
33
products:
44
routing_url: http://products:8080/graphql

generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaGeneratorHooks.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,16 @@ import com.expediagroup.graphql.generator.federation.directives.PROVIDES_DIRECTI
4747
import com.expediagroup.graphql.generator.federation.directives.PROVIDES_DIRECTIVE_TYPE
4848
import com.expediagroup.graphql.generator.federation.directives.REQUIRES_DIRECTIVE_NAME
4949
import com.expediagroup.graphql.generator.federation.directives.REQUIRES_DIRECTIVE_TYPE
50+
import com.expediagroup.graphql.generator.federation.directives.REQUIRES_SCOPE_DIRECTIVE_NAME
5051
import com.expediagroup.graphql.generator.federation.directives.SHAREABLE_DIRECTIVE_NAME
5152
import com.expediagroup.graphql.generator.federation.directives.TAG_DIRECTIVE_NAME
5253
import com.expediagroup.graphql.generator.federation.directives.keyDirectiveDefinition
5354
import com.expediagroup.graphql.generator.federation.directives.linkDirectiveDefinition
5455
import com.expediagroup.graphql.generator.federation.directives.providesDirectiveDefinition
5556
import com.expediagroup.graphql.generator.federation.directives.requiresDirectiveDefinition
57+
import com.expediagroup.graphql.generator.federation.directives.requiresScopesDirectiveType
5658
import com.expediagroup.graphql.generator.federation.directives.toAppliedLinkDirective
59+
import com.expediagroup.graphql.generator.federation.directives.toAppliedRequiresScopesDirective
5760
import com.expediagroup.graphql.generator.federation.exception.DuplicateSpecificationLinkImport
5861
import com.expediagroup.graphql.generator.federation.exception.IncorrectFederatedDirectiveUsage
5962
import com.expediagroup.graphql.generator.federation.exception.UnknownSpecificationException
@@ -65,6 +68,7 @@ import com.expediagroup.graphql.generator.federation.types.FIELD_SET_SCALAR_NAME
6568
import com.expediagroup.graphql.generator.federation.types.FIELD_SET_SCALAR_TYPE
6669
import com.expediagroup.graphql.generator.federation.types.FieldSetTransformer
6770
import com.expediagroup.graphql.generator.federation.types.LINK_IMPORT_SCALAR_TYPE
71+
import com.expediagroup.graphql.generator.federation.types.SCOPE_SCALAR_TYPE
6872
import com.expediagroup.graphql.generator.federation.types.SERVICE_FIELD_DEFINITION
6973
import com.expediagroup.graphql.generator.federation.types._Service
7074
import com.expediagroup.graphql.generator.federation.types.generateEntityFieldDefinition
@@ -73,6 +77,7 @@ import com.expediagroup.graphql.generator.hooks.SchemaGeneratorHooks
7377
import graphql.TypeResolutionEnvironment
7478
import graphql.schema.DataFetcher
7579
import graphql.schema.FieldCoordinates
80+
import graphql.schema.GraphQLAppliedDirective
7681
import graphql.schema.GraphQLCodeRegistry
7782
import graphql.schema.GraphQLDirective
7883
import graphql.schema.GraphQLNamedType
@@ -142,6 +147,18 @@ open class FederatedSchemaGeneratorHooks(
142147
}
143148
}
144149
}
150+
private val scopesScalar: GraphQLScalarType by lazy {
151+
SCOPE_SCALAR_TYPE.run {
152+
val scopesScalarName = namespacedTypeName(FEDERATION_SPEC, this.name)
153+
if (scopesScalarName != this.name) {
154+
this.transform {
155+
it.name(scopesScalarName)
156+
}
157+
} else {
158+
this
159+
}
160+
}
161+
}
145162

146163
override fun willBuildSchema(
147164
queries: List<TopLevelObject>,
@@ -235,9 +252,18 @@ open class FederatedSchemaGeneratorHooks(
235252
LINK_DIRECTIVE_NAME -> linkDirectiveDefinition(linkImportScalar)
236253
PROVIDES_DIRECTIVE_NAME -> providesDirectiveDefinition(fieldSetScalar)
237254
REQUIRES_DIRECTIVE_NAME -> requiresDirectiveDefinition(fieldSetScalar)
255+
REQUIRES_SCOPE_DIRECTIVE_NAME -> requiresScopesDirectiveType(scopesScalar)
238256
else -> super.willGenerateDirective(directiveInfo)
239257
}
240258

259+
override fun willApplyDirective(directiveInfo: DirectiveMetaInformation, directive: GraphQLDirective): GraphQLAppliedDirective? {
260+
return if (directiveInfo.effectiveName == REQUIRES_SCOPE_DIRECTIVE_NAME) {
261+
directive.toAppliedRequiresScopesDirective(directiveInfo)
262+
} else {
263+
super.willApplyDirective(directiveInfo, directive)
264+
}
265+
}
266+
241267
override fun didGenerateGraphQLType(type: KType, generatedType: GraphQLType): GraphQLType {
242268
validator.validateGraphQLType(generatedType)
243269
return super.didGenerateGraphQLType(type, generatedType)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2023 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.generator.federation.directives
18+
19+
import com.expediagroup.graphql.generator.annotations.GraphQLDirective
20+
import graphql.introspection.Introspection
21+
22+
/**
23+
* ```graphql
24+
* directive @authenticated on
25+
* ENUM
26+
* | FIELD_DEFINITION
27+
* | INTERFACE
28+
* | OBJECT
29+
* | SCALAR
30+
* ```
31+
*
32+
* Directive that is used to indicate that the target element is accessible only to the authenticated supergraph users. For more granular access control, see the @requiresScopes directive usage.
33+
* Refer to the <a href="https://www.apollographql.com/docs/router/configuration/authorization#authenticated">Apollo Router article</a> for additional details.
34+
*
35+
* @see <a href="https://www.apollographql.com/docs/federation/federated-types/federated-directives#authenticated">@authenticated definition</a>
36+
* @see <a href="https://www.apollographql.com/docs/router/configuration/authorization#authenticated">Apollo Router @authenticated documentation</a>
37+
*/
38+
@LinkedSpec(FEDERATION_SPEC)
39+
@Repeatable
40+
@GraphQLDirective(
41+
name = AUTHENTICATED_DIRECTIVE_NAME,
42+
description = AUTHENTICATED_DIRECTIVE_DESCRIPTION,
43+
locations = [
44+
Introspection.DirectiveLocation.ENUM,
45+
Introspection.DirectiveLocation.FIELD_DEFINITION,
46+
Introspection.DirectiveLocation.INTERFACE,
47+
Introspection.DirectiveLocation.OBJECT,
48+
Introspection.DirectiveLocation.SCALAR,
49+
]
50+
)
51+
annotation class AuthenticatedDirective
52+
53+
internal const val AUTHENTICATED_DIRECTIVE_NAME = "requiresScopes"
54+
private const val AUTHENTICATED_DIRECTIVE_DESCRIPTION = "Indicates to composition that the target element is accessible only to the authenticated supergraph users"

generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ComposeDirective.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import graphql.introspection.Introspection
4646
* it will generate following schema
4747
*
4848
* ```graphql
49-
* schema @composeDirective(name: "@custom") @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){
49+
* schema @composeDirective(name: "@custom") @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.5"){
5050
* query: Query
5151
* }
5252
*

generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkDirective.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const val LINK_SPEC_URL_PREFIX = "$APOLLO_SPEC_URL/$LINK_SPEC"
3232
const val LINK_SPEC_LATEST_URL = "$LINK_SPEC_URL_PREFIX/v$LINK_SPEC_LATEST_VERSION"
3333

3434
const val FEDERATION_SPEC = "federation"
35-
const val FEDERATION_SPEC_LATEST_VERSION = "2.3"
35+
const val FEDERATION_SPEC_LATEST_VERSION = "2.5"
3636
const val FEDERATION_SPEC_URL_PREFIX = "$APOLLO_SPEC_URL/$FEDERATION_SPEC"
3737
const val FEDERATION_SPEC_LATEST_URL = "$FEDERATION_SPEC_URL_PREFIX/v$FEDERATION_SPEC_LATEST_VERSION"
3838

@@ -43,7 +43,7 @@ const val FEDERATION_SPEC_LATEST_URL = "$FEDERATION_SPEC_URL_PREFIX/v$FEDERATION
4343
*
4444
* The `@link` directive links definitions within the document to external schemas.
4545
*
46-
* External schemas are identified by their url, which should end with a specification name and version with the following format: `{NAME}/v{MAJOR}.{MINOR}`, e.g. `url = "https://specs.apollo.dev/federation/v2.3"`.
46+
* External schemas are identified by their url, which should end with a specification name and version with the following format: `{NAME}/v{MAJOR}.{MINOR}`, e.g. `url = "https://specs.apollo.dev/federation/v2.5"`.
4747
*
4848
* External types are associated with the target specification by annotating it with `@LinkedSpec` meta annotation. External types defined in the specification will be automatically namespaced
4949
* (prefixed with `{NAME}__`) unless they are explicitly imported. While both custom namespace (`as`) and import arguments are optional, due to https://github.com/ExpediaGroup/graphql-kotlin/issues/1830
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright 2023 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.generator.federation.directives
18+
19+
import com.expediagroup.graphql.generator.annotations.GraphQLDirective
20+
import com.expediagroup.graphql.generator.directives.DirectiveMetaInformation
21+
import graphql.introspection.Introspection
22+
import graphql.schema.GraphQLAppliedDirective
23+
import graphql.schema.GraphQLArgument
24+
import graphql.schema.GraphQLList
25+
import graphql.schema.GraphQLNonNull
26+
import graphql.schema.GraphQLScalarType
27+
import kotlin.reflect.full.memberProperties
28+
29+
/**
30+
* ```graphql
31+
* directive @requiresScopes(scopes: [[Scope!]!]!) on
32+
* ENUM
33+
* | FIELD_DEFINITION
34+
* | INTERFACE
35+
* | OBJECT
36+
* | SCALAR
37+
* ```
38+
*
39+
* Directive that is used to indicate that the target element is accessible only to the authenticated supergraph users with the appropriate JWT scopes. Refer to the
40+
* <a href="https://www.apollographql.com/docs/router/configuration/authorization#requiresscopes">Apollo Router article</a> for additional details.
41+
*
42+
* @see <a href="https://www.apollographql.com/docs/federation/federated-types/federated-directives#requiresscopes">@requiresScope definition</a>
43+
* @see <a href="https://www.apollographql.com/docs/router/configuration/authorization#requiresscopes">Apollo Router @requiresScope documentation</a>
44+
*/
45+
@LinkedSpec(FEDERATION_SPEC)
46+
@Repeatable
47+
@GraphQLDirective(
48+
name = REQUIRES_SCOPE_DIRECTIVE_NAME,
49+
description = REQUIRES_SCOPE_DIRECTIVE_DESCRIPTION,
50+
locations = [
51+
Introspection.DirectiveLocation.ENUM,
52+
Introspection.DirectiveLocation.FIELD_DEFINITION,
53+
Introspection.DirectiveLocation.INTERFACE,
54+
Introspection.DirectiveLocation.OBJECT,
55+
Introspection.DirectiveLocation.SCALAR,
56+
]
57+
)
58+
annotation class RequiresScopesDirective(val scopes: Array<Scopes>)
59+
60+
internal const val REQUIRES_SCOPE_DIRECTIVE_NAME = "requiresScopes"
61+
private const val REQUIRES_SCOPE_DIRECTIVE_DESCRIPTION = "Indicates to composition that the target element is accessible only to the authenticated supergraph users with the appropriate JWT scopes"
62+
private const val SCOPES_ARGUMENT = "scopes"
63+
64+
internal fun requiresScopesDirectiveType(scopes: GraphQLScalarType): graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective()
65+
.name(REQUIRES_SCOPE_DIRECTIVE_NAME)
66+
.description(REQUIRES_SCOPE_DIRECTIVE_DESCRIPTION)
67+
.validLocations(
68+
Introspection.DirectiveLocation.ENUM,
69+
Introspection.DirectiveLocation.FIELD_DEFINITION,
70+
Introspection.DirectiveLocation.INTERFACE,
71+
Introspection.DirectiveLocation.OBJECT,
72+
Introspection.DirectiveLocation.SCALAR
73+
)
74+
.argument(
75+
GraphQLArgument.newArgument()
76+
.name("scopes")
77+
.type(
78+
GraphQLNonNull.nonNull(
79+
GraphQLList.list(
80+
GraphQLNonNull(
81+
GraphQLList.list(
82+
scopes
83+
)
84+
)
85+
)
86+
)
87+
)
88+
)
89+
.build()
90+
91+
@Suppress("UNCHECKED_CAST")
92+
internal fun graphql.schema.GraphQLDirective.toAppliedRequiresScopesDirective(directiveInfo: DirectiveMetaInformation): GraphQLAppliedDirective {
93+
// we need to manually transform @requiresScopes directive definition as JVM does not support nested array as annotation arguments
94+
val annotationScopes = directiveInfo.directive.annotationClass.memberProperties
95+
.first { it.name == SCOPES_ARGUMENT }
96+
.call(directiveInfo.directive) as? Array<Scopes> ?: emptyArray()
97+
val scopes = annotationScopes.map { scopesAnnotation -> scopesAnnotation.value.toList() }
98+
99+
return this.toAppliedDirective()
100+
.transform { appliedDirectiveBuilder ->
101+
this.getArgument(SCOPES_ARGUMENT)
102+
.toAppliedArgument()
103+
.transform { argumentBuilder ->
104+
argumentBuilder.valueProgrammatic(scopes)
105+
}
106+
.let {
107+
appliedDirectiveBuilder.argument(it)
108+
}
109+
}
110+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2023 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.generator.federation.directives
18+
19+
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
20+
21+
/**
22+
* Annotation representing JWT scope scalar type that is used by the `@requiresScope directive.
23+
*
24+
* @param value required JWT scope
25+
* @see [com.expediagroup.graphql.generator.federation.types.SCOPE_SCALAR_TYPE]
26+
*/
27+
@LinkedSpec(FEDERATION_SPEC)
28+
annotation class Scope(val value: String)
29+
30+
// this is a workaround for JVM lack of support nested arrays as annotation values
31+
@GraphQLIgnore
32+
annotation class Scopes(val value: Array<Scope>)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright 2023 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.generator.federation.types
18+
19+
import com.expediagroup.graphql.generator.federation.directives.Scope
20+
import com.expediagroup.graphql.generator.federation.exception.CoercingValueToLiteralException
21+
import graphql.GraphQLContext
22+
import graphql.Scalars
23+
import graphql.execution.CoercedVariables
24+
import graphql.language.StringValue
25+
import graphql.language.Value
26+
import graphql.schema.Coercing
27+
import graphql.schema.CoercingParseLiteralException
28+
import graphql.schema.CoercingSerializeException
29+
import graphql.schema.GraphQLScalarType
30+
import java.util.Locale
31+
32+
internal const val SCOPE_SCALAR_NAME = "Scope"
33+
34+
/**
35+
* Custom scalar type that is used to represent a valid JWT scope which serializes as a String.
36+
*/
37+
internal val SCOPE_SCALAR_TYPE: GraphQLScalarType = GraphQLScalarType.newScalar(Scalars.GraphQLString)
38+
.name(SCOPE_SCALAR_NAME)
39+
.description("Federation type representing a JWT scope")
40+
.coercing(ScopeCoercing)
41+
.build()
42+
43+
private object ScopeCoercing : Coercing<Scope, String> {
44+
override fun serialize(dataFetcherResult: Any, graphQLContext: GraphQLContext, locale: Locale): String =
45+
when (dataFetcherResult) {
46+
is Scope -> dataFetcherResult.value
47+
else -> throw CoercingSerializeException(
48+
"Cannot serialize $dataFetcherResult. Expected type 'Scope' but was '${dataFetcherResult.javaClass.simpleName}'."
49+
)
50+
}
51+
52+
override fun parseValue(input: Any, graphQLContext: GraphQLContext, locale: Locale): Scope =
53+
when (input) {
54+
is Scope -> input
55+
is StringValue -> Scope::class.constructors.first().call(input.value)
56+
else -> throw CoercingParseLiteralException(
57+
"Cannot parse $input to Scope. Expected AST type 'StringValue' but was '${input.javaClass.simpleName}'."
58+
)
59+
}
60+
61+
override fun parseLiteral(input: Value<*>, variables: CoercedVariables, graphQLContext: GraphQLContext, locale: Locale): Scope =
62+
when (input) {
63+
is StringValue -> Scope::class.constructors.first().call(input.value)
64+
else -> throw CoercingParseLiteralException(
65+
"Cannot parse $input to Scope. Expected AST type 'StringValue' but was '${input.javaClass.simpleName}'."
66+
)
67+
}
68+
69+
override fun valueToLiteral(input: Any, graphQLContext: GraphQLContext, locale: Locale): Value<*> =
70+
when (input) {
71+
is Scope -> StringValue.newStringValue(input.value).build()
72+
else -> throw CoercingValueToLiteralException(Scope::class, input)
73+
}
74+
}

generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaV2GeneratorTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class FederatedSchemaV2GeneratorTest {
3030
fun `verify can generate federated schema`() {
3131
val expectedSchema =
3232
"""
33-
schema @link(import : ["@external", "@key", "@provides", "@requires", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){
33+
schema @link(import : ["@external", "@key", "@provides", "@requires", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.5"){
3434
query: Query
3535
}
3636

0 commit comments

Comments
 (0)