From 969f4cb6a6c15b97b3c90f8e2e1417696057efeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20St=C3=BCbinger?= <41049452+stuebingerb@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:23:05 +0100 Subject: [PATCH] refactor!: move error handler to schema Moves the error handler from the Ktor plugin to the schema itself. The error handler can now be used to map any exception encountered during execution and delegate to the default implementation. BREAKING CHANGE: error handler is no longer supported as part of Ktor plugin configuration --- docs/content/Plugins/ktor.md | 44 +++--------------- docs/content/Reference/configuration.md | 23 +++++----- docs/content/Reference/errorHandling.md | 39 +++++++++++++++- .../api/kgraphql-ktor-stitched.api | 4 +- .../StitchedSchemaConfiguration.kt | 5 ++- .../dsl/StitchedSchemaConfigurationDSL.kt | 1 + kgraphql-ktor/api/kgraphql-ktor.api | 1 - .../com/apurebase/kgraphql/KtorFeature.kt | 14 +----- .../com/apurebase/kgraphql/KtorFeatureTest.kt | 25 ----------- .../kotlin/com/apurebase/kgraphql/KtorTest.kt | 2 - kgraphql/api/kgraphql.api | 12 ++++- .../configuration/SchemaConfiguration.kt | 4 +- .../schema/dsl/SchemaConfigurationDSL.kt | 5 ++- .../kgraphql/schema/execution/ErrorHandler.kt | 26 +++++++++++ .../execution/ParallelRequestExecutor.kt | 27 +++++------ .../kgraphql/integration/QueryTest.kt | 45 +++++++++++++++++++ 16 files changed, 168 insertions(+), 109 deletions(-) create mode 100644 kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/ErrorHandler.kt diff --git a/docs/content/Plugins/ktor.md b/docs/content/Plugins/ktor.md index b8465e3f..d40e0f09 100644 --- a/docs/content/Plugins/ktor.md +++ b/docs/content/Plugins/ktor.md @@ -50,13 +50,12 @@ test it out directly within the browser. The GraphQL feature is extending the standard [KGraphQL configuration](../Reference/configuration.md) and providing its own set of configuration as described in the table below. -| Property | Description | Default value | -|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------| -| endpoint | This specifies what route will be delivering the GraphQL endpoint. When `playground` is enabled, it will use this endpoint also. | `/graphql` | -| context | Refer to example below | | -| wrap | If you want to wrap the route into something before KGraphQL will install the GraphQL route. You can use this wrapper. See example below for a more in depth on how to use it. | | -| errorHandler | Allows interaction with exceptions thrown during GraphQL execution and optional mapping to another one — in particular mapping to `GraphQLError` for serialization in the response. | | -| schema | This is where you are defining your schema. Please refer to [KGraphQL References](../Reference/operations.md) for further documentation on this. | ***required*** | +| Property | Description | Default value | +|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------| +| endpoint | This specifies what route will be delivering the GraphQL endpoint. When `playground` is enabled, it will use this endpoint also. | `/graphql` | +| context | Allows to add call-specific information to the GraphQL context, see example below. | | +| wrap | If you want to wrap the route into something before KGraphQL will install the GraphQL route. You can use this wrapper. See example below for a more in depth on how to use it. | | +| schema | This is where you are defining your schema. Please refer to [KGraphQL References](../Reference/operations.md) for further documentation on this. | ***required*** | ### Wrap @@ -74,7 +73,7 @@ This works great alongside the [context](#context) to provide a context to the K ### Context -To get access to the context +`context` allows to add call-specific information to the GraphQL context: === "Example" ```kotlin @@ -94,35 +93,6 @@ To get access to the context } ``` -### Error Handler - -By default, KGraphQL will wrap non-`GraphQLError` exceptions into an `ExecutionException` (when `wrapErrors = true`) -or rethrow them to be handled by Ktor (when `wrapErrors = false`). - -The `errorHandler` provides a way to **intercept and transform exceptions** before they are serialized. -It is always defined — by default it simply returns the same exception instance (`{ e -> e }`), -but you can override it to map specific exception types to `GraphQLError` or other `Throwable` instances. - -=== "Example" - ```kotlin - errorHandler { e -> - when (e) { - is ValidationException -> RequestError(e.message, extensions = mapOf("type" to "VALIDATION_ERROR")) - is DomainException -> RequestError(e.message, extensions = mapOf("type" to "DOMAIN_ERROR")) - is GraphQLError -> e - else -> ExecutionException(e.message ?: "Unknown execution error", cause = e) - } - } - schema { - query("hello") { - resolver { ctx: Context -> - val user = ctx.get()!! - "Hello ${user.name}" - } - } - } - ``` - ## Schema Definition Language (SDL) The [Schema Definition Language](https://graphql.org/learn/schema/#type-language) (or Type System Definition Language) is a human-readable, language-agnostic diff --git a/docs/content/Reference/configuration.md b/docs/content/Reference/configuration.md index c6326865..94d8058c 100644 --- a/docs/content/Reference/configuration.md +++ b/docs/content/Reference/configuration.md @@ -1,16 +1,17 @@ KGraphQL schema allows configuration of the following properties: -| Property | Description | Default value | -|--------------------------------|-----------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| useDefaultPrettyPrinter | Schema pretty prints JSON responses | `false` | -| useCachingDocumentParser | Schema caches parsed query documents | `true` | -| documentParserCacheMaximumSize | Schema document cache maximum size | `1000` | -| objectMapper | Schema is using Jackson ObjectMapper from this property | result of `jacksonObjectMapper()` from [jackson-kotlin-module](https://github.com/FasterXML/jackson-module-kotlin) | -| acceptSingleValueAsArray | Schema accepts single argument values as singleton list | `true` | -| coroutineDispatcher | Schema is using CoroutineDispatcher from this property | [Dispatchers.Default](https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/jvm/src/Dispatchers.kt) | -| genericTypeResolver | Schema is using generic type resolver from this property | [GenericTypeResolver.DEFAULT](https://github.com/stuebingerb/KGraphQL/blob/main/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/GenericTypeResolver.kt) | -| wrapErrors | Schema wraps exceptions from resolvers as GraphQLError | | -| introspection | Schema allows introspection (also affects SDL). Introspection can be disabled in production to reduce attack surface. | `true` | +| Property | Description | Default value | +|--------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| useDefaultPrettyPrinter | Schema pretty prints JSON responses | `false` | +| useCachingDocumentParser | Schema caches parsed query documents | `true` | +| documentParserCacheMaximumSize | Schema document cache maximum size | `1000` | +| objectMapper | Schema is using Jackson ObjectMapper from this property | result of `jacksonObjectMapper()` from [jackson-kotlin-module](https://github.com/FasterXML/jackson-module-kotlin) | +| acceptSingleValueAsArray | Schema accepts single argument values as singleton list | `true` | +| coroutineDispatcher | Schema is using CoroutineDispatcher from this property | [Dispatchers.Default](https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/jvm/src/Dispatchers.kt) | +| genericTypeResolver | Schema is using generic type resolver from this property | [GenericTypeResolver.DEFAULT](https://github.com/stuebingerb/KGraphQL/blob/main/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/GenericTypeResolver.kt) | +| wrapErrors | Schema wraps exceptions from resolvers as GraphQLError | | +| introspection | Schema allows introspection (also affects SDL). Introspection can be disabled in production to reduce attack surface. | `true` | +| errorHandler | Allows interaction with exceptions thrown during GraphQL execution and optional mapping to another one — in particular mapping to `GraphQLError` for serialization in the response. | [ErrorHandler](https://github.com/stuebingerb/KGraphQL/blob/main/kgraphql/src/main/kotlin/com/stuebingerb/kgraphql/schema/execution/ErrorHandler.kt) | === "Example" ```kotlin diff --git a/docs/content/Reference/errorHandling.md b/docs/content/Reference/errorHandling.md index eab43bea..2f8d8632 100644 --- a/docs/content/Reference/errorHandling.md +++ b/docs/content/Reference/errorHandling.md @@ -221,4 +221,41 @@ Those re-thrown exceptions could then be handled with the [`StatusPages` Ktor pl Invalid input: java.lang.IllegalArgumentException: Illegal argument ``` -Because exceptions are re-thrown, `wrapErrors = false` can never result in partial responses. +Because exceptions are re-thrown, `wrapErrors = false` can never result in partial responses. `wrapErrors = false` will +also not invoke a custom error handler. If you want to throw exceptions with custom mapping, use `wrapErrors = true` and +re-throw mapped exceptions from the error handler. + +## Error Handler + +In KGraphQL, the schema can [configure a custom _error handler_](configuration.md) that is called for each exception +encountered during execution. It can be used to customize default error mapping, and to add additional extensions to +the response. + +The error handler is supposed to return a subclass of `GraphQLError`, which is either a `RequestError` or an `ExecutionError` +that will be handled according to the schema. When subclassing from the default `ErrorHandler`, mapping can be delegated +to the standard implementation, completely replaced, or a mixture of both. + +=== "Example" + ```kotlin + val customErrorHandler = object : ErrorHandler() { + override suspend fun handleException(ctx: Context, node: Execution.Node, exception: Throwable): GraphQLError { + return when (exception) { + is IllegalArgumentException -> ExecutionError( + message = exception.message ?: "", + node = node, + extensions = mapOf("type" to "CUSTOM_ERROR_TYPE") + ) + + is IllegalAccessException -> RequestError( + message = "You shall not pass!", + node = node.selectionNode, + extensions = mapOf("required_role" to "ADMIN", "reason" to "Gandalf") + ) + + else -> super.handleException(ctx, node, exception) + } + } + } + ``` + +(!) Exceptions from the error handler itself are *not* wrapped, regardless of the `wrapErrors` configuration. diff --git a/kgraphql-ktor-stitched/api/kgraphql-ktor-stitched.api b/kgraphql-ktor-stitched/api/kgraphql-ktor-stitched.api index 6f539681..7ebb9aa8 100644 --- a/kgraphql-ktor-stitched/api/kgraphql-ktor-stitched.api +++ b/kgraphql-ktor-stitched/api/kgraphql-ktor-stitched.api @@ -56,8 +56,8 @@ public final class com/apurebase/kgraphql/stitched/StitchedKGraphQL { } public final class com/apurebase/kgraphql/stitched/schema/configuration/StitchedSchemaConfiguration : com/apurebase/kgraphql/configuration/SchemaConfiguration { - public fun (ZJLcom/fasterxml/jackson/databind/ObjectMapper;ZLkotlinx/coroutines/CoroutineDispatcher;ZZLcom/apurebase/kgraphql/schema/execution/GenericTypeResolver;Lcom/apurebase/kgraphql/schema/execution/ArgumentTransformer;Lcom/apurebase/kgraphql/stitched/schema/execution/RemoteRequestExecutor;Ljava/lang/String;)V - public synthetic fun (ZJLcom/fasterxml/jackson/databind/ObjectMapper;ZLkotlinx/coroutines/CoroutineDispatcher;ZZLcom/apurebase/kgraphql/schema/execution/GenericTypeResolver;Lcom/apurebase/kgraphql/schema/execution/ArgumentTransformer;Lcom/apurebase/kgraphql/stitched/schema/execution/RemoteRequestExecutor;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZJLcom/fasterxml/jackson/databind/ObjectMapper;ZLkotlinx/coroutines/CoroutineDispatcher;ZZLcom/apurebase/kgraphql/schema/execution/GenericTypeResolver;Lcom/apurebase/kgraphql/schema/execution/ArgumentTransformer;Lcom/apurebase/kgraphql/schema/execution/ErrorHandler;Lcom/apurebase/kgraphql/stitched/schema/execution/RemoteRequestExecutor;Ljava/lang/String;)V + public synthetic fun (ZJLcom/fasterxml/jackson/databind/ObjectMapper;ZLkotlinx/coroutines/CoroutineDispatcher;ZZLcom/apurebase/kgraphql/schema/execution/GenericTypeResolver;Lcom/apurebase/kgraphql/schema/execution/ArgumentTransformer;Lcom/apurebase/kgraphql/schema/execution/ErrorHandler;Lcom/apurebase/kgraphql/stitched/schema/execution/RemoteRequestExecutor;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getLocalUrl ()Ljava/lang/String; public final fun getRemoteExecutor ()Lcom/apurebase/kgraphql/stitched/schema/execution/RemoteRequestExecutor; } diff --git a/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/configuration/StitchedSchemaConfiguration.kt b/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/configuration/StitchedSchemaConfiguration.kt index cedda5d7..75a29a1f 100644 --- a/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/configuration/StitchedSchemaConfiguration.kt +++ b/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/configuration/StitchedSchemaConfiguration.kt @@ -3,6 +3,7 @@ package com.apurebase.kgraphql.stitched.schema.configuration import com.apurebase.kgraphql.ExperimentalAPI import com.apurebase.kgraphql.configuration.SchemaConfiguration import com.apurebase.kgraphql.schema.execution.ArgumentTransformer +import com.apurebase.kgraphql.schema.execution.ErrorHandler import com.apurebase.kgraphql.schema.execution.GenericTypeResolver import com.apurebase.kgraphql.stitched.schema.execution.RemoteRequestExecutor import com.fasterxml.jackson.databind.ObjectMapper @@ -23,6 +24,7 @@ class StitchedSchemaConfiguration( introspection: Boolean = true, genericTypeResolver: GenericTypeResolver, argumentTransformer: ArgumentTransformer, + errorHandler: ErrorHandler, val remoteExecutor: RemoteRequestExecutor, val localUrl: String? ) : SchemaConfiguration( @@ -34,5 +36,6 @@ class StitchedSchemaConfiguration( wrapErrors, introspection, genericTypeResolver, - argumentTransformer + argumentTransformer, + errorHandler ) diff --git a/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/dsl/StitchedSchemaConfigurationDSL.kt b/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/dsl/StitchedSchemaConfigurationDSL.kt index 8efa03a5..f89afdec 100644 --- a/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/dsl/StitchedSchemaConfigurationDSL.kt +++ b/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/dsl/StitchedSchemaConfigurationDSL.kt @@ -27,6 +27,7 @@ open class StitchedSchemaConfigurationDSL : SchemaConfigurationDSL() { introspection = introspection, genericTypeResolver = genericTypeResolver, argumentTransformer = RemoteArgumentTransformer(objectMapper, genericTypeResolver), + errorHandler = errorHandler, remoteExecutor = requireNotNull(remoteExecutor) { "Remote executor not defined" }, localUrl = localUrl ) diff --git a/kgraphql-ktor/api/kgraphql-ktor.api b/kgraphql-ktor/api/kgraphql-ktor.api index f957bcda..de4a86b3 100644 --- a/kgraphql-ktor/api/kgraphql-ktor.api +++ b/kgraphql-ktor/api/kgraphql-ktor.api @@ -7,7 +7,6 @@ public final class com/apurebase/kgraphql/GraphQL { public final class com/apurebase/kgraphql/GraphQL$Configuration : com/apurebase/kgraphql/schema/dsl/SchemaConfigurationDSL { public fun ()V public final fun context (Lkotlin/jvm/functions/Function2;)V - public final fun errorHandler (Lkotlin/jvm/functions/Function1;)V public final fun getEndpoint ()Ljava/lang/String; public final fun getPlayground ()Z public final fun schema (Lkotlin/jvm/functions/Function1;)V diff --git a/kgraphql-ktor/src/main/kotlin/com/apurebase/kgraphql/KtorFeature.kt b/kgraphql-ktor/src/main/kotlin/com/apurebase/kgraphql/KtorFeature.kt index 1719d11a..c89c3401 100644 --- a/kgraphql-ktor/src/main/kotlin/com/apurebase/kgraphql/KtorFeature.kt +++ b/kgraphql-ktor/src/main/kotlin/com/apurebase/kgraphql/KtorFeature.kt @@ -45,13 +45,8 @@ class GraphQL(val schema: Schema) { wrapWith = block } - fun errorHandler(block: (e: Throwable) -> Throwable) { - errorHandler = block - } - internal var contextSetup: (ContextBuilder.(ApplicationCall) -> Unit)? = null internal var wrapWith: (Route.(next: Route.() -> Unit) -> Unit)? = null - internal var errorHandler: ((Throwable) -> Throwable) = { e -> e } internal var schemaBlock: (SchemaBuilder.() -> Unit)? = null } @@ -121,14 +116,9 @@ class GraphQL(val schema: Schema) { coroutineScope { proceed() } - } catch (e: Throwable) { - val error = config.errorHandler(e) - if (error !is GraphQLError) { - throw e - } - + } catch (e: GraphQLError) { context.respondText( - error.serialize(), + e.serialize(), ContentType.Application.Json, HttpStatusCode.OK ) diff --git a/kgraphql-ktor/src/test/kotlin/com/apurebase/kgraphql/KtorFeatureTest.kt b/kgraphql-ktor/src/test/kotlin/com/apurebase/kgraphql/KtorFeatureTest.kt index 4ab350a7..2f5fcf93 100644 --- a/kgraphql-ktor/src/test/kotlin/com/apurebase/kgraphql/KtorFeatureTest.kt +++ b/kgraphql-ktor/src/test/kotlin/com/apurebase/kgraphql/KtorFeatureTest.kt @@ -253,31 +253,6 @@ class KtorFeatureTest : KtorTest() { } } - @Test - fun `should work with error handler`() { - val errorHandler: (Throwable) -> GraphQLError = { e -> - RequestError( - message = e.message ?: "unknown", - node = null, - extensions = mapOf("type" to BuiltInErrorCodes.INTERNAL_SERVER_ERROR.name) - ) - } - - val server = withServer(errorHandler = errorHandler) { - query("error") { - resolver { throw Exception("Error message") } - } - } - - val response = server("query") { - field("error") - } - runBlocking { - response.bodyAsText() shouldBe "{\"errors\":[{\"message\":\"Error message\",\"locations\":[{\"line\":2,\"column\":1}],\"path\":[\"error\"],\"extensions\":{\"type\":\"INTERNAL_SERVER_ERROR\"}}],\"data\":null}" - response.contentType() shouldBe ContentType.Application.Json - } - } - @Test fun `should work without error handler`() { val server = withServer { diff --git a/kgraphql-ktor/src/test/kotlin/com/apurebase/kgraphql/KtorTest.kt b/kgraphql-ktor/src/test/kotlin/com/apurebase/kgraphql/KtorTest.kt index 261beb3b..0bf616d7 100644 --- a/kgraphql-ktor/src/test/kotlin/com/apurebase/kgraphql/KtorTest.kt +++ b/kgraphql-ktor/src/test/kotlin/com/apurebase/kgraphql/KtorTest.kt @@ -18,7 +18,6 @@ open class KtorTest { fun withServer( ctxBuilder: ContextBuilder.(ApplicationCall) -> Unit = {}, authHeader: String? = null, - errorHandler: ((Throwable) -> GraphQLError)? = null, wrapErrors: Boolean? = null, block: SchemaBuilder.() -> Unit ): (String, Kraph.() -> Unit) -> HttpResponse { @@ -38,7 +37,6 @@ open class KtorTest { wrap { next -> authenticate(optional = authHeader == null) { next() } } - errorHandler?.let { this.errorHandler(it) } wrapErrors?.let { this.wrapErrors = it } schema(block) } diff --git a/kgraphql/api/kgraphql.api b/kgraphql/api/kgraphql.api index 6f0fd667..1267a66a 100644 --- a/kgraphql/api/kgraphql.api +++ b/kgraphql/api/kgraphql.api @@ -88,11 +88,12 @@ public abstract interface class com/apurebase/kgraphql/configuration/PluginConfi } public class com/apurebase/kgraphql/configuration/SchemaConfiguration { - public fun (ZJLcom/fasterxml/jackson/databind/ObjectMapper;ZLkotlinx/coroutines/CoroutineDispatcher;ZZLcom/apurebase/kgraphql/schema/execution/GenericTypeResolver;Lcom/apurebase/kgraphql/schema/execution/ArgumentTransformer;)V - public synthetic fun (ZJLcom/fasterxml/jackson/databind/ObjectMapper;ZLkotlinx/coroutines/CoroutineDispatcher;ZZLcom/apurebase/kgraphql/schema/execution/GenericTypeResolver;Lcom/apurebase/kgraphql/schema/execution/ArgumentTransformer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZJLcom/fasterxml/jackson/databind/ObjectMapper;ZLkotlinx/coroutines/CoroutineDispatcher;ZZLcom/apurebase/kgraphql/schema/execution/GenericTypeResolver;Lcom/apurebase/kgraphql/schema/execution/ArgumentTransformer;Lcom/apurebase/kgraphql/schema/execution/ErrorHandler;)V + public synthetic fun (ZJLcom/fasterxml/jackson/databind/ObjectMapper;ZLkotlinx/coroutines/CoroutineDispatcher;ZZLcom/apurebase/kgraphql/schema/execution/GenericTypeResolver;Lcom/apurebase/kgraphql/schema/execution/ArgumentTransformer;Lcom/apurebase/kgraphql/schema/execution/ErrorHandler;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getArgumentTransformer ()Lcom/apurebase/kgraphql/schema/execution/ArgumentTransformer; public final fun getCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun getDocumentParserCacheMaximumSize ()J + public final fun getErrorHandler ()Lcom/apurebase/kgraphql/schema/execution/ErrorHandler; public final fun getGenericTypeResolver ()Lcom/apurebase/kgraphql/schema/execution/GenericTypeResolver; public final fun getIntrospection ()Z public final fun getObjectMapper ()Lcom/fasterxml/jackson/databind/ObjectMapper; @@ -547,6 +548,7 @@ public class com/apurebase/kgraphql/schema/dsl/SchemaConfigurationDSL { public final fun getAcceptSingleValueAsArray ()Z public final fun getCoroutineDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; public final fun getDocumentParserCacheMaximumSize ()J + public final fun getErrorHandler ()Lcom/apurebase/kgraphql/schema/execution/ErrorHandler; public final fun getGenericTypeResolver ()Lcom/apurebase/kgraphql/schema/execution/GenericTypeResolver; public final fun getIntrospection ()Z public final fun getObjectMapper ()Lcom/fasterxml/jackson/databind/ObjectMapper; @@ -556,6 +558,7 @@ public class com/apurebase/kgraphql/schema/dsl/SchemaConfigurationDSL { public final fun setAcceptSingleValueAsArray (Z)V public final fun setCoroutineDispatcher (Lkotlinx/coroutines/CoroutineDispatcher;)V public final fun setDocumentParserCacheMaximumSize (J)V + public final fun setErrorHandler (Lcom/apurebase/kgraphql/schema/execution/ErrorHandler;)V public final fun setGenericTypeResolver (Lcom/apurebase/kgraphql/schema/execution/GenericTypeResolver;)V public final fun setIntrospection (Z)V public final fun setObjectMapper (Lcom/fasterxml/jackson/databind/ObjectMapper;)V @@ -775,6 +778,11 @@ public class com/apurebase/kgraphql/schema/execution/DefaultGenericTypeResolver public fun unbox (Ljava/lang/Object;)Ljava/lang/Object; } +public class com/apurebase/kgraphql/schema/execution/ErrorHandler { + public fun ()V + public fun handleException (Lcom/apurebase/kgraphql/Context;Lcom/apurebase/kgraphql/schema/execution/Execution$Node;Ljava/lang/Throwable;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract class com/apurebase/kgraphql/schema/execution/Execution { public abstract fun getDirectives ()Ljava/util/Map; public abstract fun getFullPath ()Ljava/util/List; diff --git a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/configuration/SchemaConfiguration.kt b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/configuration/SchemaConfiguration.kt index b2282fcf..8e98b989 100644 --- a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/configuration/SchemaConfiguration.kt +++ b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/configuration/SchemaConfiguration.kt @@ -1,6 +1,7 @@ package com.apurebase.kgraphql.configuration import com.apurebase.kgraphql.schema.execution.ArgumentTransformer +import com.apurebase.kgraphql.schema.execution.ErrorHandler import com.apurebase.kgraphql.schema.execution.GenericTypeResolver import com.fasterxml.jackson.databind.ObjectMapper import kotlinx.coroutines.CoroutineDispatcher @@ -18,5 +19,6 @@ open class SchemaConfiguration( // allow schema introspection val introspection: Boolean = true, val genericTypeResolver: GenericTypeResolver, - val argumentTransformer: ArgumentTransformer + val argumentTransformer: ArgumentTransformer, + val errorHandler: ErrorHandler ) diff --git a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/dsl/SchemaConfigurationDSL.kt b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/dsl/SchemaConfigurationDSL.kt index 486d0e53..06c02145 100644 --- a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/dsl/SchemaConfigurationDSL.kt +++ b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/dsl/SchemaConfigurationDSL.kt @@ -2,6 +2,7 @@ package com.apurebase.kgraphql.schema.dsl import com.apurebase.kgraphql.configuration.SchemaConfiguration import com.apurebase.kgraphql.schema.execution.ArgumentTransformer +import com.apurebase.kgraphql.schema.execution.ErrorHandler import com.apurebase.kgraphql.schema.execution.GenericTypeResolver import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper @@ -19,6 +20,7 @@ open class SchemaConfigurationDSL { var wrapErrors: Boolean = true var introspection: Boolean = true var genericTypeResolver: GenericTypeResolver = GenericTypeResolver.DEFAULT + var errorHandler: ErrorHandler = ErrorHandler() fun update(block: SchemaConfigurationDSL.() -> Unit) = block() open fun build(): SchemaConfiguration { @@ -32,7 +34,8 @@ open class SchemaConfigurationDSL { wrapErrors = wrapErrors, introspection = introspection, genericTypeResolver = genericTypeResolver, - argumentTransformer = ArgumentTransformer(genericTypeResolver) + argumentTransformer = ArgumentTransformer(genericTypeResolver), + errorHandler = errorHandler ) } } diff --git a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/ErrorHandler.kt b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/ErrorHandler.kt new file mode 100644 index 00000000..8fa87963 --- /dev/null +++ b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/ErrorHandler.kt @@ -0,0 +1,26 @@ +package com.apurebase.kgraphql.schema.execution + +import com.apurebase.kgraphql.Context +import com.apurebase.kgraphql.ExecutionError +import com.apurebase.kgraphql.GraphQLError + +/** + * Error handler used to transform [Throwable]s encountered during execution into [GraphQLError]s included in + * the response. Default implementation will leave existing GraphQL errors as-is, and wrap everything else as + * [ExecutionError]. + * + * Intended to be subclassed for custom error handling while preserving access to default mapping. + * + * Note that exceptions caused during [handleException] are *not* wrapped, and will abort execution. + */ +open class ErrorHandler { + open suspend fun handleException(ctx: Context, node: Execution.Node, exception: Throwable): GraphQLError = + when (exception) { + is GraphQLError -> exception + else -> ExecutionError( + exception.message ?: exception::class.simpleName ?: "Error during execution", + node, + exception + ) + } +} diff --git a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/ParallelRequestExecutor.kt b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/ParallelRequestExecutor.kt index 8eb07fd7..24ee015c 100644 --- a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/ParallelRequestExecutor.kt +++ b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/ParallelRequestExecutor.kt @@ -468,26 +468,27 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { return createNode(ctx, value, node, field.returnType) } - private fun handleException( + private suspend fun handleException( ctx: ExecutionContext, node: Execution.Node, returnType: Type, exception: Throwable ): CompletableDeferred { - if (!schema.configuration.wrapErrors || exception is RequestError || exception is CancellationException) { + if (!schema.configuration.wrapErrors || exception is CancellationException) { throw exception } - val error = exception as? ExecutionError ?: ExecutionError( - exception.message ?: exception::class.simpleName ?: "Error during execution", - node, - exception - ) - if (returnType.isNullable()) { - ctx.requestContext.raiseError(error) - return CompletableDeferred(createNullNode(node, returnType)) - } else { - // Propagate error to parent - throw error + + when (val handledError = schema.configuration.errorHandler.handleException(ctx.requestContext, node, exception)) { + is RequestError -> throw handledError + is ExecutionError -> { + if (returnType.isNullable()) { + ctx.requestContext.raiseError(handledError) + return CompletableDeferred(createNullNode(node, returnType)) + } else { + // Propagate error to parent + throw handledError + } + } } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/QueryTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/QueryTest.kt index a05fcbab..a0797c41 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/QueryTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/QueryTest.kt @@ -6,6 +6,7 @@ import com.apurebase.kgraphql.Director import com.apurebase.kgraphql.ExecutionError import com.apurebase.kgraphql.ExecutionException import com.apurebase.kgraphql.Film +import com.apurebase.kgraphql.GraphQLError import com.apurebase.kgraphql.Id import com.apurebase.kgraphql.KGraphQL import com.apurebase.kgraphql.RequestError @@ -18,6 +19,7 @@ import com.apurebase.kgraphql.expectExecutionError import com.apurebase.kgraphql.expectRequestError import com.apurebase.kgraphql.extract import com.apurebase.kgraphql.helpers.getFields +import com.apurebase.kgraphql.schema.execution.ErrorHandler import com.apurebase.kgraphql.schema.execution.Execution import com.apurebase.kgraphql.schema.scalar.ID import io.kotest.matchers.shouldBe @@ -1188,4 +1190,47 @@ class QueryTest : BaseSchemaTest() { {"errors":[{"message":"Cannot get item 'missing'","locations":[{"line":1,"column":3}],"path":["items"],"extensions":{"type":"NOT_FOUND","reason":"Item is missing"}}],"data":{"items":[{"data":"Existing 1"},{"data":"Existing 2"}]}} """.trimIndent() } + + @Test + fun `should work with custom error handler`() { + val customErrorHandler = object : ErrorHandler() { + override suspend fun handleException(ctx: Context, node: Execution.Node, exception: Throwable): GraphQLError { + return when (exception) { + is IllegalArgumentException -> ExecutionError( + message = exception.message ?: "", + node = node, + extensions = mapOf("type" to "CUSTOM_ERROR_TYPE") + ) + + is IllegalAccessException -> RequestError( + message = "You shall not pass!", + node = node.selectionNode, + extensions = mapOf("required_role" to "ADMIN", "reason" to "Gandalf") + ) + + else -> super.handleException(ctx, node, exception) + } + } + } + + val schema = KGraphQL.schema { + configure { + errorHandler = customErrorHandler + } + query("executionError") { + resolver { throw IllegalArgumentException("Illegal argument") } + } + query("requestError") { + resolver { throw IllegalAccessException() } + } + } + + schema.executeBlocking("{ executionError }") shouldBe """ + {"errors":[{"message":"Illegal argument","locations":[{"line":1,"column":3}],"path":["executionError"],"extensions":{"type":"CUSTOM_ERROR_TYPE"}}],"data":{"executionError":null}} + """.trimIndent() + + schema.executeBlocking("{ requestError }") shouldBe """ + {"errors":[{"message":"You shall not pass!","locations":[{"line":1,"column":3}],"extensions":{"required_role":"ADMIN","reason":"Gandalf"}}]} + """.trimIndent() + } }