-
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 b0788e0
Showing
41 changed files
with
5,493 additions
and
44 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) | ||
} |
185 changes: 185 additions & 0 deletions
185
...ed/src/main/kotlin/com/apurebase/kgraphql/schema/stitched/DefaultRemoteRequestExecutor.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,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("}") | ||
} | ||
} | ||
} | ||
} |
97 changes: 97 additions & 0 deletions
97
...tor-stitched/src/main/kotlin/com/apurebase/kgraphql/schema/stitched/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.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 | ||
} | ||
} |
Oops, something went wrong.