Skip to content

Commit

Permalink
feat: First support for schema stitching
Browse files Browse the repository at this point in the history
This adds first support for schema stitching without aiming for full
feature compatibility. And while I have a pretty extensive internal
use case, schema stitching is still to be considered experimental and
subject to change as needed.

Schema stitching is a way to combine multiple GraphQL schemas under a
single endpoint, so that clients can request data from different APIs
in a single query. It also allows to add links between these schemas
to e.g., automatically resolve references to actual types.

This first implementation was made with the following goals/limitations
in mind:

- I tried to avoid interfering with existing code and to stick to
  existing architecture where possible
- I tried to avoid introducing new dependencies for existing users of
  the library; in particular, kgraphql core should not get any ktor
  specific dependencies
- I tried to have the stitching API as lean as possible
- I focused on the non-experimental `ParallelRequestExecutor` first

Over the course of implementing schema stitching, several bugs were
resolved on the way but schema stitching is currently still impacted
by some major issues like incomplete error handling (#114) that need
to be addressed separately.

Resolves #9
  • Loading branch information
stuebingerb committed Feb 7, 2025
1 parent d2865de commit b0788e0
Show file tree
Hide file tree
Showing 41 changed files with 5,493 additions and 44 deletions.
3 changes: 2 additions & 1 deletion docs/content/Reference/Type System/objects-and-interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ Returns:
This feature can be used in production but does currently have some issues:

1. The `useDefaultPrettyPrint` doesn't work
1. Order of fields are not guaranteed, to match the order that was requested
1. Order of fields are not guaranteed to match the order that was requested
1. Custom generic type resolvers are not supported
1. Other than that it should work as expected
1. Schema stitching is not supported
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jackson-core-databind = { module = "com.fasterxml.jackson.core:jackson-databind"
jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version = "3.2.0" }
deferredJsonBuilder = { module = "com.apurebase:DeferredJsonBuilder", version = "1.0.0" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" }
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
Expand Down
20 changes: 20 additions & 0 deletions kgraphql-ktor-stitched/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
plugins {
id("library-conventions")
alias(libs.plugins.serialization)
}

dependencies {
api(project(":kgraphql"))
api(project(":kgraphql-ktor"))
implementation(kotlin("stdlib-jdk8"))
implementation(libs.jackson.core.databind)
implementation(libs.jackson.module.kotlin)
implementation(libs.ktor.server.core)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.kotlinx.serialization.json)

testImplementation(libs.junit.jupiter.api)
testImplementation(libs.kluent)
testImplementation(libs.ktor.server.test.host)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package com.apurebase.kgraphql.schema.stitched

import com.apurebase.kgraphql.Context
import com.apurebase.kgraphql.GraphqlRequest
import com.apurebase.kgraphql.request.Variables
import com.apurebase.kgraphql.schema.execution.Execution
import com.apurebase.kgraphql.schema.execution.RemoteRequestExecutor
import com.apurebase.kgraphql.schema.model.ast.OperationTypeNode
import com.apurebase.kgraphql.schema.model.ast.SelectionNode
import com.apurebase.kgraphql.schema.model.ast.TypeNode
import com.apurebase.kgraphql.schema.model.ast.ValueNode
import com.apurebase.kgraphql.schema.structure.Field
import com.apurebase.kgraphql.schema.structure.Type
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import io.ktor.client.HttpClient
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.json.Json.Default.decodeFromString
import kotlinx.serialization.json.Json.Default.encodeToString
import kotlinx.serialization.json.JsonObject

open class DefaultRemoteRequestExecutor(private val client: HttpClient, private val objectMapper: ObjectMapper) :
RemoteRequestExecutor {

/**
* Executes the actual [request] against the given [url] in the current [ctx]. This function is intended to
* be overridden by client implementations to provide custom http handling (like adding auth headers).
*/
open suspend fun executeRequest(url: String, request: GraphqlRequest, ctx: Context): HttpResponse =
client.post(url) {
contentType(ContentType.Application.Json)
setBody(encodeToString(request))
}

/**
* Main entry point called from the local request executor for the given [node] and [ctx].
*/
final override suspend fun execute(node: Execution.Remote, ctx: Context): JsonNode? {
val remoteUrl = node.remoteUrl
val request = toGraphQLRequest(node, ctx)
val response = runCatching {
executeRequest(remoteUrl, request, ctx).bodyAsText()
}.getOrElse {
"""
{ "errors": [ { "message": "${it.message}" } ] }
""".trimIndent()
}
val responseJson = objectMapper.readTree(response)
responseJson["errors"]?.let { errors ->
// TODO: properly transfer errors from the remote execution
val messages = (errors as? ArrayNode)?.map { (it as? ObjectNode)?.get("message")?.textValue() }
?: listOf("Error(s) during remote execution")
throw RemoteExecutionException(message = messages.joinToString(", "), node = node)
}
return responseJson["data"]?.get(node.remoteOperation)
}

private fun SelectionNode.FieldNode.alias() = alias?.let {
"${it.value}: "
} ?: ""

private fun TypeNode.typeReference(): String {
return when (this) {
is TypeNode.NamedTypeNode -> nameNode.value
is TypeNode.ListTypeNode -> "[${type.typeReference()}]"
is TypeNode.NonNullTypeNode -> "${type.typeReference()}!"
}
}

/**
* Converts the given [node] to a [GraphqlRequest] in the given [ctx].
*/
private fun toGraphQLRequest(node: Execution.Remote, ctx: Context): GraphqlRequest {
val operation = when (node.operationType) {
OperationTypeNode.QUERY -> "query"
OperationTypeNode.MUTATION -> "mutation"
OperationTypeNode.SUBSCRIPTION -> "subscription"
}
val variablesString = node.variables?.takeIf { it.isNotEmpty() }?.map {
"${it.variable.valueNodeName}: ${it.type.typeReference()}"
}?.joinToString(separator = ",", prefix = "(", postfix = ") ") { it } ?: ""
val query = buildString {
append("$operation $variablesString{")
append(node.remoteOperation)
val inputArgs = node.arguments?.entries?.takeIf { it.isNotEmpty() }?.let { entries ->
entries.joinToString(prefix = "(", postfix = ")") { (key, value) ->
"$key: ${value.valueNodeName}"
}
} ?: ""
append(inputArgs)
addSelectionsForField(node)
appendLine("}")
node.namedFragments?.forEach { fragment ->
addSelectionsForFragment(fragment)
}
}
// TODO: kotlinx vs jackson...
val variables = ctx.get<Variables>()?.getRaw()?.let { decodeFromString<JsonObject>(it.toString()) }
return GraphqlRequest(query = query, variables = variables)
}

private fun StringBuilder.addSelectionsForField(node: Execution.Remote) {
val filteredSelections =
(node.selectionNode as? SelectionNode.FieldNode)?.selectionSet?.selections?.filterForType(node.field.returnType)
.orEmpty()
if (filteredSelections.isNotEmpty()) {
append("{")
filteredSelections.forEach {
addSelection(it, node.field.returnType)
}
append("}")
}
}

private fun StringBuilder.addSelectionsForFragment(fragment: Execution.Fragment) {
val fragmentName =
(fragment.selectionNode as SelectionNode.FragmentNode.FragmentSpreadNode).name.value
val filteredSelections = fragment.elements.map { it.selectionNode }.filterForType(fragment.condition.onType)
if (filteredSelections.isNotEmpty()) {
appendLine("fragment $fragmentName on ${fragment.condition.onType.name} {")
filteredSelections.forEach {
addSelection(it, fragment.condition.onType)
}
appendLine("}")
}
}

/**
* Filters [this] list of selection nodes for fields that belong to the given [type] itself, i.e. are not
* stitched from a different schema.
*/
private fun List<SelectionNode>.filterForType(type: Type): List<SelectionNode> {
val availableFieldNames: Set<String> =
type.unwrapped().fields?.filterNot { it is Field.RemoteOperation<*, *> }?.mapTo(mutableSetOf()) { it.name }
.orEmpty() + "__typename"
return filter { it !is SelectionNode.FieldNode || it.name.value in availableFieldNames }
}

private fun StringBuilder.addSelection(selection: SelectionNode, type: Type) {
when (selection) {
is SelectionNode.FieldNode -> {
val selectionArgs = selection.arguments?.takeIf { it.isNotEmpty() }
?.joinToString(separator = ",", prefix = "(", postfix = ")") { argumentNode ->
val argumentValue = if (argumentNode.value is ValueNode.VariableNode) {
"$${argumentNode.value.valueNodeName}"
} else {
argumentNode.value.valueNodeName
}
"${argumentNode.name.value}: $argumentValue"
} ?: ""
val nodeSelections = selection.selectionSet?.selections.orEmpty()
val currentType =
type.unwrapped().fields?.firstOrNull { it.name == selection.name.value }?.returnType ?: type
val filteredSelections = nodeSelections.filterForType(currentType)
appendLine("${selection.alias()}${selection.name.value}$selectionArgs")
if (filteredSelections.isNotEmpty()) {
appendLine("{")
filteredSelections.forEach { sub ->
addSelection(sub, currentType)
}
appendLine("}")
}
}

is SelectionNode.FragmentNode.FragmentSpreadNode -> {
appendLine("...${selection.name.value}")
}

is SelectionNode.FragmentNode.InlineFragmentNode -> {
appendLine("...on ${selection.typeCondition!!.name.value} {")
selection.selectionSet.selections.forEach { sub ->
addSelection(sub, type)
}
appendLine("}")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.apurebase.kgraphql.schema.stitched

import com.apurebase.kgraphql.schema.directive.DirectiveLocation
import com.apurebase.kgraphql.schema.introspection.TypeKind
import com.apurebase.kgraphql.schema.introspection.__Directive
import com.apurebase.kgraphql.schema.introspection.__EnumValue
import com.apurebase.kgraphql.schema.introspection.__Field
import com.apurebase.kgraphql.schema.introspection.__InputValue
import com.apurebase.kgraphql.schema.introspection.__Schema
import com.apurebase.kgraphql.schema.introspection.__Type
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json.Default.decodeFromString

@Serializable
data class IntrospectionResponse(val data: IntrospectionData)

@Serializable
data class IntrospectionData(
val __schema: IntrospectedSchema
)

@Serializable
data class IntrospectedDirective(
override val name: String,
override val locations: List<DirectiveLocation> = emptyList(),
override val args: List<IntrospectedInputValue> = emptyList(),
override val isRepeatable: Boolean = false,
override val description: String? = null
) : __Directive

@Serializable
data class IntrospectedEnumValue(
override val name: String,
override val isDeprecated: Boolean = false,
override val deprecationReason: String? = null,
override val description: String? = null
) : __EnumValue

@Serializable
data class IntrospectedInputValue(
override val name: String,
override val type: IntrospectedType,
override val defaultValue: String? = null,
override val isDeprecated: Boolean = false,
override val deprecationReason: String? = null,
override val description: String? = null
) : __InputValue

@Serializable
data class IntrospectedField(
override val name: String,
override val type: IntrospectedType,
override val args: List<IntrospectedInputValue> = emptyList(),
override val isDeprecated: Boolean = false,
override val deprecationReason: String? = null,
override val description: String? = null
) : __Field

@Serializable
data class IntrospectedType(
override val name: String?,
override val kind: TypeKind = TypeKind.OBJECT,
override val description: String? = null,
override var fields: List<IntrospectedField>? = null,
override val interfaces: List<IntrospectedType>? = null,
override val possibleTypes: List<IntrospectedType>? = null,
override val enumValues: List<IntrospectedEnumValue>? = null,
override val inputFields: List<IntrospectedInputValue>? = null,
override val ofType: IntrospectedType? = null
) : __Type

@Serializable
data class IntrospectedRootOperation(
override val name: String,
override val kind: TypeKind = TypeKind.OBJECT,
override val description: String? = null,
override var fields: List<IntrospectedField>? = null,
override val interfaces: List<IntrospectedType>? = null,
override val possibleTypes: List<IntrospectedType>? = null,
override val enumValues: List<IntrospectedEnumValue>? = null,
override val inputFields: List<IntrospectedInputValue>? = null,
override val ofType: IntrospectedType? = null
) : __Type

@Serializable
data class IntrospectedSchema(
override val queryType: IntrospectedRootOperation,
override val mutationType: IntrospectedRootOperation?,
override val subscriptionType: IntrospectedRootOperation?,
override val types: List<IntrospectedType>,
override val directives: List<IntrospectedDirective>,
) : __Schema {
companion object {
fun fromIntrospectionResponse(response: String) =
decodeFromString<IntrospectionResponse>(response).data.__schema
}
}
Loading

0 comments on commit b0788e0

Please sign in to comment.