-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: First support for schema stitching
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
1 parent
d2865de
commit 1c0fcd7
Showing
42 changed files
with
5,467 additions
and
45 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
131 changes: 131 additions & 0 deletions
131
kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/KtorFeature.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
14 changes: 14 additions & 0 deletions
14
...ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/RemoteExecutionException.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
) | ||
|
10 changes: 10 additions & 0 deletions
10
kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/StitchedKGraphQL.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} |
97 changes: 97 additions & 0 deletions
97
...tor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/IntrospectedSchema.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
21 changes: 21 additions & 0 deletions
21
kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/Link.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
72 changes: 72 additions & 0 deletions
72
...-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/StitchedDefaultSchema.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | ||
} |
Oops, something went wrong.