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 1c0fcd7
Show file tree
Hide file tree
Showing 42 changed files with 5,467 additions and 45 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,131 @@
package com.apurebase.kgraphql.stitched

import com.apurebase.kgraphql.ContextBuilder
import com.apurebase.kgraphql.GraphQL
import com.apurebase.kgraphql.GraphQLError
import com.apurebase.kgraphql.GraphqlRequest
import com.apurebase.kgraphql.context
import com.apurebase.kgraphql.schema.Schema
import com.apurebase.kgraphql.stitched.schema.dsl.StitchedSchemaBuilder
import com.apurebase.kgraphql.stitched.schema.dsl.StitchedSchemaConfigurationDSL
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.ApplicationCallPipeline
import io.ktor.server.application.Plugin
import io.ktor.server.application.install
import io.ktor.server.application.pluginOrNull
import io.ktor.server.request.receiveText
import io.ktor.server.response.respondBytes
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.Routing
import io.ktor.server.routing.RoutingRoot
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.util.AttributeKey
import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.json.Json.Default.decodeFromString

class StitchedGraphQL(val schema: Schema) {
class Configuration : StitchedSchemaConfigurationDSL() {
fun stitchedSchema(block: StitchedSchemaBuilder.() -> Unit) {
schemaBlock = block
}

/**
* This adds support for opening the graphql route within the browser
*/
var playground: Boolean = false

var endpoint: String = "/graphql"

fun context(block: ContextBuilder.(ApplicationCall) -> Unit) {
contextSetup = block
}

fun wrap(block: Route.(next: Route.() -> Unit) -> Unit) {
wrapWith = block
}

internal var contextSetup: (ContextBuilder.(ApplicationCall) -> Unit)? = null
internal var wrapWith: (Route.(next: Route.() -> Unit) -> Unit)? = null
internal var schemaBlock: (StitchedSchemaBuilder.() -> Unit)? = null
}

companion object Feature : Plugin<Application, Configuration, GraphQL> {
override val key = AttributeKey<GraphQL>("StitchedKGraphQL")

private val rootFeature = FeatureInstance("StitchedKGraphQL")

override fun install(pipeline: Application, configure: Configuration.() -> Unit): GraphQL {
return rootFeature.install(pipeline, configure)
}
}

class FeatureInstance(featureKey: String = "StitchedKGraphQL") : Plugin<Application, Configuration, GraphQL> {
companion object {
private val playgroundHtml: ByteArray? by lazy {
this::class.java.classLoader.getResource("playground.html")?.readBytes()
}
}

override val key = AttributeKey<GraphQL>(featureKey)

override fun install(pipeline: Application, configure: Configuration.() -> Unit): GraphQL {
val config = Configuration().apply(configure)
val schema = StitchedKGraphQL.stitchedSchema {
configuration = config
config.schemaBlock?.invoke(this)
}

val routing: Routing.() -> Unit = {
val routing: Route.() -> Unit = {
route(config.endpoint) {
post {
val bodyAsText = call.receiveText()
val request = decodeFromString<GraphqlRequest>(bodyAsText)
val ctx = context {
config.contextSetup?.invoke(this, call)
}
val result = schema.execute(
request = request.query,
variables = request.variables.toString(),
context = ctx,
operationName = request.operationName
)
call.respondText(result, contentType = ContentType.Application.Json)
}
get {
val schemaRequested = call.request.queryParameters["schema"] != null
if (schemaRequested && config.introspection) {
call.respondText(schema.printSchema())
} else if (config.playground) {
playgroundHtml?.let {
call.respondBytes(it, contentType = ContentType.Text.Html)
}
}
}
}
}

config.wrapWith?.invoke(this, routing) ?: routing(this)
}

pipeline.pluginOrNull(RoutingRoot)?.apply(routing) ?: pipeline.install(RoutingRoot, routing)

pipeline.intercept(ApplicationCallPipeline.Monitoring) {
try {
coroutineScope {
proceed()
}
} catch (e: GraphQLError) {
context.respondText(e.serialize(), ContentType.Application.Json, HttpStatusCode.OK)
}
}
return GraphQL(schema)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.apurebase.kgraphql.stitched

import com.apurebase.kgraphql.BuiltInErrorCodes
import com.apurebase.kgraphql.GraphQLError
import com.apurebase.kgraphql.schema.execution.Execution

// TODO: support multiple remote errors
class RemoteExecutionException(message: String, node: Execution.Remote) : GraphQLError(
message,
nodes = listOf(node.selectionNode),
extensionsErrorType = BuiltInErrorCodes.INTERNAL_SERVER_ERROR.name,
extensionsErrorDetail = mapOf("remoteSchema" to node.remoteUrl, "remoteOperation" to node.remoteOperation)
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.apurebase.kgraphql.stitched

import com.apurebase.kgraphql.schema.Schema
import com.apurebase.kgraphql.stitched.schema.dsl.StitchedSchemaBuilder

object StitchedKGraphQL {
fun stitchedSchema(init: StitchedSchemaBuilder.() -> Unit): Schema {
return StitchedSchemaBuilder().apply(init).build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.apurebase.kgraphql.stitched.schema

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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.apurebase.kgraphql.stitched.schema

data class Link(
val typeName: String,
val fieldName: String,
val remoteQueryName: String,
val nullable: Boolean,
// TODO: rework arguments
// -- arguments = mapOf("outletRef" to parent("outletId"), "foo" to "bar", "foobar" to "barfoo".fromParent(), "oof" to constant("rab"),
// "something" to defaultValue(), "other" to skip(), "else" to explicit(), "outlet" fromParent "outletId", "bar" asConstant "bar2"
val linkArguments: List<LinkArgument>
)

/**
* Link argument named [name] either to be provided explicitly or by [parentFieldName].
*/
// TODO: support constant values? support fluent API? And then link(argument).withDefault().withValue(value).fromParent(field)?
data class LinkArgument(
val name: String,
val parentFieldName: String? = null
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.apurebase.kgraphql.stitched.schema

import com.apurebase.kgraphql.Context
import com.apurebase.kgraphql.ValidationException
import com.apurebase.kgraphql.configuration.SchemaConfiguration
import com.apurebase.kgraphql.request.Introspection
import com.apurebase.kgraphql.request.Parser
import com.apurebase.kgraphql.request.VariablesJson
import com.apurebase.kgraphql.schema.Schema
import com.apurebase.kgraphql.schema.SchemaPrinter
import com.apurebase.kgraphql.schema.execution.ExecutionOptions
import com.apurebase.kgraphql.schema.execution.Executor
import com.apurebase.kgraphql.schema.execution.Executor.DataLoaderPrepared
import com.apurebase.kgraphql.schema.execution.Executor.Parallel
import com.apurebase.kgraphql.schema.execution.RequestExecutor
import com.apurebase.kgraphql.schema.introspection.__Schema
import com.apurebase.kgraphql.schema.structure.LookupSchema
import com.apurebase.kgraphql.schema.structure.Type
import com.apurebase.kgraphql.stitched.schema.execution.StitchedParallelRequestExecutor
import com.apurebase.kgraphql.stitched.schema.structure.StitchedRequestInterpreter
import com.apurebase.kgraphql.stitched.schema.structure.StitchedSchemaModel
import kotlinx.coroutines.coroutineScope
import kotlin.reflect.KClass

class StitchedDefaultSchema(
override val configuration: SchemaConfiguration,
internal val model: StitchedSchemaModel
) : Schema, __Schema by model, LookupSchema {

private val defaultRequestExecutor: RequestExecutor = getExecutor(configuration.executor)

private fun getExecutor(executor: Executor) = when (executor) {
Parallel -> StitchedParallelRequestExecutor(this)
DataLoaderPrepared -> error("Unsupported executor")
}

private val requestInterpreter: StitchedRequestInterpreter = StitchedRequestInterpreter(model)

override suspend fun execute(
request: String,
variables: String?,
context: Context,
options: ExecutionOptions,
operationName: String?,
): String = coroutineScope {
if (!configuration.introspection && Introspection.isIntrospection(request)) {
throw ValidationException("GraphQL introspection is not allowed")
}

val parsedVariables = variables
?.let { VariablesJson.Defined(configuration.objectMapper, variables) }
?: VariablesJson.Empty()

val document = Parser(request).parseDocument()

val executor = options.executor?.let(::getExecutor) ?: defaultRequestExecutor

executor.suspendExecute(
plan = requestInterpreter.createExecutionPlan(document, operationName, parsedVariables, options),
variables = parsedVariables,
context = context
)
}

override fun printSchema() = SchemaPrinter().print(model)

override fun typeByKClass(kClass: KClass<*>): Type? = model.queryTypes[kClass]

override fun inputTypeByKClass(kClass: KClass<*>): Type? = model.inputTypes[kClass]

override fun findTypeByName(name: String): Type? = model.allTypesByName[name]
}
Loading

0 comments on commit 1c0fcd7

Please sign in to comment.