diff --git a/.github/workflows/federation-integration.yml b/.github/workflows/federation-integration.yml index 9dcaa42a1e..846fe7f575 100644 --- a/.github/workflows/federation-integration.yml +++ b/.github/workflows/federation-integration.yml @@ -95,7 +95,7 @@ jobs: run: ./gradlew bootJar graphqlGenerateSDL - name: Compatibility Test - uses: apollographql/federation-subgraph-compatibility@v1 + uses: apollographql/federation-subgraph-compatibility@v2 with: compose: 'docker-compose.yaml' schema: 'build/schema.graphql' diff --git a/examples/federation/supergraph.yaml b/examples/federation/supergraph.yaml index d9f5e32384..d20b882be7 100644 --- a/examples/federation/supergraph.yaml +++ b/examples/federation/supergraph.yaml @@ -1,4 +1,4 @@ -federation_version: =2.4.13 +federation_version: =2.5.4 subgraphs: products: routing_url: http://products:8080/graphql diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaGeneratorHooks.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaGeneratorHooks.kt index 4e0363a51d..506dbad60a 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaGeneratorHooks.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaGeneratorHooks.kt @@ -47,13 +47,16 @@ import com.expediagroup.graphql.generator.federation.directives.PROVIDES_DIRECTI import com.expediagroup.graphql.generator.federation.directives.PROVIDES_DIRECTIVE_TYPE import com.expediagroup.graphql.generator.federation.directives.REQUIRES_DIRECTIVE_NAME import com.expediagroup.graphql.generator.federation.directives.REQUIRES_DIRECTIVE_TYPE +import com.expediagroup.graphql.generator.federation.directives.REQUIRES_SCOPE_DIRECTIVE_NAME import com.expediagroup.graphql.generator.federation.directives.SHAREABLE_DIRECTIVE_NAME import com.expediagroup.graphql.generator.federation.directives.TAG_DIRECTIVE_NAME import com.expediagroup.graphql.generator.federation.directives.keyDirectiveDefinition import com.expediagroup.graphql.generator.federation.directives.linkDirectiveDefinition import com.expediagroup.graphql.generator.federation.directives.providesDirectiveDefinition import com.expediagroup.graphql.generator.federation.directives.requiresDirectiveDefinition +import com.expediagroup.graphql.generator.federation.directives.requiresScopesDirectiveType import com.expediagroup.graphql.generator.federation.directives.toAppliedLinkDirective +import com.expediagroup.graphql.generator.federation.directives.toAppliedRequiresScopesDirective import com.expediagroup.graphql.generator.federation.exception.DuplicateSpecificationLinkImport import com.expediagroup.graphql.generator.federation.exception.IncorrectFederatedDirectiveUsage import com.expediagroup.graphql.generator.federation.exception.UnknownSpecificationException @@ -65,6 +68,7 @@ import com.expediagroup.graphql.generator.federation.types.FIELD_SET_SCALAR_NAME import com.expediagroup.graphql.generator.federation.types.FIELD_SET_SCALAR_TYPE import com.expediagroup.graphql.generator.federation.types.FieldSetTransformer import com.expediagroup.graphql.generator.federation.types.LINK_IMPORT_SCALAR_TYPE +import com.expediagroup.graphql.generator.federation.types.SCOPE_SCALAR_TYPE import com.expediagroup.graphql.generator.federation.types.SERVICE_FIELD_DEFINITION import com.expediagroup.graphql.generator.federation.types._Service import com.expediagroup.graphql.generator.federation.types.generateEntityFieldDefinition @@ -73,6 +77,7 @@ import com.expediagroup.graphql.generator.hooks.SchemaGeneratorHooks import graphql.TypeResolutionEnvironment import graphql.schema.DataFetcher import graphql.schema.FieldCoordinates +import graphql.schema.GraphQLAppliedDirective import graphql.schema.GraphQLCodeRegistry import graphql.schema.GraphQLDirective import graphql.schema.GraphQLNamedType @@ -142,6 +147,18 @@ open class FederatedSchemaGeneratorHooks( } } } + private val scopesScalar: GraphQLScalarType by lazy { + SCOPE_SCALAR_TYPE.run { + val scopesScalarName = namespacedTypeName(FEDERATION_SPEC, this.name) + if (scopesScalarName != this.name) { + this.transform { + it.name(scopesScalarName) + } + } else { + this + } + } + } override fun willBuildSchema( queries: List, @@ -235,9 +252,18 @@ open class FederatedSchemaGeneratorHooks( LINK_DIRECTIVE_NAME -> linkDirectiveDefinition(linkImportScalar) PROVIDES_DIRECTIVE_NAME -> providesDirectiveDefinition(fieldSetScalar) REQUIRES_DIRECTIVE_NAME -> requiresDirectiveDefinition(fieldSetScalar) + REQUIRES_SCOPE_DIRECTIVE_NAME -> requiresScopesDirectiveType(scopesScalar) else -> super.willGenerateDirective(directiveInfo) } + override fun willApplyDirective(directiveInfo: DirectiveMetaInformation, directive: GraphQLDirective): GraphQLAppliedDirective? { + return if (directiveInfo.effectiveName == REQUIRES_SCOPE_DIRECTIVE_NAME) { + directive.toAppliedRequiresScopesDirective(directiveInfo) + } else { + super.willApplyDirective(directiveInfo, directive) + } + } + override fun didGenerateGraphQLType(type: KType, generatedType: GraphQLType): GraphQLType { validator.validateGraphQLType(generatedType) return super.didGenerateGraphQLType(type, generatedType) diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/AuthenticatedDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/AuthenticatedDirective.kt new file mode 100644 index 0000000000..0e9396be16 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/AuthenticatedDirective.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2023 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.federation.directives + +import com.expediagroup.graphql.generator.annotations.GraphQLDirective +import graphql.introspection.Introspection + +/** + * ```graphql + * directive @authenticated on + * ENUM + * | FIELD_DEFINITION + * | INTERFACE + * | OBJECT + * | SCALAR + * ``` + * + * 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. + * Refer to the Apollo Router article for additional details. + * + * @see @authenticated definition + * @see Apollo Router @authenticated documentation + */ +@LinkedSpec(FEDERATION_SPEC) +@Repeatable +@GraphQLDirective( + name = AUTHENTICATED_DIRECTIVE_NAME, + description = AUTHENTICATED_DIRECTIVE_DESCRIPTION, + locations = [ + Introspection.DirectiveLocation.ENUM, + Introspection.DirectiveLocation.FIELD_DEFINITION, + Introspection.DirectiveLocation.INTERFACE, + Introspection.DirectiveLocation.OBJECT, + Introspection.DirectiveLocation.SCALAR, + ] +) +annotation class AuthenticatedDirective + +internal const val AUTHENTICATED_DIRECTIVE_NAME = "requiresScopes" +private const val AUTHENTICATED_DIRECTIVE_DESCRIPTION = "Indicates to composition that the target element is accessible only to the authenticated supergraph users" diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ComposeDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ComposeDirective.kt index a24ba080a6..c7a7ce2bab 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ComposeDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ComposeDirective.kt @@ -46,7 +46,7 @@ import graphql.introspection.Introspection * it will generate following schema * * ```graphql - * 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"){ + * 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"){ * query: Query * } * diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkDirective.kt index 409a3c9587..7fe6e11320 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkDirective.kt @@ -32,7 +32,7 @@ const val LINK_SPEC_URL_PREFIX = "$APOLLO_SPEC_URL/$LINK_SPEC" const val LINK_SPEC_LATEST_URL = "$LINK_SPEC_URL_PREFIX/v$LINK_SPEC_LATEST_VERSION" const val FEDERATION_SPEC = "federation" -const val FEDERATION_SPEC_LATEST_VERSION = "2.3" +const val FEDERATION_SPEC_LATEST_VERSION = "2.5" const val FEDERATION_SPEC_URL_PREFIX = "$APOLLO_SPEC_URL/$FEDERATION_SPEC" const val FEDERATION_SPEC_LATEST_URL = "$FEDERATION_SPEC_URL_PREFIX/v$FEDERATION_SPEC_LATEST_VERSION" @@ -43,7 +43,7 @@ const val FEDERATION_SPEC_LATEST_URL = "$FEDERATION_SPEC_URL_PREFIX/v$FEDERATION * * The `@link` directive links definitions within the document to external schemas. * - * 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"`. + * 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"`. * * 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 * (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 diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/RequiresScopesDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/RequiresScopesDirective.kt new file mode 100644 index 0000000000..2d1bcb1228 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/RequiresScopesDirective.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2023 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.federation.directives + +import com.expediagroup.graphql.generator.annotations.GraphQLDirective +import com.expediagroup.graphql.generator.directives.DirectiveMetaInformation +import graphql.introspection.Introspection +import graphql.schema.GraphQLAppliedDirective +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLList +import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLScalarType +import kotlin.reflect.full.memberProperties + +/** + * ```graphql + * directive @requiresScopes(scopes: [[Scope!]!]!) on + * ENUM + * | FIELD_DEFINITION + * | INTERFACE + * | OBJECT + * | SCALAR + * ``` + * + * 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 + * Apollo Router article for additional details. + * + * @see @requiresScope definition + * @see Apollo Router @requiresScope documentation + */ +@LinkedSpec(FEDERATION_SPEC) +@Repeatable +@GraphQLDirective( + name = REQUIRES_SCOPE_DIRECTIVE_NAME, + description = REQUIRES_SCOPE_DIRECTIVE_DESCRIPTION, + locations = [ + Introspection.DirectiveLocation.ENUM, + Introspection.DirectiveLocation.FIELD_DEFINITION, + Introspection.DirectiveLocation.INTERFACE, + Introspection.DirectiveLocation.OBJECT, + Introspection.DirectiveLocation.SCALAR, + ] +) +annotation class RequiresScopesDirective(val scopes: Array) + +internal const val REQUIRES_SCOPE_DIRECTIVE_NAME = "requiresScopes" +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" +private const val SCOPES_ARGUMENT = "scopes" + +internal fun requiresScopesDirectiveType(scopes: GraphQLScalarType): graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() + .name(REQUIRES_SCOPE_DIRECTIVE_NAME) + .description(REQUIRES_SCOPE_DIRECTIVE_DESCRIPTION) + .validLocations( + Introspection.DirectiveLocation.ENUM, + Introspection.DirectiveLocation.FIELD_DEFINITION, + Introspection.DirectiveLocation.INTERFACE, + Introspection.DirectiveLocation.OBJECT, + Introspection.DirectiveLocation.SCALAR + ) + .argument( + GraphQLArgument.newArgument() + .name("scopes") + .type( + GraphQLNonNull.nonNull( + GraphQLList.list( + GraphQLNonNull( + GraphQLList.list( + scopes + ) + ) + ) + ) + ) + ) + .build() + +@Suppress("UNCHECKED_CAST") +internal fun graphql.schema.GraphQLDirective.toAppliedRequiresScopesDirective(directiveInfo: DirectiveMetaInformation): GraphQLAppliedDirective { + // we need to manually transform @requiresScopes directive definition as JVM does not support nested array as annotation arguments + val annotationScopes = directiveInfo.directive.annotationClass.memberProperties + .first { it.name == SCOPES_ARGUMENT } + .call(directiveInfo.directive) as? Array ?: emptyArray() + val scopes = annotationScopes.map { scopesAnnotation -> scopesAnnotation.value.toList() } + + return this.toAppliedDirective() + .transform { appliedDirectiveBuilder -> + this.getArgument(SCOPES_ARGUMENT) + .toAppliedArgument() + .transform { argumentBuilder -> + argumentBuilder.valueProgrammatic(scopes) + } + .let { + appliedDirectiveBuilder.argument(it) + } + } +} diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/Scope.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/Scope.kt new file mode 100644 index 0000000000..d54cda5081 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/Scope.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.federation.directives + +import com.expediagroup.graphql.generator.annotations.GraphQLIgnore + +/** + * Annotation representing JWT scope scalar type that is used by the `@requiresScope directive. + * + * @param value required JWT scope + * @see [com.expediagroup.graphql.generator.federation.types.SCOPE_SCALAR_TYPE] + */ +@LinkedSpec(FEDERATION_SPEC) +annotation class Scope(val value: String) + +// this is a workaround for JVM lack of support nested arrays as annotation values +@GraphQLIgnore +annotation class Scopes(val value: Array) diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/types/Scope.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/types/Scope.kt new file mode 100644 index 0000000000..f5d159d2f5 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/types/Scope.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2023 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.federation.types + +import com.expediagroup.graphql.generator.federation.directives.Scope +import com.expediagroup.graphql.generator.federation.exception.CoercingValueToLiteralException +import graphql.GraphQLContext +import graphql.Scalars +import graphql.execution.CoercedVariables +import graphql.language.StringValue +import graphql.language.Value +import graphql.schema.Coercing +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingSerializeException +import graphql.schema.GraphQLScalarType +import java.util.Locale + +internal const val SCOPE_SCALAR_NAME = "Scope" + +/** + * Custom scalar type that is used to represent a valid JWT scope which serializes as a String. + */ +internal val SCOPE_SCALAR_TYPE: GraphQLScalarType = GraphQLScalarType.newScalar(Scalars.GraphQLString) + .name(SCOPE_SCALAR_NAME) + .description("Federation type representing a JWT scope") + .coercing(ScopeCoercing) + .build() + +private object ScopeCoercing : Coercing { + override fun serialize(dataFetcherResult: Any, graphQLContext: GraphQLContext, locale: Locale): String = + when (dataFetcherResult) { + is Scope -> dataFetcherResult.value + else -> throw CoercingSerializeException( + "Cannot serialize $dataFetcherResult. Expected type 'Scope' but was '${dataFetcherResult.javaClass.simpleName}'." + ) + } + + override fun parseValue(input: Any, graphQLContext: GraphQLContext, locale: Locale): Scope = + when (input) { + is Scope -> input + is StringValue -> Scope::class.constructors.first().call(input.value) + else -> throw CoercingParseLiteralException( + "Cannot parse $input to Scope. Expected AST type 'StringValue' but was '${input.javaClass.simpleName}'." + ) + } + + override fun parseLiteral(input: Value<*>, variables: CoercedVariables, graphQLContext: GraphQLContext, locale: Locale): Scope = + when (input) { + is StringValue -> Scope::class.constructors.first().call(input.value) + else -> throw CoercingParseLiteralException( + "Cannot parse $input to Scope. Expected AST type 'StringValue' but was '${input.javaClass.simpleName}'." + ) + } + + override fun valueToLiteral(input: Any, graphQLContext: GraphQLContext, locale: Locale): Value<*> = + when (input) { + is Scope -> StringValue.newStringValue(input.value).build() + else -> throw CoercingValueToLiteralException(Scope::class, input) + } +} diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaV2GeneratorTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaV2GeneratorTest.kt index 4bde92131e..5356f94ba9 100644 --- a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaV2GeneratorTest.kt +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaV2GeneratorTest.kt @@ -30,7 +30,7 @@ class FederatedSchemaV2GeneratorTest { fun `verify can generate federated schema`() { val expectedSchema = """ - schema @link(import : ["@external", "@key", "@provides", "@requires", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ + schema @link(import : ["@external", "@key", "@provides", "@requires", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.5"){ query: Query } diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/compose/ComposeDirectiveTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/compose/ComposeDirectiveTest.kt index 8b536ddcd1..0bdc9bf617 100644 --- a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/compose/ComposeDirectiveTest.kt +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/compose/ComposeDirectiveTest.kt @@ -43,7 +43,7 @@ class ComposeDirectiveTest { fun `verify we can generate valid schema with @composeDirective`() { val expectedSchema = """ - schema @composeDirective(name : "custom") @link(as : "myspec", import : ["@custom"], url : "https://www.myspecs.dev/myspec/v1.0") @link(import : ["@composeDirective", "@key", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ + schema @composeDirective(name : "custom") @link(as : "myspec", import : ["@custom"], url : "https://www.myspecs.dev/myspec/v1.0") @link(import : ["@composeDirective", "@key", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.5"){ query: Query } diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/contact/ContactDirectiveTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/contact/ContactDirectiveTest.kt index 0e8fb6e510..ba475eb109 100644 --- a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/contact/ContactDirectiveTest.kt +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/contact/ContactDirectiveTest.kt @@ -30,7 +30,7 @@ class ContactDirectiveTest { fun `verify we can import federation spec using custom @link`() { val expectedSchema = """ - schema @contact(description : "Send emails to foo@myteamname.com", name : "My Team Name", url : "https://myteam.slack.com/room") @link(url : "https://specs.apollo.dev/federation/v2.3"){ + schema @contact(description : "Send emails to foo@myteamname.com", name : "My Team Name", url : "https://myteam.slack.com/room") @link(url : "https://specs.apollo.dev/federation/v2.5"){ query: Query } diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/link/LinkDirectiveTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/link/LinkDirectiveTest.kt index df60e19208..31709b12b3 100644 --- a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/link/LinkDirectiveTest.kt +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/link/LinkDirectiveTest.kt @@ -45,7 +45,7 @@ class LinkDirectiveTest { fun `verify we can import federation spec using custom @link`() { val expectedSchema = """ - schema @link(as : "fed", import : [{name : "@key", as : "@myKey"}], url : "https://specs.apollo.dev/federation/v2.3"){ + schema @link(as : "fed", import : [{name : "@key", as : "@myKey"}], url : "https://specs.apollo.dev/federation/v2.5"){ query: Query } diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/requiresscope/RequiresScopesDirectiveTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/requiresscope/RequiresScopesDirectiveTest.kt new file mode 100644 index 0000000000..68d2843d48 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/requiresscope/RequiresScopesDirectiveTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2023 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.federation.directives.requiresscope + +import com.expediagroup.graphql.generator.TopLevelObject +import com.expediagroup.graphql.generator.extensions.print +import com.expediagroup.graphql.generator.federation.FederatedSchemaGeneratorConfig +import com.expediagroup.graphql.generator.federation.FederatedSchemaGeneratorHooks +import com.expediagroup.graphql.generator.federation.directives.REQUIRES_SCOPE_DIRECTIVE_NAME +import com.expediagroup.graphql.generator.federation.directives.RequiresScopesDirective +import com.expediagroup.graphql.generator.federation.directives.Scope +import com.expediagroup.graphql.generator.federation.directives.Scopes +import com.expediagroup.graphql.generator.federation.toFederatedSchema +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import kotlin.test.assertNotNull + +class RequiresScopesDirectiveTest { + + @Test + fun `verify we can import federation spec using custom @link`() { + val expectedSchema = + """ + schema @link(url : "https://specs.apollo.dev/federation/v2.5"){ + query: Query + } + + "Marks the field, argument, input field or enum value as deprecated" + directive @deprecated( + "The reason for the deprecation" + reason: String = "No longer supported" + ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION + + "Indicates to composition that the target element is accessible only to the authenticated supergraph users with the appropriate JWT scopes" + directive @federation__requiresScopes(scopes: [[federation__Scope]!]!) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + + "Directs the executor to include this field or fragment only when the `if` argument is true" + directive @include( + "Included when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + "Links definitions within the document to external schemas." + directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA + + "Directs the executor to skip this field or fragment when the `if` argument is true." + directive @skip( + "Skipped when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + "Exposes a URL that specifies the behaviour of this scalar." + directive @specifiedBy( + "The URL that specifies the behaviour of this scalar." + url: String! + ) on SCALAR + + type Query { + _service: _Service! + foo: String! @federation__requiresScopes(scopes : [["scope1", "scope2"], ["scope3"]]) + } + + type _Service { + sdl: String! + } + + "Federation type representing a JWT scope" + scalar federation__Scope + + scalar link__Import + """.trimIndent() + + val config = FederatedSchemaGeneratorConfig( + supportedPackages = listOf("com.expediagroup.graphql.generator.federation.directives.requiresscope"), + hooks = FederatedSchemaGeneratorHooks(emptyList()) + ) + + val schema = toFederatedSchema(queries = listOf(TopLevelObject(FooQuery())), config = config) + Assertions.assertEquals(expectedSchema, schema.print().trim()) + val query = schema.getObjectType("Query") + assertNotNull(query) + val fooQuery = query.getField("foo") + assertNotNull(fooQuery) + assertNotNull(fooQuery.hasAppliedDirective(REQUIRES_SCOPE_DIRECTIVE_NAME)) + } + + class FooQuery { + @RequiresScopesDirective(scopes = [Scopes([Scope("scope1"), Scope("scope2")]), Scopes([Scope("scope3")])]) + fun foo(): String = TODO() + } +} diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/execution/ServiceQueryResolverTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/execution/ServiceQueryResolverTest.kt index 2fd9b1b5c9..09a1641c4d 100644 --- a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/execution/ServiceQueryResolverTest.kt +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/execution/ServiceQueryResolverTest.kt @@ -90,7 +90,7 @@ scalar CustomScalar""" const val BASE_SERVICE_SDL = """ -schema @link(url : "https://specs.apollo.dev/federation/v2.3"){ +schema @link(url : "https://specs.apollo.dev/federation/v2.5"){ query: Query } @@ -118,7 +118,7 @@ scalar link__Import const val FEDERATED_SERVICE_SDL_V2 = """ -schema @link(import : ["@external", "@key", "@provides", "@requires", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ +schema @link(import : ["@external", "@key", "@provides", "@requires", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.5"){ query: Query } diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/types/ScopesTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/types/ScopesTest.kt new file mode 100644 index 0000000000..6567bd8683 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/types/ScopesTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2023 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.federation.types + +import com.expediagroup.graphql.generator.federation.directives.Scope +import graphql.GraphQLContext +import graphql.execution.CoercedVariables +import graphql.language.IntValue +import graphql.language.StringValue +import graphql.schema.Coercing +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingSerializeException +import org.junit.jupiter.api.Test +import java.math.BigInteger +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ScopesTest { + private val coercing: Coercing<*, *> = SCOPE_SCALAR_TYPE.coercing + + @Test + fun `serialize should throw exception when not a Scope`() { + assertFailsWith { + coercing.serialize(StringValue("hello"), GraphQLContext.getDefault(), Locale.ENGLISH) + } + } + + @Test + fun `serialize should return the value from Scope`() { + val result = coercing.serialize(Scope("1"), GraphQLContext.getDefault(), Locale.ENGLISH) + assertEquals(expected = "1", actual = result) + } + + @Test + fun `parseValue should parse StringValue`() { + val result = coercing.parseValue(StringValue("scope"), GraphQLContext.getDefault(), Locale.ENGLISH) + assertTrue(result is Scope) + } + + @Test + fun `parseValue should throw exception on non-StringValue`() { + assertFailsWith { + coercing.parseValue(IntValue(BigInteger.ONE), GraphQLContext.getDefault(), Locale.ENGLISH) + } + } + + @Test + fun `parseLiteral should map StringValue to a Scope`() { + val result = coercing.parseLiteral(StringValue("scope"), CoercedVariables.emptyVariables(), GraphQLContext.getDefault(), Locale.ENGLISH) + assertTrue(result is Scope) + } + + @Test + fun `parseLiteral should throw exception on non-StringValue`() { + assertFailsWith { + coercing.parseLiteral(IntValue(BigInteger.ONE), CoercedVariables.emptyVariables(), GraphQLContext.getDefault(), Locale.ENGLISH) + } + } +} diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/hooks/SchemaGeneratorHooks.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/hooks/SchemaGeneratorHooks.kt index 682bb6e348..9d71aaf8ec 100644 --- a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/hooks/SchemaGeneratorHooks.kt +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/hooks/SchemaGeneratorHooks.kt @@ -28,6 +28,7 @@ import com.expediagroup.graphql.generator.exceptions.EmptySubscriptionTypeExcept import com.expediagroup.graphql.generator.internal.extensions.isSubclassOf import com.expediagroup.graphql.generator.internal.extensions.isValidAdditionalType import graphql.schema.FieldCoordinates +import graphql.schema.GraphQLAppliedDirective import graphql.schema.GraphQLCodeRegistry import graphql.schema.GraphQLDirective import graphql.schema.GraphQLFieldDefinition @@ -79,6 +80,11 @@ interface SchemaGeneratorHooks { */ fun willGenerateDirective(directiveInfo: DirectiveMetaInformation): GraphQLDirective? = null + /** + * Called before transforming directive definition to applied directive. This allows for special handling of the directive transformation (e.g. handling nulls, default parameters, etc). + */ + fun willApplyDirective(directiveInfo: DirectiveMetaInformation, directive: GraphQLDirective): GraphQLAppliedDirective? = null + /** * Called after using reflection to generate the graphql object type but before returning it to the schema builder. * This allows for modifying the type info, like description or directives @@ -157,22 +163,27 @@ interface SchemaGeneratorHooks { } /** - * Called after auto-generating the directive from the annotation that allows final transformation before it is applied to a target location. + * Called after auto-generating the directive definition from the annotation that allows final transformation before it is added to the schema document. */ fun didGenerateDirective(directiveInfo: DirectiveMetaInformation, directive: GraphQLDirective): GraphQLDirective = directive /** - * Called after converting the function to a field definition but before adding to the query object to allow customization + * Called after transforming directive definition to applied directive that allows for final transformation before it is applied to a target location. + */ + fun didApplyDirective(directiveInfo: DirectiveMetaInformation, directive: GraphQLAppliedDirective): GraphQLAppliedDirective = directive + + /** + * Called after converting the function to a field definition but before adding to the query object to allow customization. */ fun didGenerateQueryField(kClass: KClass<*>, function: KFunction<*>, fieldDefinition: GraphQLFieldDefinition): GraphQLFieldDefinition = fieldDefinition /** - * Called after converting the function to a field definition but before adding to the mutation object to allow customization + * Called after converting the function to a field definition but before adding to the mutation object to allow customization. */ fun didGenerateMutationField(kClass: KClass<*>, function: KFunction<*>, fieldDefinition: GraphQLFieldDefinition): GraphQLFieldDefinition = fieldDefinition /** - * Called after converting the function to a field definition but before adding to the subscription object to allow customization + * Called after converting the function to a field definition but before adding to the subscription object to allow customization. */ fun didGenerateSubscriptionField(kClass: KClass<*>, function: KFunction<*>, fieldDefinition: GraphQLFieldDefinition): GraphQLFieldDefinition = fieldDefinition diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateDirective.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateDirective.kt index 03d012cc52..10f4e19530 100644 --- a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateDirective.kt +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateDirective.kt @@ -92,24 +92,26 @@ private fun getDirective(generator: SchemaGenerator, directiveInfo: DirectiveMet generator.config.hooks.didGenerateDirective(directiveInfo, generatedDirective) } - return if (directive.arguments.isNotEmpty()) { - directive.toAppliedDirective() - .transform { builder -> - directiveInfo.directive.annotationClass.getValidProperties(generator.config.hooks).forEach { prop -> - directive.getArgument(prop.name) - ?.toAppliedArgument() - ?.transform { argumentBuilder -> - val value = prop.call(directiveInfo.directive) - argumentBuilder.valueProgrammatic(value) - } - ?.let { appliedDirectiveArgument -> - builder.argument(appliedDirectiveArgument) - } + val appliedDirective = generator.config.hooks.willApplyDirective(directiveInfo, directive) + ?: if (directive.arguments.isNotEmpty()) { + directive.toAppliedDirective() + .transform { builder -> + directiveInfo.directive.annotationClass.getValidProperties(generator.config.hooks).forEach { prop -> + directive.getArgument(prop.name) + ?.toAppliedArgument() + ?.transform { argumentBuilder -> + val value = prop.call(directiveInfo.directive) + argumentBuilder.valueProgrammatic(value) + } + ?.let { appliedDirectiveArgument -> + builder.argument(appliedDirectiveArgument) + } + } } - } - } else { - directive.toAppliedDirective() - } + } else { + directive.toAppliedDirective() + } + return generator.config.hooks.didApplyDirective(directiveInfo, appliedDirective) } private fun generateDirectiveArgument(prop: KProperty<*>, generator: SchemaGenerator): GraphQLArgument { diff --git a/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/custom.graphql b/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/custom.graphql index 970d9b3f4d..14847a7894 100644 --- a/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/custom.graphql +++ b/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/custom.graphql @@ -1,4 +1,4 @@ -schema @link(url : "https://specs.apollo.dev/federation/v2.3"){ +schema @link(url : "https://specs.apollo.dev/federation/v2.5"){ query: Query } diff --git a/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/federated.graphql b/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/federated.graphql index 70a9fb3547..04e17b2e09 100644 --- a/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/federated.graphql +++ b/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/federated.graphql @@ -1,4 +1,4 @@ -schema @link(url : "https://specs.apollo.dev/federation/v2.3"){ +schema @link(url : "https://specs.apollo.dev/federation/v2.5"){ query: Query } diff --git a/integration/maven-plugin-integration-tests/integration/generate-sdl-federated/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt b/integration/maven-plugin-integration-tests/integration/generate-sdl-federated/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt index 951691bd31..518b90882f 100755 --- a/integration/maven-plugin-integration-tests/integration/generate-sdl-federated/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt +++ b/integration/maven-plugin-integration-tests/integration/generate-sdl-federated/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt @@ -36,7 +36,7 @@ class GenerateSDLMojoTest { assertTrue(schemaFile.exists(), "schema file was generated") val expectedSchema = """ - schema @link(url : "https://specs.apollo.dev/federation/v2.3"){ + schema @link(url : "https://specs.apollo.dev/federation/v2.5"){ query: Query } diff --git a/integration/maven-plugin-integration-tests/integration/generate-sdl-hooks/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt b/integration/maven-plugin-integration-tests/integration/generate-sdl-hooks/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt index c596dc2bce..942b48e705 100755 --- a/integration/maven-plugin-integration-tests/integration/generate-sdl-hooks/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt +++ b/integration/maven-plugin-integration-tests/integration/generate-sdl-hooks/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt @@ -36,7 +36,7 @@ class GenerateSDLMojoTest { assertTrue(schemaFile.exists(), "schema file was generated") val expectedSchema = """ - schema @link(url : "https://specs.apollo.dev/federation/v2.3"){ + schema @link(url : "https://specs.apollo.dev/federation/v2.5"){ query: Query } diff --git a/plugins/schema/graphql-kotlin-sdl-generator/src/integrationTest/kotlin/com/expediagroup/graphql/plugin/schema/GenerateCustomSDLTest.kt b/plugins/schema/graphql-kotlin-sdl-generator/src/integrationTest/kotlin/com/expediagroup/graphql/plugin/schema/GenerateCustomSDLTest.kt index 8071065e52..75837d7a8e 100644 --- a/plugins/schema/graphql-kotlin-sdl-generator/src/integrationTest/kotlin/com/expediagroup/graphql/plugin/schema/GenerateCustomSDLTest.kt +++ b/plugins/schema/graphql-kotlin-sdl-generator/src/integrationTest/kotlin/com/expediagroup/graphql/plugin/schema/GenerateCustomSDLTest.kt @@ -25,7 +25,7 @@ class GenerateCustomSDLTest { fun `verify we can generate SDL using custom hooks provider`() { val expectedSchema = """ - schema @link(url : "https://specs.apollo.dev/federation/v2.3"){ + schema @link(url : "https://specs.apollo.dev/federation/v2.5"){ query: Query } diff --git a/website/docs/schema-generator/federation/apollo-federation.mdx b/website/docs/schema-generator/federation/apollo-federation.mdx index ffc5c7aeec..f97979b3c0 100644 --- a/website/docs/schema-generator/federation/apollo-federation.mdx +++ b/website/docs/schema-generator/federation/apollo-federation.mdx @@ -119,22 +119,12 @@ toFederatedSchema( will generate ```graphql -schema @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ +schema @link(import : ["@key", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.5"){ query: Query } -directive @composeDirective(name: String!) repeatable on SCHEMA -directive @extends on OBJECT | INTERFACE -directive @external on FIELD_DEFINITION -directive @inaccessible on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION -directive @interfaceObject on OBJECT directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE directive @link(import: [String], url: String!) repeatable on SCHEMA -directive @override(from: String!) on FIELD_DEFINITION -directive @provides(fields: FieldSet!) on FIELD_DEFINITION -directive @requires(fields: FieldSet!) on FIELD_DEFINITION -directive @shareable on OBJECT | FIELD_DEFINITION -directive @tag(name: String!) repeatable on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION type Query { getUsers: [User!]! diff --git a/website/docs/schema-generator/federation/federated-directives.md b/website/docs/schema-generator/federation/federated-directives.md index d34f13273f..0a7f044b92 100644 --- a/website/docs/schema-generator/federation/federated-directives.md +++ b/website/docs/schema-generator/federation/federated-directives.md @@ -4,7 +4,22 @@ title: Federated Directives --- `graphql-kotlin` supports a number of directives that can be used to annotate a schema and direct certain behaviors. -For more details, see the [Apollo Federation Specification](https://www.apollographql.com/docs/federation/federation-spec/). +For more details, see the [Apollo Federation Specification](https://www.apollographql.com/docs/federation/subgraph-spec/). + +## `@authenticated` directive + +```graphql +directive @authenticated on + ENUM + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | SCALAR +``` + +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`[#requirescope-directive] directive usage. Refer to the [Apollo Router documentation](https://www.apollographql.com/docs/router/configuration/authorization#authenticated) +for additional details. ## `@composeDirective` directive @@ -43,7 +58,7 @@ it will generate following schema schema @composeDirective(name: "@custom") @link(import : ["@custom"], url: "https://myspecs.dev/myCustomDirective/v1.0") -@link(url : "https://specs.apollo.dev/federation/v2.3") +@link(url : "https://specs.apollo.dev/federation/v2.5") { query: Query } @@ -362,7 +377,7 @@ scalar Import The `@link` directive links definitions within the document to external schemas. See [@link specification](https://specs.apollo.dev/link/v1.0) for details. External schemas are identified by their `url`, which ends with a name and version with the following format: `{NAME}/v{MAJOR}.{MINOR}`, -e.g. `url = "https://specs.apollo.dev/federation/v2.3"`. +e.g. `url = "https://specs.apollo.dev/federation/v2.5"`. 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 (prefixed with `{NAME}__`) unless they are explicitly @@ -577,6 +592,20 @@ type Product @key(fields : "id") { } ``` +## `@requiresScopes` directive + +```graphql +directive @requiresScopes(scopes: [[Scope!]!]!) on + ENUM + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | SCALAR +``` + +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 +[Apollo Router documentation](https://www.apollographql.com/docs/router/configuration/authorization#requiresscopes) for additional details. + ## `@shareable` directive :::note