Skip to content

Commit

Permalink
[generator] Apollo Federation v2 support (#1459)
Browse files Browse the repository at this point in the history
Federation v2 is an evolution of the Federation spec to make it more powerful, flexible and easier to adapt. While v1 and v2 schemas are similar in many ways, Federation v2 relaxes some of the constraints and adds additional capabilities. See [Apollo documentation](https://www.apollographql.com/docs/federation/federation-2/new-in-federation-2/) for details.

By default, `graphql-kotlin-federation` library will generate Federation v1 compatible schema. In order to generate v2 compatible schema you have to explicitly opt-in by specifying `optInFederationV2 = true` on your instance of `FederatedSchemaGeneratorHooks`.

```kotlin
val myHooks = FederatedSchemaGeneratorHooks(resolvers = myFederatedResolvers, optInFederationV2 = true)
val myConfig = FederatedSchemaGeneratorConfig(
  supportedPackages = "com.example",
  hooks = myHooks
)

toFederatedSchema(
  config = myConfig,
  queries = listOf(TopLevelObject(MyQuery()))
)
```

New directives
* `@contact` - provides contact information to the given subgraph
* `@inaccessible` - allows to exclude schema elements from the GraphQL Gateway
* `@link` - imports external entities to the schema
* `@override` - migrate field resolution logic from one subgraph to another
* `@shareable` - marks fields and objects as resolvable across multiple subgraphs
* `@tag` - adds additional metadata to schema elements (used by Apollo Contracts)
  • Loading branch information
dariuszkuc authored Jun 20, 2022
1 parent 5721ea3 commit 5f09656
Show file tree
Hide file tree
Showing 26 changed files with 1,210 additions and 52 deletions.
41 changes: 38 additions & 3 deletions generator/graphql-kotlin-federation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ Once all the federated objects are annotated, you will also have to configure co
that are used to instantiate federated objects and finally generate the schema using `toFederatedSchema` function
([link](https://github.com/ExpediaGroup/graphql-kotlin/blob/master/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/toFederatedSchema.kt#L34)).

```mermaid
graph TD;
gateway([Supergraph<br/>Gateway]);
serviceA[Products<br/>Subgraph];
serviceB[Reviews<br/>Subgraph];
gateway --- serviceA & serviceB;
```

>NOTE: `graphql-kotlin-federation` libraries allow you to build individual GraphQL services, aka Subgraphs. This library does not
>provide capability to build a GraphQL Gateway, aka Supergraph.
See more

* [Federation Spec](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/)
Expand Down Expand Up @@ -37,7 +48,31 @@ implementation("com.expediagroup", "graphql-kotlin-federation", latestVersion)

In order to generate valid federated schemas, you will need to annotate both your base schema and the one extending it. Federated Gateway (e.g. Apollo) will then combine the individual graphs to form single federated graph.

#### Base Schema
### Federation v1 vs Federation v2

Federation v2 is an evolution of the Federation spec to make it more powerful, flexible and easier to adapt. While v1 and
v2 schemas are similar in many ways, Federation v2 relaxes some of the constraints and adds additional capabilities. See
[Apollo documentation](https://www.apollographql.com/docs/federation/federation-2/new-in-federation-2/) for details.

By default, `graphql-kotlin-federation` library will generate Federation v1 compatible schema. In order to generate v2
compatible schema you have to explicitly opt-in by specifying `optInFederationV2 = true` on your instance of `FederatedSchemaGeneratorHooks`.

```kotlin
val myHooks = FederatedSchemaGeneratorHooks(resolvers = myFederatedResolvers, optInFederationV2 = true)
val myConfig = FederatedSchemaGeneratorConfig(
supportedPackages = "com.example",
hooks = myHooks
)

toFederatedSchema(
config = myConfig,
queries = listOf(TopLevelObject(MyQuery()))
)
```

>NOTE: Federation v2 compatible schemas, can be generated using `graphql-kotlin-spring-server` by configuring `graphql.federation.optInV2 = true` property.
### Base Schema (Products Subgraph)

Base schema defines GraphQL types that will be extended by schemas exposed by other GraphQL services. In the example below, we define base `Product` type with `id` and `description` fields. `id` is the primary key that uniquely identifies the `Product` type object and is specified in `@key` directive.

Expand Down Expand Up @@ -84,7 +119,7 @@ type _Service {
}
```

#### Extended Schema
### Extended Schema (Reviews Subgraph)

Extended federated GraphQL schemas provide additional functionality to the types already exposed by other GraphQL services. In the example below, `Product` type is extended to add new `reviews` field to it. Primary key needed to instantiate the `Product` type (i.e. `id`) has to match the `@key` definition on the base type. Since primary keys are defined on the base type and are only referenced from the extended type, all of the fields that are part of the field set specified in `@key` directive have to be marked as `@external`.

Expand Down Expand Up @@ -144,7 +179,7 @@ type _Service {

Federated Gateway will then combine the schemas from the individual services to generate single schema.

#### Federated GraphQL schema
### Federated Supergraph schema

```graphql
schema {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,35 @@ package com.expediagroup.graphql.generator.federation

import com.expediagroup.graphql.generator.annotations.GraphQLName
import com.expediagroup.graphql.generator.directives.DEPRECATED_DIRECTIVE_NAME
import com.expediagroup.graphql.generator.directives.DirectiveMetaInformation
import com.expediagroup.graphql.generator.extensions.print
import com.expediagroup.graphql.generator.federation.directives.EXTENDS_DIRECTIVE_TYPE
import com.expediagroup.graphql.generator.federation.directives.EXTERNAL_DIRECTIVE_TYPE
import com.expediagroup.graphql.generator.federation.directives.FEDERATION_SPEC_URL
import com.expediagroup.graphql.generator.federation.directives.FieldSet
import com.expediagroup.graphql.generator.federation.directives.INACCESSIBLE_DIRECTIVE_NAME
import com.expediagroup.graphql.generator.federation.directives.INACCESSIBLE_DIRECTIVE_TYPE
import com.expediagroup.graphql.generator.federation.directives.KEY_DIRECTIVE_NAME
import com.expediagroup.graphql.generator.federation.directives.KEY_DIRECTIVE_TYPE
import com.expediagroup.graphql.generator.federation.directives.KEY_DIRECTIVE_TYPE_V2
import com.expediagroup.graphql.generator.federation.directives.LINK_DIRECTIVE_NAME
import com.expediagroup.graphql.generator.federation.directives.LINK_DIRECTIVE_TYPE
import com.expediagroup.graphql.generator.federation.directives.LINK_SPEC_URL
import com.expediagroup.graphql.generator.federation.directives.OVERRIDE_DIRECTIVE_NAME
import com.expediagroup.graphql.generator.federation.directives.OVERRIDE_DIRECTIVE_TYPE
import com.expediagroup.graphql.generator.federation.directives.PROVIDES_DIRECTIVE_TYPE
import com.expediagroup.graphql.generator.federation.directives.REQUIRES_DIRECTIVE_TYPE
import com.expediagroup.graphql.generator.federation.directives.SHAREABLE_DIRECTIVE_NAME
import com.expediagroup.graphql.generator.federation.directives.SHAREABLE_DIRECTIVE_TYPE
import com.expediagroup.graphql.generator.federation.directives.TAG_DIRECTIVE_TYPE
import com.expediagroup.graphql.generator.federation.directives.appliedLinkDirective
import com.expediagroup.graphql.generator.federation.exception.IncorrectFederatedDirectiveUsage
import com.expediagroup.graphql.generator.federation.execution.EntityResolver
import com.expediagroup.graphql.generator.federation.execution.FederatedTypeResolver
import com.expediagroup.graphql.generator.federation.extensions.addDirectivesIfNotPresent
import com.expediagroup.graphql.generator.federation.types.ANY_SCALAR_TYPE
import com.expediagroup.graphql.generator.federation.types.ENTITY_UNION_NAME
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.SERVICE_FIELD_DEFINITION
import com.expediagroup.graphql.generator.federation.types._Service
Expand All @@ -52,34 +68,92 @@ import kotlin.reflect.full.findAnnotation
/**
* Hooks for generating federated GraphQL schema.
*/
open class FederatedSchemaGeneratorHooks(private val resolvers: List<FederatedTypeResolver<*>>) : SchemaGeneratorHooks {
open class FederatedSchemaGeneratorHooks(private val resolvers: List<FederatedTypeResolver<*>>, private val optInFederationV2: Boolean = false) : SchemaGeneratorHooks {
private val scalarDefinitionRegex = "(^\".+\"$[\\r\\n])?^scalar (_FieldSet|_Any)$[\\r\\n]*".toRegex(setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE))
private val emptyQueryRegex = "^type Query @extends \\s*\\{\\s*}\\s*".toRegex(setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE))
private val serviceFieldRegex = "\\s*_service: _Service!".toRegex(setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE))
private val serviceTypeRegex = "^type _Service\\s*\\{\\s*sdl: String!\\s*}\\s*".toRegex(setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE))
private val validator = FederatedSchemaValidator()

private val federatedDirectiveTypes: List<GraphQLDirective> = listOf(EXTERNAL_DIRECTIVE_TYPE, REQUIRES_DIRECTIVE_TYPE, PROVIDES_DIRECTIVE_TYPE, KEY_DIRECTIVE_TYPE, EXTENDS_DIRECTIVE_TYPE)
private val directivesToInclude: List<String> = federatedDirectiveTypes.map { it.name }.plus(DEPRECATED_DIRECTIVE_NAME)
private val customDirectivePredicate: Predicate<String> = Predicate { directivesToInclude.contains(it) }
private val federationV2OnlyDirectiveNames: Set<String> = setOf(
INACCESSIBLE_DIRECTIVE_NAME,
LINK_DIRECTIVE_NAME,
OVERRIDE_DIRECTIVE_NAME,
SHAREABLE_DIRECTIVE_NAME
)

private val federatedDirectiveV1List: List<GraphQLDirective> = listOf(
EXTENDS_DIRECTIVE_TYPE,
EXTERNAL_DIRECTIVE_TYPE,
KEY_DIRECTIVE_TYPE,
PROVIDES_DIRECTIVE_TYPE,
REQUIRES_DIRECTIVE_TYPE
)
private val federatedDirectiveV2List: List<GraphQLDirective> = listOf(
EXTENDS_DIRECTIVE_TYPE,
EXTERNAL_DIRECTIVE_TYPE,
INACCESSIBLE_DIRECTIVE_TYPE,
KEY_DIRECTIVE_TYPE,
LINK_DIRECTIVE_TYPE,
OVERRIDE_DIRECTIVE_TYPE,
PROVIDES_DIRECTIVE_TYPE,
REQUIRES_DIRECTIVE_TYPE,
SHAREABLE_DIRECTIVE_TYPE,
TAG_DIRECTIVE_TYPE
)

/**
* Add support for _FieldSet scalar to the schema.
*/
override fun willGenerateGraphQLType(type: KType): GraphQLType? = when (type.classifier) {
FieldSet::class -> FIELD_SET_SCALAR_TYPE
else -> null
else -> super.willGenerateGraphQLType(type)
}

override fun willGenerateDirective(directiveInfo: DirectiveMetaInformation): GraphQLDirective? =
if (optInFederationV2) {
willGenerateFederatedDirectiveV2(directiveInfo)
} else {
willGenerateFederatedDirective(directiveInfo)
}

private fun willGenerateFederatedDirective(directiveInfo: DirectiveMetaInformation) =
if (federationV2OnlyDirectiveNames.contains(directiveInfo.effectiveName)) {
throw IncorrectFederatedDirectiveUsage(directiveInfo.effectiveName)
} else if (KEY_DIRECTIVE_NAME == directiveInfo.effectiveName) {
KEY_DIRECTIVE_TYPE
} else {
super.willGenerateDirective(directiveInfo)
}

private fun willGenerateFederatedDirectiveV2(directiveInfo: DirectiveMetaInformation) =
if (KEY_DIRECTIVE_NAME == directiveInfo.effectiveName) {
KEY_DIRECTIVE_TYPE_V2
} else {
super.willGenerateDirective(directiveInfo)
}

override fun didGenerateGraphQLType(type: KType, generatedType: GraphQLType): GraphQLType {
validator.validateGraphQLType(generatedType)
return super.didGenerateGraphQLType(type, generatedType)
}

override fun willBuildSchema(builder: GraphQLSchema.Builder): GraphQLSchema.Builder {
if (optInFederationV2) {
val fed2Imports = federatedDirectiveV2List.map { it.name }
.plus(FIELD_SET_SCALAR_NAME)

builder.withSchemaDirective(LINK_DIRECTIVE_TYPE)
.withSchemaAppliedDirective(appliedLinkDirective(LINK_SPEC_URL))
.withSchemaAppliedDirective(appliedLinkDirective(FEDERATION_SPEC_URL, fed2Imports))
}

val originalSchema = builder.build()
val originalQuery = originalSchema.queryType
val federatedCodeRegistry = GraphQLCodeRegistry.newCodeRegistry(originalSchema.codeRegistry)

// Add all the federation directives if they are not present
val federatedSchemaBuilder = originalSchema.addDirectivesIfNotPresent(federatedDirectiveTypes)
val federatedSchemaBuilder = originalSchema.addDirectivesIfNotPresent(federatedDirectiveList())

// Register the data fetcher for the _service query
val sdl = getFederatedServiceSdl(originalSchema)
Expand All @@ -101,6 +175,12 @@ open class FederatedSchemaGeneratorHooks(private val resolvers: List<FederatedTy
.codeRegistry(federatedCodeRegistry.build())
}

private fun federatedDirectiveList(): List<GraphQLDirective> = if (optInFederationV2) {
federatedDirectiveV2List
} else {
federatedDirectiveV1List
}

/**
* Federated service may not have any regular queries but will have federated queries. In order to ensure that we
* have a valid GraphQL schema that can be modified in the [willBuildSchema], query has to have at least one single field.
Expand All @@ -126,6 +206,8 @@ open class FederatedSchemaGeneratorHooks(private val resolvers: List<FederatedTy
* https://www.apollographql.com/docs/apollo-server/federation/federation-spec/#query_service
*/
private fun getFederatedServiceSdl(schema: GraphQLSchema): String {
val directivesToInclude: List<String> = federatedDirectiveList().map { it.name }.plus(DEPRECATED_DIRECTIVE_NAME)
val customDirectivePredicate: Predicate<String> = Predicate { directivesToInclude.contains(it) }
return schema.print(
includeDefaultSchemaDefinition = false,
includeDirectiveDefinitions = false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2022 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.DirectiveLocation

/**
* ```graphql
* directive @contact(
* "Contact title of the subgraph owner"
* name: String!
* "URL where the subgraph's owner can be reached"
* url: String
* "Other relevant notes can be included here; supports markdown links"
* description: String
* ) on SCHEMA
* ```
*
* Contact schema directive can be used to provide team contact information to your subgraph schema. This information is automatically parsed and displayed by Apollo Studio.
*
* Example usage on schema class:
* ```kotlin
* @ContactDirective(
* name = "My Team Name",
* url = "https://myteam.slack.com/archives/teams-chat-room-url",
* description = "send urgent issues to [#oncall](https://yourteam.slack.com/archives/oncall)."
* )
* class MySchema
* ```
*
* @param name subgraph owner name
* @param url optional URL where the subgraph's owner can be reached
* @param description optional additional information about contacting the owners, can include markdown links
*
* @see <a href="https://www.apollographql.com/docs/studio/federated-graphs/#subgraph-contact-info">Subgraph Contact Info</a>
*/
@GraphQLDirective(
name = "contact",
description = "Provides contact information of the owner responsible for this subgraph schema.",
locations = [DirectiveLocation.SCHEMA]
)
annotation class ContactDirective(
/** Contact title of the subgraph owner */
val name: String,
/** URL where the subgraph's owner can be reached */
val url: String = "",
/** Other relevant notes can be included here; supports markdown links */
val description: String = ""
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 Expedia, Inc
* Copyright 2022 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 @@ -27,6 +27,9 @@ import graphql.introspection.Introspection.DirectiveLocation
* Extends directive is used to represent type extensions in the schema. Native type extensions are currently unsupported by the graphql-kotlin libraries. Federated extended types should have
* corresponding @key directive defined that specifies primary key required to fetch the underlying object.
*
* >NOTE: While Federation v2 no longer requires `@extends` directive due to the smart entity type merging. `graphql-kotlin` still requires `@extends` directive to programmatically locate all federated
* entity types in order to add them to the schema.
*
* Example:
* Given
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 Expedia, Inc
* Copyright 2022 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 @@ -27,6 +27,9 @@ import graphql.introspection.Introspection.DirectiveLocation
* The @external directive is used to mark a field as owned by another service. This allows service A to use fields from service B while also knowing at runtime the types of that field. @external
* directive is only applicable on federated extended types. All the external fields should either be referenced from the @key, @requires or @provides directives field sets.
*
* Due to the smart merging of entity types, `@external` directive is no longer required on `@key` fields and can be omitted from the schema. `@external` directive is only required on fields
* referenced by the `@requires` and `@provides` directive.
*
* Example:
* Given
*
Expand Down
Loading

0 comments on commit 5f09656

Please sign in to comment.