Skip to content

Commit

Permalink
Initial federation support (#36)
Browse files Browse the repository at this point in the history
* Initial federation support

* add apiDump

* fix introspection

* fix test in CI

* use kotlinpoet functions

* remove unneeded tests

* Revert "fix test in CI"

This reverts commit 382630e.

* update workflow file

* fix enums with KSP2
  • Loading branch information
martinbonnin authored Oct 22, 2024
1 parent 75eb863 commit 5838974
Show file tree
Hide file tree
Showing 41 changed files with 1,191 additions and 311 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build-pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ jobs:
- uses: gradle/actions/setup-gradle@dbbdc275be76ac10734476cc723d82dfe7ec6eda #v3.4.2
- run: |
./gradlew build
./gradlew -p execution-tests build
./gradlew -p tests build
./gradlew -p sample-ktor build
./gradlew -p sample-http4k build
3 changes: 3 additions & 0 deletions Writerside/doc.tree
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@
<toc-element topic="http4k.md"/>
<toc-element topic="spring.md"/>
</toc-element>
<toc-element toc-title="Apollo Federation">
<toc-element topic="federation.md"/>
</toc-element>
</instance-profile>
98 changes: 98 additions & 0 deletions Writerside/topics/federation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Apollo Federation

Apollo Kotlin Execution supports [Apollo Federation](https://www.apollographql.com/federation).

To use federation, add the `apollo-execution-federation` artifact to your project:

```kotlin
dependencies {
// Add the federation dependency
implementation("com.apollographql.execution:apollo-execution-federation:%latest_version%")
}
```

## Defining entity keys

You can define [entity](https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/entities/intro) key using the `GraphQLKey` annotation:

```kotlin
class Product(
@GraphQLKey
val id: String,
val name: String
)
```

The `GraphQLKey` annotation is translated at build time into a matching federation `@key` directive:

```graphql
@key(fields: "id")
type Product {
id: String!,
name: String!
}
```

> By adding the annotation on the field definition instead of the type definition, Apollo Kotlin Execution gives you more type safety.
{style="note"}

## Federation subgraph fields

Whenever a type containing a `@GraphQLKey` field is present, Apollo Kotlin Execution adds the [federation subgraph fields](https://www.apollographql.com/docs/graphos/reference/federation/subgraph-specific-fields), `_service` and `_entities`:

```graphql
# an union containing all the federated types in the schema, constructed at build time
union _Entity = Product | ...
# coerced as a JSON object containing '__typename' and all the key fields.
scalar _Any

type _Service {
sdl: String!
}

extend type Query {
_entities(representations: [_Any!]!): [_Entity]!
_service: _Service!
}
```

## Defining federated resolvers

In order to support the `_entities` field, federation requires a resolver that can resolve an entity from its key field.

You can add one by defining a `resolve` function on the companion object:

```kotlin
class Product(
@GraphQLKey
val id: String,
val name: String
) {
companion object {
fun resolve(id: String): Product {
return products.first { it.id == id }
}
}
}

val products = listOf(
Product("1", "foo"),
Product("2", "bar")
)
```

Just like regular resolvers, the entity resolvers can be suspend and/or have an `ExecutionContext` parameter:

```kotlin
class Product(
@GraphQLKey
val id: String,
val name: String
) {
companion object {
suspend fun resolve(executionContext: ExecutionContext, id: String): Product {
return executionContext.loader.getProdut(id)
}
}
}
```
2 changes: 2 additions & 0 deletions apollo-execution-federation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Module apollo-execution-federation

10 changes: 10 additions & 0 deletions apollo-execution-federation/api/apollo-execution-federation.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
public abstract interface annotation class com/apollographql/execution/federation/GraphQLKey : java/lang/annotation/Annotation {
}

public final class com/apollographql/execution/federation/_AnyCoercing : com/apollographql/execution/Coercing {
public static final field INSTANCE Lcom/apollographql/execution/federation/_AnyCoercing;
public fun deserialize (Ljava/lang/Object;)Ljava/lang/Object;
public fun parseLiteral (Lcom/apollographql/apollo/ast/GQLValue;)Ljava/lang/Object;
public fun serialize (Ljava/lang/Object;)Ljava/lang/Object;
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Klib ABI Dump
// Targets: [macosArm64]
// Rendering settings:
// - Signature version: 2
// - Show manifest properties: true
// - Show declarations: true

// Library unique name: <com.apollographql.execution:apollo-execution-federation>
final object com.apollographql.execution.federation/_AnyCoercing : com.apollographql.execution/Coercing<kotlin/Any?> { // com.apollographql.execution.federation/_AnyCoercing|null[0]
final fun deserialize(kotlin/Any?): kotlin/Any? // com.apollographql.execution.federation/_AnyCoercing.deserialize|deserialize(kotlin.Any?){}[0]
final fun parseLiteral(com.apollographql.apollo.ast/GQLValue): kotlin/Any? // com.apollographql.execution.federation/_AnyCoercing.parseLiteral|parseLiteral(com.apollographql.apollo.ast.GQLValue){}[0]
final fun serialize(kotlin/Any?): kotlin/Any? // com.apollographql.execution.federation/_AnyCoercing.serialize|serialize(kotlin.Any?){}[0]
}
open annotation class com.apollographql.execution.federation/GraphQLKey : kotlin/Annotation { // com.apollographql.execution.federation/GraphQLKey|null[0]
constructor <init>() // com.apollographql.execution.federation/GraphQLKey.<init>|<init>(){}[0]
}
29 changes: 29 additions & 0 deletions apollo-execution-federation/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import com.gradleup.librarian.gradle.librarianModule

plugins {
id("org.jetbrains.kotlin.multiplatform")
}

librarianModule(true)

kotlin {
jvm()
macosArm64()

sourceSets {
getByName("commonMain") {
dependencies {
api(libs.apollo.ast)
api(libs.apollo.api)
api(project(":apollo-execution-runtime"))
}
}

getByName("commonTest") {
dependencies {
implementation(libs.kotlin.test)
}
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.apollographql.execution.federation


@Target(AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION)
annotation class GraphQLKey
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.apollographql.execution.federation

import com.apollographql.apollo.ast.GQLBooleanValue
import com.apollographql.apollo.ast.GQLEnumValue
import com.apollographql.apollo.ast.GQLFloatValue
import com.apollographql.apollo.ast.GQLIntValue
import com.apollographql.apollo.ast.GQLListValue
import com.apollographql.apollo.ast.GQLNullValue
import com.apollographql.apollo.ast.GQLObjectValue
import com.apollographql.apollo.ast.GQLStringValue
import com.apollographql.apollo.ast.GQLValue
import com.apollographql.apollo.ast.GQLVariableValue
import com.apollographql.execution.Coercing
import com.apollographql.execution.JsonValue

object _AnyCoercing: Coercing<Any?> {
override fun serialize(internalValue: Any?): JsonValue {
return internalValue
}

override fun deserialize(value: JsonValue): Any? {
return value
}

override fun parseLiteral(value: GQLValue): Any? {
return when (value) {
is GQLBooleanValue -> value.value
is GQLEnumValue -> value.value
is GQLFloatValue -> value.value
is GQLIntValue -> value.value
is GQLListValue -> value.values.map { parseLiteral(it) }
is GQLNullValue -> null
is GQLObjectValue -> value.fields.map { it.name to parseLiteral(it.value) }.toMap()
is GQLStringValue -> value.value
is GQLVariableValue -> error("Cannot coerce variable")
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.apollographql.execution.processor

import com.apollographql.apollo.ast.GQLObjectTypeDefinition
import com.apollographql.execution.processor.codegen.*
import com.apollographql.execution.processor.sir.SirClassName
import com.apollographql.execution.processor.sir.SirObjectDefinition
import com.apollographql.execution.processor.sir.maybeFederate
import com.google.devtools.ksp.containingFile
import com.google.devtools.ksp.getConstructors
import com.google.devtools.ksp.isAbstract
Expand Down Expand Up @@ -63,10 +66,11 @@ class ApolloProcessor(
val definitions = result.definitions

val context = KotlinExecutableSchemaContext(packageName)
val maybeFederatedDefinitions = definitions.maybeFederate()
val schemaDocumentBuilder = SchemaDocumentBuilder(
context = context,
serviceName = serviceName,
sirDefinitions = definitions
sirDefinitions = maybeFederatedDefinitions
)

val builders = mutableListOf<CgFileBuilder>()
Expand All @@ -81,12 +85,27 @@ class ApolloProcessor(
logger = logger
)
)

val entities = definitions.filterIsInstance<SirObjectDefinition>().filter { it.isEntity }
val entityResolverBuilder = if (entities.isNotEmpty()) {
EntityResolverBuilder(
context = context,
serviceName = serviceName,
entities = entities,
).also {
builders.add(it)
}
} else {
null
}

builders.add(
ExecutableSchemaBuilderBuilder(
context = context,
serviceName = serviceName,
schemaDocument = schemaDocumentBuilder.schemaDocument,
sirDefinitions = definitions
sirDefinitions = definitions,
entityResolver = entityResolverBuilder?.entityResolver
)
)

Expand All @@ -105,14 +124,14 @@ class ApolloProcessor(
This class was automatically generated by Apollo GraphQL version '$VERSION'.
""".trimIndent()
""".trimIndent()
).build()
}
.forEach { sourceFile ->
codeGenerator.createNewFile(
dependencies,
packageName = sourceFile.packageName,
// SourceFile contains .kt
// SourceFile contains.kt
fileName = sourceFile.name.substringBeforeLast('.'),
).bufferedWriter().use {
sourceFile.writeTo(it)
Expand All @@ -124,7 +143,7 @@ class ApolloProcessor(
"${serviceName}Schema.graphqls",
"",
).bufferedWriter().use {
it.write(schemaString(definitions))
it.write(schemaString(maybeFederatedDefinitions))
}
return emptyList()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.TypeVariableName
import com.squareup.kotlinpoet.buildCodeBlock
import com.squareup.kotlinpoet.withIndent
import java.util.Locale

internal fun String.capitalizeFirstLetter(): String {
Expand Down Expand Up @@ -84,7 +86,7 @@ internal class CoercingsBuilder(
.addParameter(ParameterSpec("key", KotlinSymbols.String))
.returns(TypeVariableName("T"))
.addCode(
buildCode {
buildCodeBlock {
add("if (this == null) return null as T\n")
add("check(this is %T<*,*>)\n", KotlinSymbols.Map)
block()
Expand All @@ -96,11 +98,11 @@ internal class CoercingsBuilder(
private fun getInputFunSpec(): FunSpec {
return inputFunSpecInternal("getInput") {
add("return if(containsKey(key)) {\n")
indent {
withIndent {
add("%T(get(key))\n", KotlinSymbols.Present)
}
add("} else {\n")
indent {
withIndent {
add("%T\n", KotlinSymbols.Absent)
}
add("} as T\n")
Expand All @@ -120,21 +122,21 @@ internal class CoercingsBuilder(
)
.addModifiers(KModifier.INTERNAL)
.initializer(
buildCode {
buildCodeBlock {
add("object: %T<%T> {\n", KotlinSymbols.Coercing, targetClassName.asKotlinPoet())
indent {
withIndent {
add("override fun serialize(internalValue: %T): Any?{\n", targetClassName.asKotlinPoet())
indent {
withIndent {
add("return internalValue.name\n")
}
add("}\n")
add("override fun deserialize(value: Any?): %T {\n", targetClassName.asKotlinPoet())
indent {
withIndent {
add("return %T.valueOf(value.toString())\n", targetClassName.asKotlinPoet())
}
add("}\n")
add("override fun parseLiteral(gqlValue: %T): %T {\n", AstValue, targetClassName.asKotlinPoet())
indent {
withIndent {
add("return %T.valueOf((gqlValue as %T).value)\n", targetClassName.asKotlinPoet(), AstEnumValue)
}
add("}\n")
Expand All @@ -151,18 +153,18 @@ internal class CoercingsBuilder(
KotlinSymbols.Coercing.parameterizedBy(targetClassName.asKotlinPoet())
)
.initializer(
buildCode {
buildCodeBlock {
add("object: %T<%T> {\n", KotlinSymbols.Coercing, targetClassName.asKotlinPoet())
indent {
withIndent {
add("override fun serialize(internalValue: %T): Any?{\n", targetClassName.asKotlinPoet())
indent {
withIndent {
add("error(\"Input objects cannot be serialized\")")
}
add("}\n")
add("override fun deserialize(value: Any?): %T {\n", targetClassName.asKotlinPoet())
indent {
withIndent {
add("return %T(\n", targetClassName.asKotlinPoet())
indent {
withIndent {
inputFields.forEach {
val getInput = if (it.defaultValue == null && it.type !is SirNonNullType) {
"getInput"
Expand All @@ -176,7 +178,7 @@ internal class CoercingsBuilder(
}
add("}\n")
add("override fun parseLiteral(gqlValue: %T): %T {\n", AstValue, targetClassName.asKotlinPoet())
indent {
withIndent {
add("error(\"Input objects cannot be parsed from literals\")")
}
add("}\n")
Expand Down
Loading

0 comments on commit 5838974

Please sign in to comment.