Skip to content

Commit

Permalink
BREAKING(feat(federation)): full @link support (#1816)
Browse files Browse the repository at this point in the history
### 📝 Description

Introduce full `@link` support that includes namespacing and renaming of
the imported elements.

The `@link` directive allows users to link definitions within the
document to external schemas. It is the core feature of the Apollo
federation v2. While imported elements still need to have their
definitions in the local schema, `@link` allows users to namespace
and/or rename those items to avoid any local type conflicts. By default,
all external types that are not explicitly imported have to be
namespaced using the spec name or a provided custom namespace.

Updated `@link` definition

```graphql
directive @link(url: String!, as: String, import: [link__Import]) repeatable on SCHEMA

scalar link__Import
```

* url - external specification url
* as - optional custom namespace
* import - list of elements to import, can either be simple Strings
(e.g. `@key`) or custom imports that rename elements (e.g. `{ name:
"@key", as: "@mykey" }`)

By default, `graphql-kotlin` will continue to apply `@link` directive
using latest supported federation specification but will only
auto-import federation specific directives up to version 2.3 and only if
they are present (i.e. applied to an element) in the schema. All new fed
v2.4+ won't be included in the auto-imports and instead will be
namespaced with the spec name, e.g. `@federation__authenticated`.

Users can provide custom `@link` information by providing a schema
object with `@LinkDirective` information, e.g.

```kotlin
@LinkDirective(url = "https://specs.apollo.dev/federation/v2.3", `as`: "fed", import = [LinkImport(name = "@key", `as` = "@mykey"), LinkImport(name = "@requires")])
class MyCustomLinkSchema
```

Will generate following schema

```graphql
schema @link(as: "fed", import : [{name : "@key", as : "@mykey"}, "@requires"], url : "https://specs.apollo.dev/federation/v2.3"){
  query: Query
}

// directive imported with custom name
"Space separated list of primary keys needed to access federated object"
directive @mykey(fields: fed__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE

// directive imported with same name
"Specifies required input field set from the base type for a resolver"
directive @requires(fields: fed__FieldSet!) on FIELD_DEFINITION

// type imported with custom namespace
"Federation type representing set of fields"
scalar fed__FieldSet
```

When importing custom specifications, in order to be able to identify
whether element is part of custom specification, we need to annotate it
with new `@LinkedSpec` annotation. All federation directives were
updated to rely on this mechanism, e.g.

```kotlin
@LinkedSpec(FEDERATION_SPEC)
@repeatable
@GraphQLDirective(
    name = KEY_DIRECTIVE_NAME,
    description = KEY_DIRECTIVE_DESCRIPTION,
    locations = [DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE]
)
annotation class KeyDirective(val fields: FieldSet, val resolvable: Boolean = true)
```

### 🔗 Related Issues
  • Loading branch information
dariuszkuc committed Sep 8, 2023
1 parent e64e80f commit 1401099
Show file tree
Hide file tree
Showing 43 changed files with 1,261 additions and 493 deletions.
2 changes: 1 addition & 1 deletion examples/federation/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
router:
image: ghcr.io/apollographql/router:v1.10.1
image: ghcr.io/apollographql/router:v1.29.1
volumes:
- ./router.yaml:/dist/config/router.yaml
- ./supergraph.graphql:/dist/config/supergraph.graphql
Expand Down
2 changes: 1 addition & 1 deletion examples/federation/supergraph.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
federation_version: =2.4.8
federation_version: =2.4.13
subgraphs:
products:
routing_url: http://products:8080/graphql
Expand Down
4 changes: 2 additions & 2 deletions generator/graphql-kotlin-federation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ tasks {
limit {
counter = "INSTRUCTION"
value = "COVEREDRATIO"
minimum = "0.96".toBigDecimal()
minimum = "0.95".toBigDecimal()
}
limit {
counter = "BRANCH"
value = "COVEREDRATIO"
minimum = "0.90".toBigDecimal()
minimum = "0.80".toBigDecimal()
}
}
}
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@
package com.expediagroup.graphql.generator.federation.directives

import com.expediagroup.graphql.generator.annotations.GraphQLDirective
import graphql.Scalars
import graphql.introspection.Introspection
import graphql.schema.GraphQLArgument
import graphql.schema.GraphQLNonNull

/**
* ```graphql
Expand All @@ -37,6 +34,7 @@ import graphql.schema.GraphQLNonNull
* annotation class CustomDirective
*
* @ComposeDirective(name = "custom")
* @LinkDirective()
* class CustomSchema
*
* class SimpleQuery {
Expand All @@ -48,7 +46,7 @@ import graphql.schema.GraphQLNonNull
* it will generate following schema
*
* ```graphql
* schema @composeDirective(name: "@myDirective") @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.3"){
* query: Query
* }
*
Expand All @@ -61,6 +59,7 @@ import graphql.schema.GraphQLNonNull
*
* @see <a href="https://www.apollographql.com/docs/federation/federated-types/federated-directives/#composedirective">@composeDirective definition</a>
*/
@LinkedSpec(FEDERATION_SPEC)
@Repeatable
@GraphQLDirective(
name = COMPOSE_DIRECTIVE_NAME,
Expand All @@ -71,15 +70,3 @@ annotation class ComposeDirective(val name: String)

internal const val COMPOSE_DIRECTIVE_NAME = "composeDirective"
private const val COMPOSE_DIRECTIVE_DESCRIPTION = "Marks underlying custom directive to be included in the Supergraph schema"

internal val COMPOSE_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective()
.name(COMPOSE_DIRECTIVE_NAME)
.description(COMPOSE_DIRECTIVE_DESCRIPTION)
.validLocations(Introspection.DirectiveLocation.SCHEMA)
.argument(
GraphQLArgument.newArgument()
.name("name")
.type(GraphQLNonNull.nonNull(Scalars.GraphQLString))
)
.repeatable(true)
.build()
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022 Expedia, Inc
* 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.
Expand All @@ -17,7 +17,10 @@
package com.expediagroup.graphql.generator.federation.directives

import com.expediagroup.graphql.generator.annotations.GraphQLDirective
import graphql.Scalars
import graphql.introspection.Introspection.DirectiveLocation
import graphql.schema.GraphQLArgument
import graphql.schema.GraphQLNonNull

/**
* ```graphql
Expand Down Expand Up @@ -65,3 +68,27 @@ annotation class ContactDirective(

internal const val CONTACT_DIRECTIVE_NAME = "contact"
private const val CONTACT_DIRECTIVE_DESCRIPTION = "Provides contact information of the owner responsible for this subgraph schema."

internal val CONTACT_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective()
.name(CONTACT_DIRECTIVE_NAME)
.description(CONTACT_DIRECTIVE_DESCRIPTION)
.validLocations(DirectiveLocation.SCHEMA)
.argument(
GraphQLArgument.newArgument()
.name("name")
.type(GraphQLNonNull.nonNull(Scalars.GraphQLString))
.build()
)
.argument(
GraphQLArgument.newArgument()
.name("url")
.type(Scalars.GraphQLString)
.build()
)
.argument(
GraphQLArgument.newArgument()
.name("description")
.type(Scalars.GraphQLString)
.build()
)
.build()
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022 Expedia, Inc
* 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.
Expand Down Expand Up @@ -52,6 +52,7 @@ import graphql.introspection.Introspection.DirectiveLocation
*
* @see KeyDirective
*/
@LinkedSpec(FEDERATION_SPEC)
@Deprecated(message = "@extends is only required in Federation v1 and can be safely omitted from Federation v2 schemas")
@GraphQLDirective(
name = EXTENDS_DIRECTIVE_NAME,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import graphql.introspection.Introspection.DirectiveLocation
* @see KeyDirective
* @see RequiresDirective
*/
@LinkedSpec(FEDERATION_SPEC)
@GraphQLDirective(
name = EXTERNAL_DIRECTIVE_NAME,
description = EXTERNAL_DIRECTIVE_DESCRIPTION,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022 Expedia, Inc
* 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.
Expand All @@ -17,7 +17,7 @@
package com.expediagroup.graphql.generator.federation.directives

/**
* Annotation representing _FieldSet scalar type that is used to represent a set of fields.
* Annotation representing FieldSet scalar type that is used to represent a set of fields.
*
* Field set can represent:
* - single field, e.g. "id"
Expand All @@ -28,4 +28,5 @@ package com.expediagroup.graphql.generator.federation.directives
*
* @see [com.expediagroup.graphql.generator.federation.types.FIELD_SET_SCALAR_TYPE]
*/
@LinkedSpec(FEDERATION_SPEC)
annotation class FieldSet(val value: String)
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022 Expedia, Inc
* 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.
Expand Down Expand Up @@ -68,6 +68,7 @@ import graphql.introspection.Introspection.DirectiveLocation
*
* @see <a href="https://specs.apollo.dev/inaccessible/v0.2">@inaccessible specification</a>
*/
@LinkedSpec(FEDERATION_SPEC)
@GraphQLDirective(
name = INACCESSIBLE_DIRECTIVE_NAME,
description = INACESSIBLE_DIRECTIVE_DESCRIPTION,
Expand All @@ -88,20 +89,3 @@ annotation class InaccessibleDirective

internal const val INACCESSIBLE_DIRECTIVE_NAME = "inaccessible"
private const val INACESSIBLE_DIRECTIVE_DESCRIPTION = "Marks location within schema as inaccessible from the GraphQL Gateway"

internal val INACCESSIBLE_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective()
.name(INACCESSIBLE_DIRECTIVE_NAME)
.description(INACESSIBLE_DIRECTIVE_DESCRIPTION)
.validLocations(
DirectiveLocation.FIELD_DEFINITION,
DirectiveLocation.OBJECT,
DirectiveLocation.INTERFACE,
DirectiveLocation.UNION,
DirectiveLocation.ENUM,
DirectiveLocation.ENUM_VALUE,
DirectiveLocation.SCALAR,
DirectiveLocation.INPUT_OBJECT,
DirectiveLocation.INPUT_FIELD_DEFINITION,
DirectiveLocation.ARGUMENT_DEFINITION
)
.build()
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import graphql.introspection.Introspection
* }
* ```
*/
@LinkedSpec(FEDERATION_SPEC)
@GraphQLDirective(
name = INTERFACE_OBJECT_DIRECTIVE_NAME,
description = INTERFACE_OBJECT_DIRECTIVE_DESCRIPTION,
Expand All @@ -76,9 +77,3 @@ annotation class InterfaceObjectDirective

internal const val INTERFACE_OBJECT_DIRECTIVE_NAME = "interfaceObject"
private const val INTERFACE_OBJECT_DIRECTIVE_DESCRIPTION = "Provides meta information to the router that this entity type is an interface in the supergraph."

internal val INTERFACE_OBJECT_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective()
.name(INTERFACE_OBJECT_DIRECTIVE_NAME)
.description(INTERFACE_OBJECT_DIRECTIVE_DESCRIPTION)
.validLocations(Introspection.DirectiveLocation.OBJECT)
.build()
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022 Expedia, Inc
* 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.
Expand All @@ -18,9 +18,11 @@ package com.expediagroup.graphql.generator.federation.directives

import com.expediagroup.graphql.generator.annotations.GraphQLDirective
import com.expediagroup.graphql.generator.federation.types.FIELD_SET_ARGUMENT
import com.expediagroup.graphql.generator.federation.types.fieldSetArgumentDefinition
import graphql.Scalars
import graphql.introspection.Introspection.DirectiveLocation
import graphql.schema.GraphQLArgument
import graphql.schema.GraphQLScalarType

/**
* ```graphql
Expand Down Expand Up @@ -89,6 +91,7 @@ import graphql.schema.GraphQLArgument
* @see FieldSet
* @see ExternalDirective
*/
@LinkedSpec(FEDERATION_SPEC)
@Repeatable
@GraphQLDirective(
name = KEY_DIRECTIVE_NAME,
Expand All @@ -108,11 +111,11 @@ internal val KEY_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schem
.repeatable(true)
.build()

internal val KEY_DIRECTIVE_TYPE_V2: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective()
internal fun keyDirectiveDefinition(fieldSetScalar: GraphQLScalarType): graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective()
.name(KEY_DIRECTIVE_NAME)
.description(KEY_DIRECTIVE_DESCRIPTION)
.validLocations(DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE)
.argument(FIELD_SET_ARGUMENT)
.argument(fieldSetArgumentDefinition(fieldSetScalar))
.argument(
GraphQLArgument.newArgument()
.name("resolvable")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,36 @@ import graphql.introspection.Introspection.DirectiveLocation
import graphql.schema.GraphQLArgument
import graphql.schema.GraphQLList
import graphql.schema.GraphQLNonNull
import graphql.schema.GraphQLScalarType

const val LINK_SPEC_URL = "https://specs.apollo.dev/link/v1.0/"
const val FEDERATION_SPEC_URL = "https://specs.apollo.dev/federation/v2.3"
const val APOLLO_SPEC_URL = "https://specs.apollo.dev"

const val LINK_SPEC = "link"
const val LINK_SPEC_LATEST_VERSION = "1.0"
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_URL_PREFIX = "$APOLLO_SPEC_URL/$FEDERATION_SPEC"
const val FEDERATION_SPEC_LATEST_URL = "$FEDERATION_SPEC_URL_PREFIX/v$FEDERATION_SPEC_LATEST_VERSION"

/**
* ```graphql
* directive @link(url: String!, import: [Import]) repeatable on SCHEMA
* directive @link(url: String!, as: String, import: [Import]) repeatable on SCHEMA
* ```
*
* The `@link` directive links definitions within the document to external schemas.
*
* External schemas are identified by their url, which optionally ends with a name and version with the following format: `{NAME}/v{MAJOR}.{MINOR}`
* 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"`.
*
* By default, external types should be namespaced (prefixed with namespace__, e.g. key directive should be namespaced as federation__key) unless they are explicitly imported. `graphql-kotlin`
* automatically imports ALL federation directives to avoid the need for namespacing.
*
* >NOTE: We currently DO NOT support full `@link` directive capability as it requires support for namespacing and renaming imports. This functionality may be added in the future releases. See `@link`
* specification for details.
* 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
* we currently always require those values to be explicitly provided.
*
* @param url external schema URL
* @param as custom namespace, should default to the specification name specified in the url
* @param import list of imported schema elements
*
* @see <a href="https://specs.apollo.dev/link/v1.0/">@link specification</a>
*/
Expand All @@ -51,12 +61,12 @@ const val FEDERATION_SPEC_URL = "https://specs.apollo.dev/federation/v2.3"
description = LINK_DIRECTIVE_DESCRIPTION,
locations = [DirectiveLocation.SCHEMA]
)
annotation class LinkDirective(val url: String, val import: Array<String>)
annotation class LinkDirective(val url: String, val `as`: String, val import: Array<LinkImport>)

internal const val LINK_DIRECTIVE_NAME = "link"
private const val LINK_DIRECTIVE_DESCRIPTION = "Links definitions within the document to external schemas."

internal val LINK_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective()
internal fun linkDirectiveDefinition(importScalarType: GraphQLScalarType): graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective()
.name(LINK_DIRECTIVE_NAME)
.description(LINK_DIRECTIVE_DESCRIPTION)
.validLocations(DirectiveLocation.SCHEMA)
Expand All @@ -65,18 +75,23 @@ internal val LINK_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.sche
.name("url")
.type(GraphQLNonNull.nonNull(Scalars.GraphQLString))
)
.argument(
GraphQLArgument.newArgument()
.name("as")
.type(Scalars.GraphQLString)
)
.argument(
GraphQLArgument
.newArgument()
.name("import")
.type(GraphQLList.list(Scalars.GraphQLString))
.type(GraphQLList.list(importScalarType))
)
.repeatable(true)
.build()

internal fun appliedLinkDirective(url: String, imports: List<String> = emptyList()) = LINK_DIRECTIVE_TYPE.toAppliedDirective()
internal fun graphql.schema.GraphQLDirective.toAppliedLinkDirective(url: String, namespace: String? = null, imports: List<String> = emptyList()) = this.toAppliedDirective()
.transform { appliedDirectiveBuilder ->
LINK_DIRECTIVE_TYPE.getArgument("url")
this.getArgument("url")
.toAppliedArgument()
.transform { argumentBuilder ->
argumentBuilder.valueProgrammatic(url)
Expand All @@ -85,8 +100,19 @@ internal fun appliedLinkDirective(url: String, imports: List<String> = emptyList
appliedDirectiveBuilder.argument(it)
}

if (!namespace.isNullOrBlank()) {
this.getArgument("as")
.toAppliedArgument()
.transform { argumentBuilder ->
argumentBuilder.valueProgrammatic(namespace)
}
.let {
appliedDirectiveBuilder.argument(it)
}
}

if (imports.isNotEmpty()) {
LINK_DIRECTIVE_TYPE.getArgument("import")
this.getArgument("import")
.toAppliedArgument()
.transform { argumentBuilder ->
argumentBuilder.valueProgrammatic(imports)
Expand Down
Loading

0 comments on commit 1401099

Please sign in to comment.