Skip to content

Commit

Permalink
feat: support Apollo Federation 2.5
Browse files Browse the repository at this point in the history
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 (ExpediaGroup#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.
  • Loading branch information
dariuszkuc committed Sep 11, 2023
1 parent 1401099 commit 0d0031b
Show file tree
Hide file tree
Showing 24 changed files with 558 additions and 50 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/federation-integration.yml
Expand Up @@ -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'
Expand Down
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<TopLevelObject>,
Expand Down Expand Up @@ -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)
Expand Down
@@ -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 <a href="https://www.apollographql.com/docs/router/configuration/authorization#authenticated">Apollo Router article</a> for additional details.
*
* @see <a href="https://www.apollographql.com/docs/federation/federated-types/federated-directives#authenticated">@authenticated definition</a>
* @see <a href="https://www.apollographql.com/docs/router/configuration/authorization#authenticated">Apollo Router @authenticated documentation</a>
*/
@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"
Expand Up @@ -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
* }
*
Expand Down
Expand Up @@ -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"

Expand All @@ -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
Expand Down
@@ -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
* <a href="https://www.apollographql.com/docs/router/configuration/authorization#requiresscopes">Apollo Router article</a> for additional details.
*
* @see <a href="https://www.apollographql.com/docs/federation/federated-types/federated-directives#requiresscopes">@requiresScope definition</a>
* @see <a href="https://www.apollographql.com/docs/router/configuration/authorization#requiresscopes">Apollo Router @requiresScope documentation</a>
*/
@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<Scopes>)

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<Scopes> ?: 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)
}
}
}
@@ -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<Scope>)
@@ -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<Scope, String> {
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)
}
}
Expand Up @@ -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
}
Expand Down
Expand Up @@ -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
}
Expand Down

0 comments on commit 0d0031b

Please sign in to comment.