From c70d3086e36fa445b3f56799a429d3992b173158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20St=C3=BCbinger?= <41049452+stuebingerb@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:46:09 +0100 Subject: [PATCH] feat!: support partial responses Adds support for proper error handling and partial responses as defined by the spec, cf. https://spec.graphql.org/September2025/#sec-Errors KGraphQL now distinguishes between _request_ and _execution_ errors, and the latter now result in a response that contains both, `data` and `errors`. Execution errors result in a value of `null` for nullable fields, and will bubble up to their parent node for non-nullable fields. Additionally, resolvers can now raise execution errors while still returning data. If data _and_ errors are present in the response, the `errors` key is serialized first to make it more apparent that errors are present. Behavior for request errors is unchanged, and `wrapErrors` configuration is still supported for now - but likely subject to change in the future. Resolves #114 BREAKING CHANGE: relying on exceptions being thrown from query execution may no longer work as before. It is advised to specify `wrapErrors = false` and report an issue with the respective use case. --- docs/content/Reference/errorHandling.md | 200 +++++++--- gradle/libs.versions.toml | 1 + .../api/kgraphql-ktor-stitched.api | 4 - .../stitched/RemoteExecutionException.kt | 17 - .../AbstractRemoteRequestExecutor.kt | 97 +++-- .../execution/StitchedSchemaExecutionTest.kt | 21 +- .../com/apurebase/kgraphql/KtorFeatureTest.kt | 12 +- kgraphql/api/kgraphql.api | 15 +- kgraphql/build.gradle.kts | 1 + .../kotlin/com/apurebase/kgraphql/Context.kt | 10 +- .../com/apurebase/kgraphql/GraphQLError.kt | 2 +- .../kgraphql/helpers/KGraphQLExtensions.kt | 27 +- .../apurebase/kgraphql/request/Variables.kt | 3 +- .../kgraphql/schema/DefaultSchema.kt | 29 +- .../kgraphql/schema/builtin/BuiltInScalars.kt | 32 +- .../schema/execution/ArgumentTransformer.kt | 59 +-- .../execution/ParallelRequestExecutor.kt | 343 ++++++++++-------- .../kgraphql/schema/scalar/Coercion.kt | 23 +- .../kgraphql/access/AccessRulesTest.kt | 17 +- .../configuration/SchemaConfigurationTest.kt | 4 +- .../kgraphql/integration/MutationTest.kt | 12 +- .../integration/ParallelExecutionTest.kt | 27 ++ .../kgraphql/integration/QueryTest.kt | 333 +++++++++++++++-- .../kgraphql/schema/SchemaBuilderTest.kt | 6 +- .../kgraphql/schema/SchemaInheritanceTest.kt | 6 +- .../IntrospectionSpecificationTest.kt | 6 +- .../language/FragmentsSpecificationTest.kt | 10 +- .../language/InputValuesSpecificationTest.kt | 77 ++-- .../language/ListInputCoercionTest.kt | 38 +- .../language/OperationsSpecificationTest.kt | 12 +- .../QueryDocumentSpecificationTest.kt | 6 +- .../language/SourceTextSpecificationTest.kt | 8 +- .../language/VariablesSpecificationTest.kt | 29 +- .../typesystem/DirectivesSpecificationTest.kt | 4 +- .../typesystem/EnumsSpecificationTest.kt | 3 +- .../InputObjectsSpecificationTest.kt | 9 +- .../typesystem/InterfacesSpecificationTest.kt | 4 +- .../typesystem/ListsSpecificationTest.kt | 4 +- .../typesystem/NonNullSpecificationTest.kt | 32 +- .../typesystem/ScalarsSpecificationTest.kt | 15 +- .../typesystem/UnionsSpecificationTest.kt | 8 +- .../com/apurebase/kgraphql/CommonTestUtils.kt | 28 ++ 42 files changed, 1039 insertions(+), 555 deletions(-) delete mode 100644 kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/RemoteExecutionException.kt diff --git a/docs/content/Reference/errorHandling.md b/docs/content/Reference/errorHandling.md index 4e63a4cd..eab43bea 100644 --- a/docs/content/Reference/errorHandling.md +++ b/docs/content/Reference/errorHandling.md @@ -1,83 +1,179 @@ # Error Handling -Error handling is currently implemented in a basic way only, and does for example not support multiple errors or partial responses. +When `wrapErrors` is `true` (which is the default), exceptions encountered during execution of a request will be wrapped, and are returned as part of a well-formed response. -## Basics +## Error Format -When an exception occurs, request execution is aborted and results in an error response depending on the type of exception. +Every error has an entry with the key `message` that contains a human-readable description of the problem. -Exceptions that extend `GraphQLError` via either `RequestError` or `ExecutionError` will be mapped to a response that contains -an `errors` key with optional `locations` and `path` keys detailing where it occurred. -Sub classes can also provide arbitrary `extensions`, by default an error `type` will be added: +If an error can be associated to a point in the GraphQL document, it will contain an entry with the key `locations` that lists all lines and columns this error is referring to. + +If an error can be associated to a particular field, it will contain an entry with the key `path`, containing all segments up to the field where it occurred. This helps clients to distinguish genuine `null` responses from missing values due to errors. + +Additionally, every error can contain an entry with the key `extensions` that is a map of server-specific additions outside of the schema. All built-in errors will be mapped with a `type` extension according to their class. + +Depending on the type of error, the response may also contain a `data` key and partial response data. + +## Request Errors + +_Request errors_ are errors that result in no response data. They are typically raised before execution begins, and may be caused by parsing errors in the request document, invalid syntax or invalid input values for variables. + +Request errors are typically the fault of the requesting client. + +If a request error is raised, the response will only contain an `errors` key with corresponding details. === "Example" ```json { - "errors": [ - { - "message": "Property 'nonexisting' on 'MyType' does not exist", - "locations": [ - { - "line": 3, - "column": 1 - } - ], - "extensions": { - "type": "GRAPHQL_VALIDATION_FAILED" - } - } - ] + "errors": [ + { + "message": "Missing selection set on property 'film' of type 'Film'", + "extensions": { + "type": "GRAPHQL_VALIDATION_FAILED" + } + } + ] } ``` -All built-in exceptions extend `GraphQLError`. +## Execution Errors -## Exceptions From Resolvers +_Execution errors_ (previously called _field errors_ in the spec) are errors raised during execution of a particular field, and result in partial response data. They may be caused by coercion failures or internal errors during function invocation. -What happens with exceptions from resolvers depends on the [schema configuration](configuration.md). +Execution errors are typically the fault of the GraphQL server. -### wrapErrors = true +When an execution error occurs, it is added to the list of errors in the response, and the value of its field is coerced to `null`. If that is a valid value for the field, execution continues with the next sibling. Otherwise, when the field is a non-null type, the error is propagated to the parent field, until a nullable type or the root type is reached. -With `wrapErrors = true` (which is the default), exceptions are wrapped as `ExecutionException`, which is a `GraphQLError`: +Execution errors can lead to partial responses, where some fields can still return proper data. To make partial responses more easily identifiable, the `errors` key will be serialized as first entry in the response JSON. -=== "Example" - ```kotlin - KGraphQL.schema { - configure { - wrapErrors = true +Given the [Star Wars schema](../Tutorials/starwars.md), if fetching one of the friends' names fails in the following operation, the response might contain a friend without a name: + +=== "SDL" + ```graphql + type Hero { + friends: [Hero]! + id: ID! + name: String + } + + type Query { + hero: Hero! + } + ``` +=== "Request" + ```graphql + { + hero { + name + heroFriends: friends { + id + name } - query("throwError") { - resolver { - throw IllegalArgumentException("Illegal argument") + } + } + ``` +=== "Response" + ```json + { + "errors": [ + { + "message": "Name for character with ID 1002 could not be fetched.", + "locations": [{ "line": 6, "column": 7 }], + "path": ["hero", "heroFriends", 1, "name"] + } + ], + "data": { + "hero": { + "name": "R2-D2", + "heroFriends": [ + { + "id": "1000", + "name": "Luke Skywalker" + }, + { + "id": "1002", + "name": null + }, + { + "id": "1003", + "name": "Leia Organa" } + ] } + } } ``` -=== "Response (HTTP 200)" +If the field `name` was declared as non-null, the whole list entry would be missing instead. However, the error itself would still be the same: + +=== "SDL" + ```graphql + type Hero { + friends: [Hero]! + id: ID! + name: String! + } + + type Query { + hero: Hero! + } + ``` +=== "Request" + ```graphql + { + hero { + name + heroFriends: friends { + id + name + } + } + } + ``` +=== "Response" ```json { - "errors": [ + "errors": [ + { + "message": "Name for character with ID 1002 could not be fetched.", + "locations": [{ "line": 6, "column": 7 }], + "path": ["hero", "heroFriends", 1, "name"] + } + ], + "data": { + "hero": { + "name": "R2-D2", + "heroFriends": [ { - "message": "Illegal argument", - "locations": [ - { - "line": 2, - "column": 1 - } - ], - "path": [ - "throwError" - ], - "extensions": { - "type": "INTERNAL_SERVER_ERROR" - } + "id": "1000", + "name": "Luke Skywalker" + }, + null, + { + "id": "1003", + "name": "Leia Organa" } - ] + ] + } + } } ``` -### wrapErrors = false +## Raising Errors From Resolvers + +In addition to returning (partial) data, resolvers can also add execution errors to the response via `Context.raiseError`: + +=== "Example" + ```kotlin + query("items") { + resolver { node: Execution.Node, ctx: Context -> + ctx.raiseError(MissingItemError("Cannot get item 'missing'", node)) + listOf(Item("Existing 1"), Item("Existing 2")) + } + } + ``` + +## wrapErrors = false With `wrapErrors = false`, exceptions are re-thrown: @@ -94,7 +190,6 @@ With `wrapErrors = false`, exceptions are re-thrown: } } ``` - === "Response (HTTP 500)" ```html @@ -121,8 +216,9 @@ Those re-thrown exceptions could then be handled with the [`StatusPages` Ktor pl } } ``` - === "Response (HTTP 400)" ```text Invalid input: java.lang.IllegalArgumentException: Illegal argument ``` + +Because exceptions are re-thrown, `wrapErrors = false` can never result in partial responses. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4c89fee4..e6bec773 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ 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" } kotest = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } +kotest-json = { module = "io.kotest:kotest-assertions-json", version.ref = "kotest" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit-jupiter" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit-jupiter" } kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinx-benchmark" } diff --git a/kgraphql-ktor-stitched/api/kgraphql-ktor-stitched.api b/kgraphql-ktor-stitched/api/kgraphql-ktor-stitched.api index ee4808b8..6f539681 100644 --- a/kgraphql-ktor-stitched/api/kgraphql-ktor-stitched.api +++ b/kgraphql-ktor-stitched/api/kgraphql-ktor-stitched.api @@ -1,7 +1,3 @@ -public final class com/apurebase/kgraphql/stitched/RemoteExecutionException : com/apurebase/kgraphql/ExecutionError { - public fun (Ljava/lang/String;Lcom/apurebase/kgraphql/schema/execution/Execution$Remote;)V -} - public final class com/apurebase/kgraphql/stitched/StitchedGraphQL { public static final field Feature Lcom/apurebase/kgraphql/stitched/StitchedGraphQL$Feature; public fun (Lcom/apurebase/kgraphql/schema/Schema;)V diff --git a/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/RemoteExecutionException.kt b/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/RemoteExecutionException.kt deleted file mode 100644 index 028b42b7..00000000 --- a/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/RemoteExecutionException.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.apurebase.kgraphql.stitched - -import com.apurebase.kgraphql.BuiltInErrorCodes -import com.apurebase.kgraphql.ExecutionError -import com.apurebase.kgraphql.ExperimentalAPI -import com.apurebase.kgraphql.schema.execution.Execution - -// TODO: support multiple remote errors -@ExperimentalAPI -class RemoteExecutionException(message: String, node: Execution.Remote) : ExecutionError( - message = message, - node = node, - extensions = mapOf( - "type" to BuiltInErrorCodes.INTERNAL_SERVER_ERROR.name, - "detail" to mapOf("remoteUrl" to node.remoteUrl, "remoteOperation" to node.remoteOperation) - ) -) diff --git a/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/execution/AbstractRemoteRequestExecutor.kt b/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/execution/AbstractRemoteRequestExecutor.kt index b3034c4f..0bd7810a 100644 --- a/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/execution/AbstractRemoteRequestExecutor.kt +++ b/kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/schema/execution/AbstractRemoteRequestExecutor.kt @@ -1,5 +1,6 @@ package com.apurebase.kgraphql.stitched.schema.execution +import com.apurebase.kgraphql.BuiltInErrorCodes import com.apurebase.kgraphql.Context import com.apurebase.kgraphql.ExecutionError import com.apurebase.kgraphql.ExperimentalAPI @@ -11,16 +12,33 @@ import com.apurebase.kgraphql.schema.model.ast.SelectionNode import com.apurebase.kgraphql.schema.model.ast.ValueNode import com.apurebase.kgraphql.schema.structure.Field import com.apurebase.kgraphql.schema.structure.Type -import com.apurebase.kgraphql.stitched.RemoteExecutionException import com.apurebase.kgraphql.stitched.StitchedGraphqlRequest -import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.annotation.JsonIgnoreProperties 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.IntNode -import com.fasterxml.jackson.databind.node.LongNode import com.fasterxml.jackson.databind.node.ObjectNode -import com.fasterxml.jackson.databind.node.TextNode +import com.fasterxml.jackson.module.kotlin.readValue + +/** + * Custom remote execution error to be able to provide a [path] via constructor. + */ +private class RemoteExecutionError( + message: String, + extensions: Map, + node: Execution.Remote, + override val path: List +) : ExecutionError(message, node, extensions = extensions) + +@JsonIgnoreProperties(ignoreUnknown = true) +private class ResponseError( + val message: String, + val path: List?, + // reponse location is not mapped because we want the local location anyway + val extensions: Map? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +private class StitchedGraphQLResponse(val data: ObjectNode?, val errors: List?) @ExperimentalAPI abstract class AbstractRemoteRequestExecutor(private val objectMapper: ObjectMapper) : RemoteRequestExecutor { @@ -34,59 +52,36 @@ abstract class AbstractRemoteRequestExecutor(private val objectMapper: ObjectMap /** * 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? { + final override suspend fun execute(node: Execution.Remote, ctx: Context): JsonNode? = runCatching { val remoteUrl = node.remoteUrl val request = toGraphQLRequest(node, ctx) - val response = runCatching { - executeRequest(remoteUrl, request, ctx) - }.getOrElse { - """ - { "errors": [ { "message": "${it.message}" } ] } - """.trimIndent() - } - val responseJson = objectMapper.readTree(response) - responseJson["errors"]?.let { errors -> - (errors as? ArrayNode)?.forEach { error -> - val objectNode = error as? ObjectNode - val message = objectNode?.get("message")?.textValue()?.takeIf { it.isNotBlank() } - ?: "Error(s) during remote execution" - val extensionsNode = objectNode?.get("extensions") as? ObjectNode - val extensions = mapOf("remoteUrl" to node.remoteUrl, "remoteOperation" to node.remoteOperation) + - extensionsNode?.let { - objectMapper.convertValue(it, object : TypeReference>() {}) - }.orEmpty() - // Build a custom node for the remote error that includes the returned path - val executionErrorNode = object : Execution.Node( - node.selectionNode, - node.field, - node.children, - node.arguments, - node.directives, - node.variables, - node.arrayIndex, - node - ) { + val response = objectMapper.readValue(executeRequest(remoteUrl, request, ctx)) + response.errors?.forEach { error -> + ctx.raiseError( + RemoteExecutionError( + error.message, + node.errorExtensions() + error.extensions.orEmpty(), + node, // The first path segment of the remote error is the executed query. As this is transparent from our // stitched schema, we need to remove that segment for a proper path. - override val fullPath: List = node.fullPath + ((objectNode?.get("path") as? ArrayNode)?.map { - when (it) { - is IntNode, is LongNode -> it.longValue() - is TextNode -> it.textValue() - else -> it.toString() - } - }?.drop(1) ?: emptyList()) - } - throw ExecutionError( - message = message, - node = executionErrorNode, - extensions = extensions + node.fullPath + error.path?.drop(1).orEmpty() ) - } - throw RemoteExecutionException(message = "Error(s) during remote execution", node = node) + ) } - return responseJson["data"]?.get(node.remoteOperation) + response.data?.get(node.remoteOperation) + }.getOrElse { + ctx.raiseError( + ExecutionError(it.message ?: it.javaClass.simpleName, node, it, node.errorExtensions()) + ) + null } + private fun Execution.Remote.errorExtensions() = mapOf( + "remoteUrl" to remoteUrl, + "remoteOperation" to remoteOperation, + "type" to BuiltInErrorCodes.INTERNAL_SERVER_ERROR.name + ) + private fun SelectionNode.FieldNode.alias() = alias?.let { "${it.value}: " } ?: "" diff --git a/kgraphql-ktor-stitched/src/test/kotlin/com/apurebase/kgraphql/stitched/schema/execution/StitchedSchemaExecutionTest.kt b/kgraphql-ktor-stitched/src/test/kotlin/com/apurebase/kgraphql/stitched/schema/execution/StitchedSchemaExecutionTest.kt index a6554cac..f9a93454 100644 --- a/kgraphql-ktor-stitched/src/test/kotlin/com/apurebase/kgraphql/stitched/schema/execution/StitchedSchemaExecutionTest.kt +++ b/kgraphql-ktor-stitched/src/test/kotlin/com/apurebase/kgraphql/stitched/schema/execution/StitchedSchemaExecutionTest.kt @@ -3047,7 +3047,7 @@ class StitchedSchemaExecutionTest { } } query("failRemoteObject") { - resolver { localName: String -> + resolver, String> { localName: String -> listOf( RemoteType(localName, "remoteObject1"), RemoteType(localName, "remoteObject2") @@ -3055,10 +3055,10 @@ class StitchedSchemaExecutionTest { } } type { - property("problematic") { + property("problematic") { resolver { parent: RemoteType -> if (parent.localName == "local1" && parent.name == "remoteObject2") { - throw Exception() + throw Exception("I fail") } else { "unproblematic" } @@ -3080,7 +3080,7 @@ class StitchedSchemaExecutionTest { } localSchema { query("failLocal") { - resolver { node: Execution.Node -> + resolver { node: Execution.Node -> throw ExecutionError( message = "don't call me local!", node = node, @@ -3119,33 +3119,32 @@ class StitchedSchemaExecutionTest { """.trimIndent() // Query that failed during execution - // TODO: should IMHO contain a data key according to https://spec.graphql.org/draft/#sec-Response-Format client.post("local") { header(HttpHeaders.ContentType, ContentType.Application.Json) setBody(graphqlRequest("{ failLocal }")) }.bodyAsText() shouldBe """ - {"errors":[{"message":"don't call me local!","locations":[{"line":1,"column":3}],"path":["failLocal"],"extensions":{"type":"INTERNAL_SERVER_ERROR","detail":{"localErrorKey":"localErrorValue"}}}]} + {"errors":[{"message":"don't call me local!","locations":[{"line":1,"column":3}],"path":["failLocal"],"extensions":{"type":"INTERNAL_SERVER_ERROR","detail":{"localErrorKey":"localErrorValue"}}}],"data":{"failLocal":null}} """.trimIndent() client.post("local") { header(HttpHeaders.ContentType, ContentType.Application.Json) setBody(graphqlRequest("{ failRemote }")) }.bodyAsText() shouldBe """ - {"errors":[{"message":"don't call me remote!","locations":[{"line":1,"column":3}],"path":["failRemote"],"extensions":{"remoteUrl":"remote","remoteOperation":"failRemote","type":"BAD_USER_INPUT","remoteErrorKey":["remoteErrorValue1","remoteErrorValue2"]}}]} + {"errors":[{"message":"don't call me remote!","locations":[{"line":1,"column":3}],"path":["failRemote"],"extensions":{"remoteUrl":"remote","remoteOperation":"failRemote","type":"BAD_USER_INPUT","remoteErrorKey":["remoteErrorValue1","remoteErrorValue2"]}},{"message":"Null result for non-nullable operation 'failRemote'","locations":[{"line":1,"column":3}],"path":["failRemote"],"extensions":{"type":"INTERNAL_SERVER_ERROR"}}],"data":null} """.trimIndent() client.post("local") { header(HttpHeaders.ContentType, ContentType.Application.Json) setBody(graphqlRequest("{ failRemote2 }")) }.bodyAsText() shouldBe """ - {"errors":[{"message":"Error(s) during remote execution","locations":[{"line":1,"column":3}],"path":["failRemote2"],"extensions":{"remoteUrl":"remote","remoteOperation":"failRemote2","type":"INTERNAL_SERVER_ERROR"}}]} + {"errors":[{"message":"IllegalStateException","locations":[{"line":1,"column":3}],"path":["failRemote2"],"extensions":{"remoteUrl":"remote","remoteOperation":"failRemote2","type":"INTERNAL_SERVER_ERROR"}},{"message":"Null result for non-nullable operation 'failRemote2'","locations":[{"line":1,"column":3}],"path":["failRemote2"],"extensions":{"type":"INTERNAL_SERVER_ERROR"}}],"data":null} """.trimIndent() client.post("local") { header(HttpHeaders.ContentType, ContentType.Application.Json) setBody(graphqlRequest("{ localTypes { name stitchedProperty { name localName problematic } } }")) }.bodyAsText() shouldBe """ - {"errors":[{"message":"Error(s) during remote execution","locations":[{"line":1,"column":21}],"path":["localTypes",0,"stitchedProperty",1,"problematic"],"extensions":{"remoteUrl":"remote","remoteOperation":"failRemoteObject","type":"INTERNAL_SERVER_ERROR"}}]} + {"errors":[{"message":"I fail","locations":[{"line":1,"column":21}],"path":["localTypes",0,"stitchedProperty",1,"problematic"],"extensions":{"remoteUrl":"remote","remoteOperation":"failRemoteObject","type":"INTERNAL_SERVER_ERROR"}}],"data":{"localTypes":[{"name":"local1","stitchedProperty":[{"name":"remoteObject1","localName":"local1","problematic":"unproblematic"},{"name":"remoteObject2","localName":"local1","problematic":null}]},{"name":"local2","stitchedProperty":[{"name":"remoteObject1","localName":"local2","problematic":"unproblematic"},{"name":"remoteObject2","localName":"local2","problematic":"unproblematic"}]}]}} """.trimIndent() } @@ -3195,14 +3194,14 @@ class StitchedSchemaExecutionTest { header(HttpHeaders.ContentType, ContentType.Application.Json) setBody(graphqlRequest("{ remoteString }")) }.bodyAsText() shouldBe """ - {"errors":[{"message":"Connection timed out","locations":[{"line":1,"column":3}],"path":["remoteString"],"extensions":{"remoteUrl":"remote","remoteOperation":"remoteString"}}]} + {"errors":[{"message":"Connection timed out","locations":[{"line":1,"column":3}],"path":["remoteString"],"extensions":{"remoteUrl":"remote","remoteOperation":"remoteString","type":"INTERNAL_SERVER_ERROR"}}],"data":{"remoteString":null}} """.trimIndent() client.post("local") { header(HttpHeaders.ContentType, ContentType.Application.Json) setBody(graphqlRequest("{ localTypes { name stitchedProperty } }")) }.bodyAsText() shouldBe """ - {"errors":[{"message":"Connection timed out","locations":[{"line":1,"column":21}],"path":["localTypes",0,"stitchedProperty"],"extensions":{"remoteUrl":"remote","remoteOperation":"remoteString"}}]} + {"errors":[{"message":"Connection timed out","locations":[{"line":1,"column":21}],"path":["localTypes",0,"stitchedProperty"],"extensions":{"remoteUrl":"remote","remoteOperation":"remoteString","type":"INTERNAL_SERVER_ERROR"}}],"data":{"localTypes":[{"name":"local1","stitchedProperty":null}]}} """.trimIndent() } 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 26b57b1a..4ab350a7 100644 --- a/kgraphql-ktor/src/test/kotlin/com/apurebase/kgraphql/KtorFeatureTest.kt +++ b/kgraphql-ktor/src/test/kotlin/com/apurebase/kgraphql/KtorFeatureTest.kt @@ -189,7 +189,7 @@ class KtorFeatureTest : KtorTest() { } runBlocking { response.bodyAsText() shouldBe """ - {"errors":[{"message":"Actors above 30 don't have nicknames","locations":[{"line":3,"column":1}],"path":["actors",1,"nickname"],"extensions":{"type":"INTERNAL_SERVER_ERROR"}}]} + {"errors":[{"message":"Actors above 30 don't have nicknames","locations":[{"line":3,"column":1}],"path":["actors",1,"nickname"],"extensions":{"type":"INTERNAL_SERVER_ERROR"}}],"data":null} """.trimIndent() response.contentType() shouldBe ContentType.Application.Json } @@ -207,7 +207,7 @@ class KtorFeatureTest : KtorTest() { } } query("persons") { - resolver { -> + resolver?> { listOf( Person("Mary", 32, Movie("Sharks", listOf(Actor("George", 23), Actor("Jack", 21)))), Person("Jimmy", 11, Movie("Unknown", listOf(Actor("John", 42)))) @@ -229,7 +229,7 @@ class KtorFeatureTest : KtorTest() { } runBlocking { response.bodyAsText() shouldBe """ - {"errors":[{"message":"Actors above 30 don't have nicknames","locations":[{"line":9,"column":1}],"path":["persons",1,"favouriteMovie","actors",0,"nickname"],"extensions":{"type":"INTERNAL_SERVER_ERROR"}}]} + {"errors":[{"message":"Actors above 30 don't have nicknames","locations":[{"line":9,"column":1}],"path":["persons",1,"favouriteMovie","actors",0,"nickname"],"extensions":{"type":"INTERNAL_SERVER_ERROR"}}],"data":{"persons":null}} """.trimIndent() response.contentType() shouldBe ContentType.Application.Json } @@ -273,7 +273,7 @@ class KtorFeatureTest : KtorTest() { field("error") } runBlocking { - response.bodyAsText() shouldBe "{\"errors\":[{\"message\":\"Error message\",\"extensions\":{\"type\":\"INTERNAL_SERVER_ERROR\"}}]}" + 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 } } @@ -282,7 +282,7 @@ class KtorFeatureTest : KtorTest() { fun `should work without error handler`() { val server = withServer { query("error") { - resolver { throw Exception("Error message") } + resolver { throw Exception("Error message") } } } @@ -290,7 +290,7 @@ class KtorFeatureTest : KtorTest() { field("error") } runBlocking { - response.bodyAsText() shouldBe "{\"errors\":[{\"message\":\"Error message\",\"locations\":[{\"line\":2,\"column\":1}],\"path\":[\"error\"],\"extensions\":{\"type\":\"INTERNAL_SERVER_ERROR\"}}]}" + response.bodyAsText() shouldBe "{\"errors\":[{\"message\":\"Error message\",\"locations\":[{\"line\":2,\"column\":1}],\"path\":[\"error\"],\"extensions\":{\"type\":\"INTERNAL_SERVER_ERROR\"}}],\"data\":{\"error\":null}}" response.contentType() shouldBe ContentType.Application.Json } } diff --git a/kgraphql/api/kgraphql.api b/kgraphql/api/kgraphql.api index 25470eb4..6f0fd667 100644 --- a/kgraphql/api/kgraphql.api +++ b/kgraphql/api/kgraphql.api @@ -9,9 +9,11 @@ public final class com/apurebase/kgraphql/BuiltInErrorCodes : java/lang/Enum { } public final class com/apurebase/kgraphql/Context { - public fun (Ljava/util/Map;)V + public fun (Ljava/util/Map;Ljava/util/List;)V + public synthetic fun (Ljava/util/Map;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun get (Lkotlin/reflect/KClass;)Ljava/lang/Object; public final fun plus (Ljava/lang/Object;)Lcom/apurebase/kgraphql/Context; + public final fun raiseError (Lcom/apurebase/kgraphql/ExecutionError;)Z } public final class com/apurebase/kgraphql/ContextBuilder { @@ -52,9 +54,9 @@ public abstract class com/apurebase/kgraphql/GraphQLError : java/lang/Exception public fun serialize ()Ljava/lang/String; } -public final class com/apurebase/kgraphql/InvalidInputValueException : com/apurebase/kgraphql/RequestError { - public fun (Ljava/lang/String;Lcom/apurebase/kgraphql/schema/model/ast/ASTNode;Ljava/lang/Throwable;)V - public synthetic fun (Ljava/lang/String;Lcom/apurebase/kgraphql/schema/model/ast/ASTNode;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class com/apurebase/kgraphql/InvalidInputValueException : com/apurebase/kgraphql/ExecutionError { + public fun (Ljava/lang/String;Lcom/apurebase/kgraphql/schema/execution/Execution;Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/String;Lcom/apurebase/kgraphql/schema/execution/Execution;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class com/apurebase/kgraphql/InvalidSyntaxException : com/apurebase/kgraphql/RequestError { @@ -899,10 +901,9 @@ public final class com/apurebase/kgraphql/schema/execution/ParallelRequestExecut } public final class com/apurebase/kgraphql/schema/execution/ParallelRequestExecutor$ExecutionContext { - public fun (Lkotlinx/coroutines/CoroutineScope;Lcom/apurebase/kgraphql/request/Variables;Lcom/apurebase/kgraphql/Context;Ljava/util/Map;)V + public fun (Lcom/apurebase/kgraphql/request/Variables;Lcom/apurebase/kgraphql/Context;Ljava/util/Map;)V public final fun getLoaders ()Ljava/util/Map; public final fun getRequestContext ()Lcom/apurebase/kgraphql/Context; - public final fun getScope ()Lkotlinx/coroutines/CoroutineScope; public final fun getVariables ()Lcom/apurebase/kgraphql/request/Variables; } @@ -2012,7 +2013,7 @@ public abstract interface class com/apurebase/kgraphql/schema/scalar/BooleanScal } public final class com/apurebase/kgraphql/schema/scalar/CoercionKt { - public static final fun deserializeScalar (Lcom/apurebase/kgraphql/schema/structure/Type$Scalar;Lcom/apurebase/kgraphql/schema/model/ast/ValueNode;)Ljava/lang/Object; + public static final fun deserializeScalar (Lcom/apurebase/kgraphql/schema/structure/Type$Scalar;Lcom/apurebase/kgraphql/schema/model/ast/ValueNode;Lcom/apurebase/kgraphql/schema/execution/Execution;)Ljava/lang/Object; public static final fun serializeScalar (Lcom/fasterxml/jackson/databind/node/JsonNodeFactory;Lcom/apurebase/kgraphql/schema/structure/Type$Scalar;Ljava/lang/Object;Lcom/apurebase/kgraphql/schema/execution/Execution;)Lcom/fasterxml/jackson/databind/node/ValueNode; } diff --git a/kgraphql/build.gradle.kts b/kgraphql/build.gradle.kts index 7d070fed..5cf4657c 100644 --- a/kgraphql/build.gradle.kts +++ b/kgraphql/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testFixturesImplementation(libs.kotest) + testFixturesImplementation(libs.kotest.json) benchmarkImplementation(libs.kotlinx.benchmark.runtime) } diff --git a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/Context.kt b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/Context.kt index 6259c1dc..444c0358 100644 --- a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/Context.kt +++ b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/Context.kt @@ -1,10 +1,14 @@ package com.apurebase.kgraphql import com.apurebase.kgraphql.schema.introspection.NotIntrospected +import java.util.Collections import kotlin.reflect.KClass @NotIntrospected -class Context(private val map: Map, Any>) { +class Context( + private val map: Map, Any>, + internal val errors: MutableList = Collections.synchronizedList(mutableListOf()) +) { operator fun get(kClass: KClass): T? { val value = map[kClass] @@ -18,5 +22,7 @@ class Context(private val map: Map, Any>) { inline fun get(): T? = get(T::class) - operator fun plus(value: T): Context = Context(map + (value::class to value)) + operator fun plus(value: T): Context = Context(map + (value::class to value), errors) + + fun raiseError(error: ExecutionError) = errors.add(error) } diff --git a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/GraphQLError.kt b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/GraphQLError.kt index 8f24dd6d..a4a79c45 100644 --- a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/GraphQLError.kt +++ b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/GraphQLError.kt @@ -188,7 +188,7 @@ class ExecutionException(message: String, node: Execution, cause: Throwable? = n extensions = mapOf("type" to BuiltInErrorCodes.INTERNAL_SERVER_ERROR.name) ) -class InvalidInputValueException(message: String, node: ASTNode?, originalError: Throwable? = null) : RequestError( +class InvalidInputValueException(message: String, node: Execution, originalError: Throwable? = null) : ExecutionError( message = message, node = node, originalError = originalError, diff --git a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/helpers/KGraphQLExtensions.kt b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/helpers/KGraphQLExtensions.kt index ecba0249..fd7203ae 100644 --- a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/helpers/KGraphQLExtensions.kt +++ b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/helpers/KGraphQLExtensions.kt @@ -1,12 +1,14 @@ package com.apurebase.kgraphql.helpers -import com.apurebase.kgraphql.InvalidInputValueException +import com.apurebase.kgraphql.ExecutionError +import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.schema.execution.Execution import com.apurebase.kgraphql.schema.introspection.TypeKind import com.apurebase.kgraphql.schema.introspection.__Type import com.apurebase.kgraphql.schema.model.ast.NameNode import com.apurebase.kgraphql.schema.model.ast.ValueNode 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.BooleanNode import com.fasterxml.jackson.databind.node.DoubleNode @@ -111,9 +113,8 @@ fun JsonNode?.toValueNode(expectedType: __Type): ValueNode = when (this) { "Expected INPUT_OBJECT for ${expectedType.unwrapped().name} but got ${expectedType.kind}" } val expectedPropType = inputFields.firstOrNull { it.name == prop.key }?.type - ?: throw InvalidInputValueException( - "Property '${prop.key}' on '${expectedType.unwrapped().name}' does not exist", - null + ?: throw ValidationException( + "Property '${prop.key}' on '${expectedType.unwrapped().name}' does not exist" ) ValueNode.ObjectValueNode.ObjectFieldNode( null, @@ -129,3 +130,21 @@ fun JsonNode?.toValueNode(expectedType: __Type): ValueNode = when (this) { } internal fun Double.isWholeNumber() = this % 1.0 == 0.0 + +internal fun List.toJsonNode(objectMapper: ObjectMapper): ArrayNode = + objectMapper.createArrayNode().apply { + addAll( + this@toJsonNode.map { error -> + objectMapper.createObjectNode().apply { + put("message", error.message) + error.locations?.let { + set("locations", objectMapper.valueToTree(it)) + } + set("path", objectMapper.valueToTree(error.path)) + error.extensions?.let { + set("extensions", objectMapper.valueToTree(it)) + } + } + } + ) + } diff --git a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/request/Variables.kt b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/request/Variables.kt index d93631f3..dffbc725 100644 --- a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/request/Variables.kt +++ b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/request/Variables.kt @@ -1,6 +1,5 @@ package com.apurebase.kgraphql.request -import com.apurebase.kgraphql.InvalidInputValueException import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.schema.model.ast.TypeNode import com.apurebase.kgraphql.schema.model.ast.ValueNode @@ -38,7 +37,7 @@ data class Variables(private val variablesJson: VariablesJson, private val varia val invalidNullability = (invalidTypeNullability || invalidElementNullability) && noDefaultProvided if (invalidName || invalidIsList || invalidNullability) { - throw InvalidInputValueException( + throw ValidationException( "Invalid variable '$${variable.variable.name.value}' argument type '${variableType.nameNode.value}', expected '${expectedType.typeReference()}'", variable ) diff --git a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/DefaultSchema.kt b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/DefaultSchema.kt index c31372da..e967fef3 100644 --- a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/DefaultSchema.kt +++ b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/DefaultSchema.kt @@ -1,6 +1,7 @@ package com.apurebase.kgraphql.schema import com.apurebase.kgraphql.Context +import com.apurebase.kgraphql.RequestError import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.configuration.SchemaConfiguration import com.apurebase.kgraphql.request.CachingDocumentParser @@ -43,21 +44,25 @@ class DefaultSchema( context: Context, operationName: String?, ): String = coroutineScope { - if (!configuration.introspection && Introspection.isIntrospection(request)) { - throw ValidationException("GraphQL introspection is not allowed") - } + try { + if (!configuration.introspection && Introspection.isIntrospection(request)) { + throw ValidationException("GraphQL introspection is not allowed") + } - val parsedVariables = variables - ?.let { VariablesJson.Defined(configuration.objectMapper.readTree(variables)) } - ?: VariablesJson.Empty() + val parsedVariables = variables + ?.let { VariablesJson.Defined(configuration.objectMapper.readTree(variables)) } + ?: VariablesJson.Empty() - val document = requestParser.parseDocument(request) + val document = requestParser.parseDocument(request) - requestExecutor.suspendExecute( - plan = requestInterpreter.createExecutionPlan(document, operationName, parsedVariables), - variables = parsedVariables, - context = context - ) + requestExecutor.suspendExecute( + plan = requestInterpreter.createExecutionPlan(document, operationName, parsedVariables), + variables = parsedVariables, + context = context + ) + } catch (e: RequestError) { + e.serialize() + } } override fun printSchema() = SchemaPrinter().print(model) diff --git a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/builtin/BuiltInScalars.kt b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/builtin/BuiltInScalars.kt index 3dee20de..f511a4ea 100644 --- a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/builtin/BuiltInScalars.kt +++ b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/builtin/BuiltInScalars.kt @@ -2,7 +2,7 @@ package com.apurebase.kgraphql.schema.builtin -import com.apurebase.kgraphql.InvalidInputValueException +import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.defaultKQLTypeName import com.apurebase.kgraphql.schema.model.TypeDef import com.apurebase.kgraphql.schema.model.ast.ValueNode @@ -66,7 +66,7 @@ object STRING_COERCION : StringScalarCoercion { override fun deserialize(raw: String, valueNode: ValueNode) = when (valueNode) { is StringValueNode -> valueNode.value - else -> throw InvalidInputValueException("Cannot coerce '${valueNode.valueNodeName}' to String", valueNode) + else -> throw ValidationException("Cannot coerce '${valueNode.valueNodeName}' to String", valueNode) } } @@ -77,7 +77,7 @@ object DOUBLE_COERCION : StringScalarCoercion { is DoubleValueNode -> valueNode.value is NumberValueNode -> valueNode.value.toDouble() - else -> throw InvalidInputValueException("Cannot coerce '${valueNode.valueNodeName}' to Float", valueNode) + else -> throw ValidationException("Cannot coerce '${valueNode.valueNodeName}' to Float", valueNode) } } @@ -88,7 +88,7 @@ object FLOAT_COERCION : StringScalarCoercion { is DoubleValueNode -> valueNode.value.toFloat() is NumberValueNode -> valueNode.value.toFloat() - else -> throw InvalidInputValueException("Cannot coerce '${valueNode.valueNodeName}' to Float", valueNode) + else -> throw ValidationException("Cannot coerce '${valueNode.valueNodeName}' to Float", valueNode) } } @@ -97,12 +97,12 @@ object INT_COERCION : StringScalarCoercion { override fun deserialize(raw: String, valueNode: ValueNode) = when (valueNode) { is NumberValueNode -> when { - valueNode.value > Integer.MAX_VALUE -> throw InvalidInputValueException( + valueNode.value > Integer.MAX_VALUE -> throw ValidationException( "Cannot coerce '${valueNode.valueNodeName}' to Int as it is greater than (2^-31)-1", valueNode ) - valueNode.value < Integer.MIN_VALUE -> throw InvalidInputValueException( + valueNode.value < Integer.MIN_VALUE -> throw ValidationException( "Cannot coerce '${valueNode.valueNodeName}' to Int as it is less than -(2^-31)", valueNode ) @@ -110,7 +110,7 @@ object INT_COERCION : StringScalarCoercion { else -> valueNode.value.toInt() } - else -> throw InvalidInputValueException("Cannot coerce '${valueNode.valueNodeName}' to Int", valueNode) + else -> throw ValidationException("Cannot coerce '${valueNode.valueNodeName}' to Int", valueNode) } } @@ -119,12 +119,12 @@ object SHORT_COERCION : StringScalarCoercion { override fun deserialize(raw: String, valueNode: ValueNode) = when (valueNode) { is NumberValueNode -> when { - valueNode.value > Short.MAX_VALUE -> throw InvalidInputValueException( + valueNode.value > Short.MAX_VALUE -> throw ValidationException( "Cannot coerce '${valueNode.value}' to Short as it is greater than (2^-15)-1", valueNode ) - valueNode.value < Short.MIN_VALUE -> throw InvalidInputValueException( + valueNode.value < Short.MIN_VALUE -> throw ValidationException( "Cannot coerce '${valueNode.value}' to Short as it is less than -(2^-15)", valueNode ) @@ -132,7 +132,7 @@ object SHORT_COERCION : StringScalarCoercion { else -> valueNode.value.toShort() } - else -> throw InvalidInputValueException("Cannot coerce '${valueNode.valueNodeName}' to Short", valueNode) + else -> throw ValidationException("Cannot coerce '${valueNode.valueNodeName}' to Short", valueNode) } } @@ -141,7 +141,7 @@ object LONG_COERCION : StringScalarCoercion { override fun deserialize(raw: String, valueNode: ValueNode) = when (valueNode) { is NumberValueNode -> valueNode.value - else -> throw InvalidInputValueException("Cannot coerce '${valueNode.valueNodeName}' to Long", valueNode) + else -> throw ValidationException("Cannot coerce '${valueNode.valueNodeName}' to Long", valueNode) } } @@ -154,17 +154,17 @@ object BOOLEAN_COERCION : StringScalarCoercion { valueNode.value.equals("true", true) -> true valueNode.value.equals("false", true) -> false - else -> throw IllegalArgumentException("Cannot coerce '${valueNode.value}' to Boolean") + else -> throw ValidationException("Cannot coerce '${valueNode.valueNodeName}' to Boolean") } is NumberValueNode -> when (valueNode.value) { 0L, -1L -> false 1L -> true - else -> throw IllegalArgumentException("Cannot coerce '${valueNode.value}' to Boolean") + else -> throw ValidationException("Cannot coerce '${valueNode.value}' to Boolean") } - else -> throw InvalidInputValueException("Cannot coerce '${valueNode.valueNodeName}' to Boolean", valueNode) + else -> throw ValidationException("Cannot coerce '${valueNode.valueNodeName}' to Boolean", valueNode) } } @@ -175,7 +175,7 @@ object ID_COERCION : StringScalarCoercion { is StringValueNode -> ID(valueNode.value) is NumberValueNode -> ID(valueNode.value.toString()) - else -> throw InvalidInputValueException("Cannot coerce '${valueNode.valueNodeName}' to ID", valueNode) + else -> throw ValidationException("Cannot coerce '${valueNode.valueNodeName}' to ID", valueNode) } } @@ -188,6 +188,6 @@ object CHAR_COERCION : StringScalarCoercion { valueNode.value.toInt() ) - else -> throw InvalidInputValueException("Cannot coerce '${valueNode.valueNodeName}' to Char", valueNode) + else -> throw ValidationException("Cannot coerce '${valueNode.valueNodeName}' to Char", valueNode) } } diff --git a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/ArgumentTransformer.kt b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/ArgumentTransformer.kt index 98b47933..50c826ac 100644 --- a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/ArgumentTransformer.kt +++ b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/execution/ArgumentTransformer.kt @@ -1,7 +1,9 @@ package com.apurebase.kgraphql.schema.execution import com.apurebase.kgraphql.Context +import com.apurebase.kgraphql.ExecutionError import com.apurebase.kgraphql.InvalidInputValueException +import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.request.Variables import com.apurebase.kgraphql.schema.introspection.TypeKind import com.apurebase.kgraphql.schema.model.FunctionWrapper @@ -31,7 +33,7 @@ open class ArgumentTransformer(val genericTypeResolver: GenericTypeResolver) { } if (unsupportedArguments?.isNotEmpty() == true) { - throw InvalidInputValueException( + throw ValidationException( "'$funName' does support arguments: ${inputValues.map { it.name }}. Found arguments: ${args.keys}", executionNode.selectionNode ) @@ -48,16 +50,16 @@ open class ArgumentTransformer(val genericTypeResolver: GenericTypeResolver) { value == null && parameter.type.kind == TypeKind.NON_NULL -> { parameter.default ?: throw InvalidInputValueException( "Argument '${parameter.name}' of type ${parameter.type.typeReference()} on field '$funName' is not nullable, value cannot be null", - executionNode.selectionNode + executionNode ) } else -> { - val transformedValue = transformValue(parameter.type, value!!, variables, true, parameter.default) + val transformedValue = transformValue(parameter.type, value!!, variables, true, parameter.default, executionNode) if (transformedValue == null && parameter.type.isNotNullable()) { throw InvalidInputValueException( - "Argument ${parameter.name} is not optional, value cannot be null", - executionNode.selectionNode + "Argument '${parameter.name}' is not optional, value cannot be null", + executionNode ) } transformedValue @@ -76,12 +78,13 @@ open class ArgumentTransformer(val genericTypeResolver: GenericTypeResolver) { coerceSingleValueAsList: Boolean, // Location default value to allow calling with a nullable variable type, cf. // https://spec.graphql.org/September2025/#sec-All-Variable-Usages-Are-Allowed.Allowing-Optional-Variables-When-Default-Values-Exist - locationDefaultValue: Any? + locationDefaultValue: Any?, + executionNode: Execution ): Any? { return when { value is ValueNode.VariableNode -> { variables.get(type, value, locationDefaultValue) - ?.let { transformValue(type, it, variables, coerceSingleValueAsList, locationDefaultValue) } + ?.let { transformValue(type, it, variables, coerceSingleValueAsList, locationDefaultValue, executionNode) } ?: locationDefaultValue } @@ -91,11 +94,11 @@ open class ArgumentTransformer(val genericTypeResolver: GenericTypeResolver) { // for the list's item type on the provided value (note this may apply recursively for nested lists). type.isList() && value !is ValueNode.ListValueNode && value !is ValueNode.NullValueNode -> { if (coerceSingleValueAsList) { - transformToCollection(type.listType(), listOf(value), variables, true) + transformToCollection(type.listType(), listOf(value), variables, true, executionNode = executionNode) } else { throw InvalidInputValueException( "Cannot coerce '${value.valueNodeName}' to List", - value + executionNode ) } } @@ -111,13 +114,13 @@ open class ArgumentTransformer(val genericTypeResolver: GenericTypeResolver) { val inputField = inputFieldsByName[fieldName] ?: throw InvalidInputValueException( "Property '$fieldName' on '${type.unwrapped().name}' does not exist", - value + executionNode ) val paramType = inputField.type as? Type - ?: throw InvalidInputValueException( + ?: throw ExecutionError( "Something went wrong while searching for the constructor parameter type '$fieldName'", - value + executionNode ) val parameterName = (inputField as? InputValue<*>)?.parameterName ?: fieldName @@ -125,8 +128,9 @@ open class ArgumentTransformer(val genericTypeResolver: GenericTypeResolver) { paramType, valueField.value, variables, - coerceSingleValueAsList = true, - locationDefaultValue + true, + locationDefaultValue, + executionNode ) } @@ -162,7 +166,7 @@ open class ArgumentTransformer(val genericTypeResolver: GenericTypeResolver) { if (missingNonOptionalInputs.isNotEmpty()) { throw InvalidInputValueException( "Missing non-optional input fields: ${missingNonOptionalInputs.joinToString()}", - value + executionNode ) } @@ -173,7 +177,7 @@ open class ArgumentTransformer(val genericTypeResolver: GenericTypeResolver) { if (type.isNotNullable()) { throw InvalidInputValueException( "Cannot coerce '${value.valueNodeName}' to ${type.unwrapped().name}", - value + executionNode ) } else { null @@ -184,14 +188,14 @@ open class ArgumentTransformer(val genericTypeResolver: GenericTypeResolver) { if (type.isNotList()) { throw InvalidInputValueException( "Cannot coerce '${value.valueNodeName}' to ${type.unwrapped().name}", - value + executionNode ) } else { - transformToCollection(type.listType(), value.values, variables, false) + transformToCollection(type.listType(), value.values, variables, false, executionNode = executionNode) } } - else -> transformString(value, type.unwrapped()) + else -> transformString(value, type.unwrapped(), executionNode) } } @@ -200,34 +204,35 @@ open class ArgumentTransformer(val genericTypeResolver: GenericTypeResolver) { values: List, variables: Variables, coerceSingleValueAsList: Boolean, - defaultValue: Any? = null + defaultValue: Any? = null, + executionNode: Execution ): Collection<*> = if (type.kClass.isSubclassOf(Set::class)) { values.mapTo(mutableSetOf()) { valueNode -> - transformValue(type.unwrapList(), valueNode, variables, coerceSingleValueAsList, defaultValue) + transformValue(type.unwrapList(), valueNode, variables, coerceSingleValueAsList, defaultValue, executionNode) } } else { values.map { valueNode -> - transformValue(type.unwrapList(), valueNode, variables, coerceSingleValueAsList, defaultValue) + transformValue(type.unwrapList(), valueNode, variables, coerceSingleValueAsList, defaultValue, executionNode) } } - private fun transformString(value: ValueNode, type: Type): Any = + private fun transformString(value: ValueNode, type: Type, executionNode: Execution): Any = (type as? Type.Enum<*>)?.let { enumType -> if (value is ValueNode.EnumValueNode) { enumType.values.firstOrNull { it.name == value.value }?.value ?: throw InvalidInputValueException( "Invalid enum ${enumType.name} value. Expected one of ${enumType.values.map { it.value }}", - value + executionNode ) } else { throw InvalidInputValueException( "Cannot coerce string literal '${value.valueNodeName}' to enum ${enumType.name}", - value + executionNode ) } } ?: (type as? Type.Scalar<*>)?.let { scalarType -> - deserializeScalar(scalarType, value) + deserializeScalar(scalarType, value, executionNode) } ?: throw InvalidInputValueException( "Cannot coerce '${value.valueNodeName}' to enum ${type.name}", - value + executionNode ) } 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 51192f04..8eb07fd7 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 @@ -1,8 +1,10 @@ package com.apurebase.kgraphql.schema.execution import com.apurebase.kgraphql.Context +import com.apurebase.kgraphql.ExecutionError import com.apurebase.kgraphql.ExecutionException -import com.apurebase.kgraphql.GraphQLError +import com.apurebase.kgraphql.RequestError +import com.apurebase.kgraphql.helpers.toJsonNode import com.apurebase.kgraphql.mapIndexedParallel import com.apurebase.kgraphql.request.Variables import com.apurebase.kgraphql.request.VariablesJson @@ -20,19 +22,17 @@ import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.NullNode import com.fasterxml.jackson.databind.node.ObjectNode import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.job import nidomiro.kdataloader.DataLoader +import kotlin.coroutines.cancellation.CancellationException import kotlin.reflect.KProperty1 @Suppress("UNCHECKED_CAST") // For valid structure there is no risk of ClassCastException class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { class ExecutionContext( - val scope: CoroutineScope, val variables: Variables, val requestContext: Context, val loaders: Map, DataLoader> @@ -62,10 +62,10 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { private val argumentsHandler = schema.configuration.argumentTransformer - private val jsonNodeFactory = schema.configuration.objectMapper.nodeFactory - private val dispatcher = schema.configuration.coroutineDispatcher + private val jsonNodeFactory = schema.configuration.objectMapper.nodeFactory + private val objectWriter = schema.configuration.objectMapper.writer().let { if (schema.configuration.useDefaultPrettyPrinter) { it.withDefaultPrettyPrinter() @@ -74,43 +74,64 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { } } - override suspend fun suspendExecute(plan: ExecutionPlan, variables: VariablesJson, context: Context): String = - coroutineScope { - val root = jsonNodeFactory.objectNode() - val data = root.putObject("data") - val loaders = plan.constructLoaders() - - suspend fun executeOperation(operation: Execution.Node): Pair?> = - coroutineScope { - val ctx = ExecutionContext(this, Variables(variables, operation.variables), context, loaders) - if (shouldInclude(ctx, operation)) { + override suspend fun suspendExecute(plan: ExecutionPlan, variables: VariablesJson, context: Context): String { + val root = jsonNodeFactory.objectNode() + val loaders = plan.constructLoaders() + + suspend fun executeOperation(operation: Execution.Node): Pair?> = + coroutineScope { + val ctx = ExecutionContext(Variables(variables, operation.variables), context, loaders) + if (shouldInclude(ctx, operation)) { + try { operation to writeOperation( isSubscription = plan.isSubscription, ctx = ctx, node = operation, operation = operation.field as Field.Function<*, *> ) - } else { - operation to null + } catch (e: ExecutionError) { + context.raiseError(e) + if (operation.field.returnType.isNullable()) { + operation to CompletableDeferred(jsonNodeFactory.nullNode()) + } else { + operation to null + } } + } else { + operation to null } - - // TODO: we might want a SerialRequestExecutor or at least rename *Parallel*RequestExecutor - val resultMap = if (plan.executionMode == ExecutionMode.Normal) { - plan.mapIndexedParallel { _, operation -> executeOperation(operation) }.toMap() - } else { - plan.associate { operation -> executeOperation(operation) } } - for (operation in plan) { - // Remove all by skip/include directives - if (resultMap[operation] != null) { - data.merge(operation.aliasOrKey, resultMap[operation]!!.await()) - } + // TODO: we might want a SerialRequestExecutor or at least rename *Parallel*RequestExecutor + val resultMap = if (plan.executionMode == ExecutionMode.Normal) { + plan.mapIndexedParallel(dispatcher) { _, operation -> executeOperation(operation) }.toMap() + } else { + plan.associate { operation -> executeOperation(operation) } + } + + val data = if (resultMap.values.any { it != null }) { + jsonNodeFactory.objectNode() + } else { + jsonNodeFactory.nullNode() + } + + for (operation in plan) { + // Remove all by skip/include directives + if (resultMap[operation] != null) { + (data as ObjectNode).merge(operation.aliasOrKey, resultMap[operation]!!.await()) } + } - objectWriter.writeValueAsString(root) + // https://spec.graphql.org/September2025/#note-19ca4 + // "When "errors" is present in an execution result, it may be helpful for it to appear first when serialized to make it more apparent that errors are present." + // So let's put errors first + if (context.errors.isNotEmpty()) { + root.set("errors", context.errors.toJsonNode(schema.configuration.objectMapper)) } + root.set("data", data) + + return objectWriter.writeValueAsString(root) + } private suspend fun writeOperation( isSubscription: Boolean, @@ -118,19 +139,27 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { node: Execution.Node, operation: FunctionWrapper ): Deferred { - node.field.checkAccess(null, ctx.requestContext) - val operationResult: Deferred = operation.invoke( - isSubscription = isSubscription, - children = node.children, - funName = node.field.name, - receiver = null, - inputValues = node.field.arguments, - args = node.arguments, - executionNode = node, - ctx = ctx - ) + try { + node.field.checkAccess(null, ctx.requestContext) + } catch (e: Throwable) { + return handleException(ctx, node, node.field.returnType, e) + } - return createNode(ctx, operationResult, node, node.field.returnType) + try { + val operationResult = operation.invoke( + isSubscription = isSubscription, + children = node.children, + funName = node.field.name, + receiver = null, + inputValues = node.field.arguments, + args = node.arguments, + executionNode = node, + ctx = ctx + ) + return createNode(ctx, operationResult, node, node.field.returnType) + } catch (e: Throwable) { + return handleException(ctx, node, node.field.returnType, e) + } } private suspend fun createUnionOperationNode( @@ -139,8 +168,11 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { node: Execution.Union, unionProperty: Field.Union ): Deferred { - node.field.checkAccess(parent, ctx.requestContext) - + try { + node.field.checkAccess(parent, ctx.requestContext) + } catch (e: Throwable) { + return handleException(ctx, node, node.field.returnType, e) + } val operationResult: Any? = unionProperty.invoke( funName = unionProperty.name, receiver = parent, @@ -170,64 +202,78 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { node: Execution.Node, returnType: Type ): Deferred { - if (value == null || value is NullNode) { - return CompletableDeferred(createNullNode(node, returnType)) - } + try { + if (value == null || value is NullNode) { + return CompletableDeferred(createNullNode(node, returnType)) + } - val unboxed = schema.configuration.genericTypeResolver.unbox(value) - if (unboxed !== value) { - return createNode(ctx, unboxed, node, returnType) - } + val unboxed = schema.configuration.genericTypeResolver.unbox(value) + if (unboxed !== value) { + return createNode(ctx, unboxed, node, returnType) + } - return when { - // Check value, not returnType, because this method can be invoked with element value - value is Collection<*> || value is Array<*> || value is ArrayNode || value::class in typeByPrimitiveArrayClass.keys -> ctx.scope.async { - val values: Collection<*> = when (value) { - is Array<*> -> value.toList() - is ArrayNode -> value.toList() - is IntArray -> value.toList() - is ShortArray -> value.toList() - is LongArray -> value.toList() - is FloatArray -> value.toList() - is DoubleArray -> value.toList() - is CharArray -> value.toList() - is BooleanArray -> value.toList() - else -> value as Collection<*> - } - if (returnType.isList()) { - val unwrappedReturnType = returnType.unwrapList() - val valuesMap = values.mapIndexedParallel(dispatcher) { i, value -> - value to createNode(ctx, value, node.withIndex(i), unwrappedReturnType) - }.toMap() - values.fold(jsonNodeFactory.arrayNode(values.size)) { array, v -> - array.add(valuesMap[v]?.await()) + return when { + // Check value, not returnType, because this method can be invoked with element value + value is Collection<*> || value is Array<*> || value is ArrayNode || value::class in typeByPrimitiveArrayClass.keys -> { + val values: Collection<*> = when (value) { + is Array<*> -> value.toList() + is ArrayNode -> value.toList() + is IntArray -> value.toList() + is ShortArray -> value.toList() + is LongArray -> value.toList() + is FloatArray -> value.toList() + is DoubleArray -> value.toList() + is CharArray -> value.toList() + is BooleanArray -> value.toList() + else -> value as Collection<*> + } + if (returnType.isList()) { + val unwrappedReturnType = returnType.unwrapList() + val valueNodes = values.mapIndexedParallel(dispatcher) { i, value -> + createNode(ctx, value, node.withIndex(i), unwrappedReturnType) + } + CompletableDeferred(valueNodes.fold(jsonNodeFactory.arrayNode(values.size)) { array, v -> + array.add(v.await()) + }) + } else { + handleException( + ctx, + node, + returnType, + ExecutionException( + "Invalid collection value for non-collection property '${node.aliasOrKey}'", + node + ) + ) } - } else { - throw ExecutionException( - "Invalid collection value for non-collection property '${node.aliasOrKey}'", - node - ) } - } - value is String -> CompletableDeferred(jsonNodeFactory.textNode(value)) - value is Int -> CompletableDeferred(jsonNodeFactory.numberNode(value)) - value is Float -> CompletableDeferred(jsonNodeFactory.numberNode(value)) - value is Double -> CompletableDeferred(jsonNodeFactory.numberNode(value)) - value is Boolean -> CompletableDeferred(jsonNodeFactory.booleanNode(value)) - value is Long -> CompletableDeferred(jsonNodeFactory.numberNode(value)) - value is Short -> CompletableDeferred(jsonNodeFactory.numberNode(value)) + value is String -> CompletableDeferred(jsonNodeFactory.textNode(value)) + value is Int -> CompletableDeferred(jsonNodeFactory.numberNode(value)) + value is Float -> CompletableDeferred(jsonNodeFactory.numberNode(value)) + value is Double -> CompletableDeferred(jsonNodeFactory.numberNode(value)) + value is Boolean -> CompletableDeferred(jsonNodeFactory.booleanNode(value)) + value is Long -> CompletableDeferred(jsonNodeFactory.numberNode(value)) + value is Short -> CompletableDeferred(jsonNodeFactory.numberNode(value)) - value is Deferred<*> -> createNode(ctx, value.await(), node, returnType) + value is Deferred<*> -> createNode(ctx, value.await(), node, returnType) - node.children.isNotEmpty() -> createObjectNode(ctx, value, node, returnType) + node.children.isNotEmpty() -> createObjectNode(ctx, value, node, returnType) - node is Execution.Union -> createObjectNode(ctx, value, node.memberExecution(returnType), returnType) + node is Execution.Union -> createObjectNode( + ctx, + value, + node.memberExecution(returnType), + returnType + ) - // TODO: do we have to consider more? more validation e.g.? - value is JsonNode -> CompletableDeferred(value) + // TODO: do we have to consider more? more validation e.g.? + value is JsonNode -> CompletableDeferred(value) - else -> CompletableDeferred(createSimpleValueNode(returnType, value, node)) + else -> CompletableDeferred(createSimpleValueNode(returnType, value, node)) + } + } catch (e: ExecutionError) { + return handleException(ctx, node, returnType, e) } } @@ -248,35 +294,30 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { if (returnType.kind != TypeKind.NON_NULL) { return jsonNodeFactory.nullNode() } else { - throw ExecutionException("Null result for non-nullable operation '${node.aliasOrKey}'", node) + throw ExecutionError("Null result for non-nullable operation '${node.aliasOrKey}'", node) } } - private fun createObjectNode( + private suspend fun createObjectNode( ctx: ExecutionContext, value: T, node: Execution.Node, type: Type - ): Deferred = ctx.scope.async { + ): Deferred { val objectNode = jsonNodeFactory.objectNode() - val deferreds = mutableListOf?>>>() - for (child in node.children) { + val deferreds = node.children.mapIndexedParallel { _, child -> when (child) { - is Execution.Fragment -> deferreds.add(ctx.scope.async { - handleFragment(ctx, value, child.withParent(node)) - }) - - else -> deferreds.add(ctx.scope.async { - handleProperty(ctx, value, child.withParent(node), type)?.let { mapOf(it) } ?: emptyMap() - }) + is Execution.Fragment -> handleFragment(ctx, value, child.withParent(node)) + else -> handleProperty(ctx, value, child.withParent(node), type)?.let { mapOf(it) } ?: emptyMap() } } + deferreds.forEach { - it.await().forEach { (key, value) -> - objectNode.merge(key, value?.await()) + it.forEach { (key, value) -> + objectNode.merge(key, value.await()) } } - objectNode + return CompletableDeferred(objectNode) } private suspend fun handleProperty( @@ -317,7 +358,7 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { return child.aliasOrKey to (createPropertyNode(ctx, value, child, field) ?: return null) } - else -> throw UnsupportedOperationException("Handling containers is not implemented yet") + is Execution.Fragment -> throw UnsupportedOperationException("Handling fragments is not implemented yet") } } @@ -326,10 +367,9 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { value: T, container: Execution.Fragment ): Map> { - val expectedType = container.condition.onType val include = shouldInclude(ctx, container) - if (include) { + val expectedType = container.condition.onType if (expectedType.kind == TypeKind.OBJECT || expectedType.kind == TypeKind.INTERFACE) { // TODO: for remote objects we now rely on the presence of the __typename. So maybe we should/need to automatically add it if not present already? Can this break something? if (expectedType.isInstance(value) || (value is JsonNode && value["__typename"].textValue() == expectedType.name)) { @@ -365,9 +405,12 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { field: Field ): Deferred? { val include = shouldInclude(ctx, node) - node.field.checkAccess(parentValue, ctx.requestContext) - if (include) { + try { + node.field.checkAccess(parentValue, ctx.requestContext) + } catch (e: Throwable) { + return handleException(ctx, node, node.field.returnType, e) + } when { parentValue is JsonNode -> { // This covers a) Field.Delegated but also b) *local* types that are returned from @@ -425,21 +468,48 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { return createNode(ctx, value, node, field.returnType) } + private fun handleException( + ctx: ExecutionContext, + node: Execution.Node, + returnType: Type, + exception: Throwable + ): CompletableDeferred { + if (!schema.configuration.wrapErrors || exception is RequestError || 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 + } + } + private suspend fun handleFunctionProperty( ctx: ExecutionContext, parentValue: T, node: Execution.Node, field: Field.Function<*, *> ): Deferred { - val result = field.invoke( - funName = field.name, - receiver = parentValue, - inputValues = field.arguments, - args = node.arguments, - executionNode = node, - ctx = ctx - ) - return createNode(ctx, result, node, field.returnType) + try { + val result = field.invoke( + funName = field.name, + receiver = parentValue, + inputValues = field.arguments, + args = node.arguments, + executionNode = node, + ctx = ctx + ) + return createNode(ctx, result, node, field.returnType) + } catch (e: Throwable) { + return handleException(ctx, node, field.returnType, e) + } } private suspend fun shouldInclude(ctx: ExecutionContext, executionNode: Execution): Boolean { @@ -459,7 +529,7 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { }?.reduce { acc, b -> acc && b } ?: true } - internal fun FunctionWrapper.invoke( + internal suspend fun FunctionWrapper.invoke( isSubscription: Boolean = false, children: Collection = emptyList(), funName: String, @@ -477,30 +547,17 @@ class ParallelRequestExecutor(val schema: DefaultSchema) : RequestExecutor { ctx.variables, executionNode, ctx.requestContext, - this + this@invoke ) ?: return CompletableDeferred(value = null) - // exceptions are not caught on purpose to pass up business logic errors - return ctx.scope.async { - try { - when { - hasReceiver -> invoke(receiver, *transformedArgs.toTypedArray()) - isSubscription -> { - val subscriptionArgs = children.map { (it as Execution.Node).aliasOrKey } - invoke(transformedArgs, subscriptionArgs, objectWriter) - } - - else -> invoke(*transformedArgs.toTypedArray()) - } - } catch (e: GraphQLError) { - throw e - } catch (e: Throwable) { - if (schema.configuration.wrapErrors) { - throw ExecutionException(e.message ?: "", node = executionNode, cause = e) - } else { - throw e - } + return when { + hasReceiver -> CompletableDeferred(invoke(receiver, *transformedArgs.toTypedArray())) + isSubscription -> { + val subscriptionArgs = children.map { (it as Execution.Node).aliasOrKey } + CompletableDeferred(invoke(transformedArgs, subscriptionArgs, objectWriter)) } + + else -> CompletableDeferred(invoke(*transformedArgs.toTypedArray())) } } } diff --git a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/scalar/Coercion.kt b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/scalar/Coercion.kt index 093f7d4c..465f99b6 100644 --- a/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/scalar/Coercion.kt +++ b/kgraphql/src/main/kotlin/com/apurebase/kgraphql/schema/scalar/Coercion.kt @@ -1,5 +1,6 @@ package com.apurebase.kgraphql.schema.scalar +import com.apurebase.kgraphql.ExecutionError import com.apurebase.kgraphql.ExecutionException import com.apurebase.kgraphql.GraphQLError import com.apurebase.kgraphql.InvalidInputValueException @@ -13,7 +14,6 @@ import com.apurebase.kgraphql.schema.builtin.SHORT_COERCION import com.apurebase.kgraphql.schema.builtin.STRING_COERCION import com.apurebase.kgraphql.schema.execution.Execution import com.apurebase.kgraphql.schema.model.ast.ValueNode -import com.apurebase.kgraphql.schema.model.ast.ValueNode.StringValueNode import com.apurebase.kgraphql.schema.structure.Type import com.fasterxml.jackson.databind.node.JsonNodeFactory @@ -21,11 +21,11 @@ private typealias JsonValueNode = com.fasterxml.jackson.databind.node.ValueNode @Suppress("UNCHECKED_CAST") // TODO: Re-structure scalars, as it's a bit too complicated now. -fun deserializeScalar(scalar: Type.Scalar, value: ValueNode): T { +fun deserializeScalar(scalar: Type.Scalar, value: ValueNode, executionNode: Execution): T { try { return when (scalar.coercion) { // built-in scalars - STRING_COERCION -> STRING_COERCION.deserialize(value.valueNodeName, value as StringValueNode) as T + STRING_COERCION -> STRING_COERCION.deserialize(value.valueNodeName, value) as T FLOAT_COERCION -> FLOAT_COERCION.deserialize(value.valueNodeName, value) as T DOUBLE_COERCION -> DOUBLE_COERCION.deserialize(value.valueNodeName, value) as T SHORT_COERCION -> SHORT_COERCION.deserialize(value.valueNodeName, value) as T @@ -39,17 +39,15 @@ fun deserializeScalar(scalar: Type.Scalar, value: ValueNode): T { is DoubleScalarCoercion -> scalar.coercion.deserialize(value.valueNodeName.toDouble(), value) is BooleanScalarCoercion -> scalar.coercion.deserialize(value.valueNodeName.toBoolean(), value) is LongScalarCoercion -> scalar.coercion.deserialize(value.valueNodeName.toLong(), value) - else -> throw InvalidInputValueException( - "Unsupported coercion for scalar type ${scalar.name}", - value + else -> throw ExecutionError( + message = "Unsupported coercion for scalar type ${scalar.name}", + node = executionNode ) } - } catch (e: GraphQLError) { - throw e } catch (e: Exception) { throw InvalidInputValueException( - message = "Cannot coerce '${value.valueNodeName}' to ${scalar.name}", - node = value, + message = (e as? GraphQLError)?.message ?: "Cannot coerce '${value.valueNodeName}' to ${scalar.name}", + node = executionNode, originalError = e ) } @@ -86,5 +84,8 @@ fun serializeScalar( jsonNodeFactory.booleanNode((scalar.coercion as BooleanScalarCoercion).serialize(value)) } - else -> throw ExecutionException("Unsupported coercion for scalar ${scalar.name}", executionNode) + else -> throw ExecutionException( + message = "Unsupported coercion for scalar ${scalar.name}", + node = executionNode + ) } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/access/AccessRulesTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/access/AccessRulesTest.kt index 049dd081..af6ca860 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/access/AccessRulesTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/access/AccessRulesTest.kt @@ -4,7 +4,6 @@ import com.apurebase.kgraphql.Context import com.apurebase.kgraphql.context import com.apurebase.kgraphql.defaultSchema import com.apurebase.kgraphql.deserialize -import com.apurebase.kgraphql.expect import com.apurebase.kgraphql.extract import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test @@ -40,7 +39,7 @@ class AccessRulesTest { property(Player::id) { accessRule(accessRuleBlock) } - property("item") { + property("item") { accessRule(accessRuleBlock) resolver { "item" } } @@ -58,11 +57,9 @@ class AccessRulesTest { @Test fun `reject when not matching`() { - expect("ILLEGAL ACCESS") { - deserialize( - schema.executeBlocking("{ black_mamba {id} }", context = context { +"LAKERS" }) - ).extract("data/black_mamba/id") - } + schema.executeBlocking("{ black_mamba {id} }", context = context { +"LAKERS" }) shouldBe """ + {"errors":[{"message":"ILLEGAL ACCESS","locations":[{"line":1,"column":16}],"path":["black_mamba","id"],"extensions":{"type":"INTERNAL_SERVER_ERROR"}}],"data":null} + """.trimIndent() } @Test @@ -72,9 +69,9 @@ class AccessRulesTest { @Test fun `reject property resolver access rule`() { - expect("ILLEGAL ACCESS") { - schema.executeBlocking("{black_mamba {item}}", context = context { +"LAKERS" }).also(::println) - } + schema.executeBlocking("{black_mamba {item}}", context = context { +"LAKERS" }) shouldBe """ + {"errors":[{"message":"ILLEGAL ACCESS","locations":[{"line":1,"column":15}],"path":["black_mamba","item"],"extensions":{"type":"INTERNAL_SERVER_ERROR"}}],"data":{"black_mamba":{"item":null}}} + """.trimIndent() } //TODO: MORE TESTS diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/configuration/SchemaConfigurationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/configuration/SchemaConfigurationTest.kt index b8b45cdf..f04f8da3 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/configuration/SchemaConfigurationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/configuration/SchemaConfigurationTest.kt @@ -3,7 +3,7 @@ package com.apurebase.kgraphql.configuration import com.apurebase.kgraphql.KGraphQL.Companion.schema import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.defaultSchema -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectRequestError import io.kotest.matchers.shouldBe import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource @@ -72,7 +72,7 @@ class SchemaConfigurationTest { {"data":{"__schema":{"queryType":{"name":"Query"}}}} """.trimIndent() } else { - expect("GraphQL introspection is not allowed") { + expectRequestError("GraphQL introspection is not allowed") { schema.executeBlocking("{ __schema { queryType { name } } }") } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/MutationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/MutationTest.kt index 06084942..77d93240 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/MutationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/MutationTest.kt @@ -6,7 +6,8 @@ import com.apurebase.kgraphql.KGraphQL import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.assertNoErrors import com.apurebase.kgraphql.deserialize -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectExecutionError +import com.apurebase.kgraphql.expectRequestError import com.apurebase.kgraphql.extract import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test @@ -41,28 +42,28 @@ class MutationTest : BaseSchemaTest() { @Test fun `invalid mutation name`() { - expect("Property 'createBanana' on 'Mutation' does not exist") { + expectRequestError("Property 'createBanana' on 'Mutation' does not exist") { testedSchema.executeBlocking("mutation {createBanana(name: \"${testActor.name}\", age: ${testActor.age}){age}}") } } @Test fun `invalid argument type`() { - expect("Cannot coerce '\"fwfwf\"' to Int") { + expectExecutionError("Cannot coerce '\"fwfwf\"' to Int") { testedSchema.executeBlocking("mutation {createActor(name: \"${testActor.name}\", age: \"fwfwf\"){age}}") } } @Test fun `invalid arguments number`() { - expect("'createActor' does support arguments: [name, age], found: [name, age, bananan]") { + expectRequestError("'createActor' does support arguments: [name, age], found: [name, age, bananan]") { testedSchema.executeBlocking("mutation {createActor(name: \"${testActor.name}\", age: ${testActor.age}, bananan: \"fwfwf\"){age}}") } } @Test fun `invalid arguments number with NotIntrospected class`() { - expect("'createActorWithContext' does support arguments: [name, age], found: [name, age, bananan]") { + expectRequestError("'createActorWithContext' does support arguments: [name, age], found: [name, age, bananan]") { testedSchema.executeBlocking("mutation {createActorWithContext(name: \"${testActor.name}\", age: ${testActor.age}, bananan: \"fwfwf\"){age}}") } } @@ -94,6 +95,7 @@ class MutationTest : BaseSchemaTest() { @Test fun `multiple mutations should use serial execution`() { data class Node(val id: Int, val currentCount: Int) + var nodeCount = 0 val schema = KGraphQL.schema { query("nodeCount") { diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/ParallelExecutionTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/ParallelExecutionTest.kt index 07ac317b..d5f2e12f 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/ParallelExecutionTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/ParallelExecutionTest.kt @@ -1,8 +1,13 @@ package com.apurebase.kgraphql.integration +import com.apurebase.kgraphql.Context +import com.apurebase.kgraphql.ExecutionError import com.apurebase.kgraphql.KGraphQL import com.apurebase.kgraphql.deserialize import com.apurebase.kgraphql.extract +import com.apurebase.kgraphql.schema.execution.Execution +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.longs.shouldBeLessThan import io.kotest.matchers.shouldBe import kotlinx.coroutines.delay @@ -52,6 +57,18 @@ class ParallelExecutionTest { } } + private val errorsSchema = KGraphQL.schema { + repeat(1000) { + query("automated_$it") { + resolver { node: Execution.Node, ctx: Context -> + delay(3) + ctx.raiseError(ExecutionError("Error $it", node)) + "$it" + } + } + } + } + private val query = "{\n" + (0..999).joinToString("") { "automated_${it}\n" } + " }" @Test @@ -97,4 +114,14 @@ class ParallelExecutionTest { // takes about 300ms so if it takes 3s, we apparently ran sequentially. duration shouldBeLessThan 3000 } + + @Test + fun `parallel execution errors should not get lost`() { + val map = deserialize(errorsSchema.executeBlocking(query)) + val errorPaths = (0..999).flatMap { + map.extract>>("errors[$it]/path") + } + errorPaths shouldHaveSize 1000 + errorPaths shouldContainExactlyInAnyOrder (0..999).map { "automated_$it" } + } } 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 ddaa1c1c..a05fcbab 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/QueryTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/integration/QueryTest.kt @@ -1,24 +1,27 @@ package com.apurebase.kgraphql.integration import com.apurebase.kgraphql.Actor +import com.apurebase.kgraphql.Context import com.apurebase.kgraphql.Director +import com.apurebase.kgraphql.ExecutionError import com.apurebase.kgraphql.ExecutionException import com.apurebase.kgraphql.Film import com.apurebase.kgraphql.Id import com.apurebase.kgraphql.KGraphQL +import com.apurebase.kgraphql.RequestError import com.apurebase.kgraphql.Scenario import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.assertNoErrors import com.apurebase.kgraphql.defaultSchema import com.apurebase.kgraphql.deserialize -import com.apurebase.kgraphql.expect +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.Execution -import io.kotest.assertions.throwables.shouldThrowExactly +import com.apurebase.kgraphql.schema.scalar.ID import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContainOnlyOnce -import io.kotest.matchers.throwable.shouldHaveMessage import org.junit.jupiter.api.Test class QueryTest : BaseSchemaTest() { @@ -65,13 +68,9 @@ class QueryTest : BaseSchemaTest() { @Test fun `query with invalid field name`() { - val exception = shouldThrowExactly { + expectRequestError("Property 'favDish' on 'Director' does not exist") { testedSchema.executeBlocking("{film{title, director{name, favDish}}}") } - exception shouldHaveMessage "Property 'favDish' on 'Director' does not exist" - exception.extensions shouldBe mapOf( - "type" to "GRAPHQL_VALIDATION_FAILED" - ) } @Test @@ -112,13 +111,9 @@ class QueryTest : BaseSchemaTest() { @Test fun `query with ignored property`() { - val exception = shouldThrowExactly { + expectRequestError("Property 'author' on 'Scenario' does not exist") { testedSchema.executeBlocking("{scenario{author, content}}") } - exception shouldHaveMessage "Property 'author' on 'Scenario' does not exist" - exception.extensions shouldBe mapOf( - "type" to "GRAPHQL_VALIDATION_FAILED" - ) } @Test @@ -213,13 +208,9 @@ class QueryTest : BaseSchemaTest() { @Test fun `query with invalid field arguments`() { - val exception = shouldThrowExactly { + expectRequestError("Property 'id' on type 'Scenario' has no arguments, found: [uppercase]") { testedSchema.executeBlocking("{scenario{id(uppercase: true), content}}") } - exception shouldHaveMessage "Property 'id' on type 'Scenario' has no arguments, found: [uppercase]" - exception.extensions shouldBe mapOf( - "type" to "GRAPHQL_VALIDATION_FAILED" - ) } @Test @@ -450,7 +441,7 @@ class QueryTest : BaseSchemaTest() { @Test fun `query with missing fragment type`() { - val exception = shouldThrowExactly { + expectRequestError("Unknown type 'MissingType' in type condition on fragment") { testedSchema.executeBlocking( """ { @@ -463,15 +454,11 @@ class QueryTest : BaseSchemaTest() { """.trimIndent() ) } - exception shouldHaveMessage "Unknown type 'MissingType' in type condition on fragment" - exception.extensions shouldBe mapOf( - "type" to "GRAPHQL_VALIDATION_FAILED" - ) } @Test fun `query with missing named fragment type`() { - val exception = shouldThrowExactly { + expectRequestError("Fragment 'film_title' not found") { testedSchema.executeBlocking( """ { @@ -482,21 +469,13 @@ class QueryTest : BaseSchemaTest() { """.trimIndent() ) } - exception shouldHaveMessage "Fragment 'film_title' not found" - exception.extensions shouldBe mapOf( - "type" to "GRAPHQL_VALIDATION_FAILED" - ) } @Test fun `query with missing selection set`() { - val exception = shouldThrowExactly { + expectRequestError("Missing selection set on property 'film' of type 'Film'") { testedSchema.executeBlocking("{film}") } - exception shouldHaveMessage "Missing selection set on property 'film' of type 'Film'" - exception.extensions shouldBe mapOf( - "type" to "GRAPHQL_VALIDATION_FAILED" - ) } data class SampleNode(val id: Int, val name: String, val fields: List? = null) @@ -903,6 +882,255 @@ class QueryTest : BaseSchemaTest() { } } + // https://spec.graphql.org/September2025/#example-072c4 + @Test + fun partialResponseNullableField() { + data class Hero(val id: ID, val friends: List = emptyList()) + + val schema = KGraphQL.schema { + configure { + useDefaultPrettyPrinter = true + } + query("hero") { + resolver { -> Hero(ID("1"), listOf(Hero(ID("1000")), Hero(ID("1002")), Hero(ID("1003")))) } + } + type { + property("name") { + resolver { hero -> + when (hero.id.value) { + "1" -> "R2-D2" + "1000" -> "Luke Skywalker" + "1003" -> "Leia Organa" + else -> throw Exception("Name for character with ID ${hero.id} could not be fetched.") + } + } + } + } + } + + schema.printSchema() shouldBe """ + type Hero { + friends: [Hero]! + id: ID! + name: String + } + + type Query { + hero: Hero! + } + + """.trimIndent() + + schema.executeBlocking(""" + { + hero { + name + heroFriends: friends { + id + name + } + } + } + """.trimIndent()) shouldBe """ + { + "errors" : [ { + "message" : "Name for character with ID ID(value=1002) could not be fetched.", + "locations" : [ { + "line" : 6, + "column" : 7 + } ], + "path" : [ "hero", "heroFriends", 1, "name" ], + "extensions" : { + "type" : "INTERNAL_SERVER_ERROR" + } + } ], + "data" : { + "hero" : { + "name" : "R2-D2", + "heroFriends" : [ { + "id" : "1000", + "name" : "Luke Skywalker" + }, { + "id" : "1002", + "name" : null + }, { + "id" : "1003", + "name" : "Leia Organa" + } ] + } + } + } + """.trimIndent() + } + + // https://spec.graphql.org/September2025/#example-c18ef + @Test + fun partialResponseNonNullableField() { + data class Hero(val id: ID, val friends: List = emptyList()) + + val schema = KGraphQL.schema { + configure { + useDefaultPrettyPrinter = true + } + query("hero") { + resolver { -> Hero(ID("1"), listOf(Hero(ID("1000")), Hero(ID("1002")), Hero(ID("1003")))) } + } + type { + property("name") { + resolver { hero -> + when (hero.id.value) { + "1" -> "R2-D2" + "1000" -> "Luke Skywalker" + "1003" -> "Leia Organa" + else -> throw Exception("Name for character with ID ${hero.id} could not be fetched.") + } + } + } + } + } + + schema.printSchema() shouldBe """ + type Hero { + friends: [Hero]! + id: ID! + name: String! + } + + type Query { + hero: Hero! + } + + """.trimIndent() + + schema.executeBlocking(""" + { + hero { + name + heroFriends: friends { + id + name + } + } + } + """.trimIndent()) shouldBe """ + { + "errors" : [ { + "message" : "Name for character with ID ID(value=1002) could not be fetched.", + "locations" : [ { + "line" : 6, + "column" : 7 + } ], + "path" : [ "hero", "heroFriends", 1, "name" ], + "extensions" : { + "type" : "INTERNAL_SERVER_ERROR" + } + } ], + "data" : { + "hero" : { + "name" : "R2-D2", + "heroFriends" : [ { + "id" : "1000", + "name" : "Luke Skywalker" + }, null, { + "id" : "1003", + "name" : "Leia Organa" + } ] + } + } + } + """.trimIndent() + } + + @Test + fun partialResponseNullableList() { + data class Hero(val id: ID, val friends: List = emptyList()) + + val schema = KGraphQL.schema { + configure { + useDefaultPrettyPrinter = true + } + query("heroes") { + resolver?> { throw IllegalStateException("There are no heroes anymore") } + } + } + + schema.printSchema() shouldBe """ + type Hero { + friends: [Hero]! + id: ID! + } + + type Query { + heroes: [Hero!] + } + + """.trimIndent() + + schema.executeBlocking(""" + { + heroes { + id + } + } + """.trimIndent()) shouldBe """ + { + "errors" : [ { + "message" : "There are no heroes anymore", + "locations" : [ { + "line" : 2, + "column" : 3 + } ], + "path" : [ "heroes" ], + "extensions" : { + "type" : "INTERNAL_SERVER_ERROR" + } + } ], + "data" : { + "heroes" : null + } + } + """.trimIndent() + } + + @Test + fun `errors during object field execution should not stop other fields`() { + data class HighlanderMovie(val highlander1: String?) + data class Person(val firstName: String, val lastName: String) + + val schema = KGraphQL.schema { + query("movie") { + resolver { -> HighlanderMovie("Connor MacLeod") } + } + type { + property("highlander2") { + resolver { throw Exception("There can only be one!") } + } + property("director") { + resolver { Person("Russel", "Mulcahy") } + } + } + } + schema.executeBlocking(""" + { movie { highlander1 highlander2 director { firstName lastName } } } + """.trimIndent()) shouldBe """ + {"errors":[{"message":"There can only be one!","locations":[{"line":1,"column":23}],"path":["movie","highlander2"],"extensions":{"type":"INTERNAL_SERVER_ERROR"}}],"data":{"movie":{"highlander1":"Connor MacLeod","highlander2":null,"director":{"firstName":"Russel","lastName":"Mulcahy"}}}} + """.trimIndent() + + schema.executeBlocking(""" + { + movie { ...MovieFragment } + } + + fragment MovieFragment on HighlanderMovie { + highlander1 + highlander2 + director { firstName lastName } + } + """.trimIndent()) shouldBe """ + {"errors":[{"message":"There can only be one!","locations":[{"line":7,"column":3}],"path":["movie","highlander2"],"extensions":{"type":"INTERNAL_SERVER_ERROR"}}],"data":{"movie":{"highlander1":"Connor MacLeod","highlander2":null,"director":{"firstName":"Russel","lastName":"Mulcahy"}}}} + """.trimIndent() + } + @Test fun `invalid collection values should result in an error`() { val schema = KGraphQL.schema { @@ -919,8 +1147,45 @@ class QueryTest : BaseSchemaTest() { """.trimIndent() - expect("Invalid collection value for non-collection property 'invalidList'") { + expectExecutionError("Invalid collection value for non-collection property 'invalidList'") { schema.executeBlocking("{ invalidList }") } } + + @Test + fun `resolvers should be able to throw custom request errors`() { + data class Item(val data: String) + class ForbiddenError(node: Execution.Node, message: String) : + RequestError(message, node = node.selectionNode, extensions = mapOf("type" to "FORBIDDEN")) + + val schema = KGraphQL.schema { + query("items") { + resolver { node: Execution.Node -> + throw ForbiddenError(node, "Not allowed") + } + } + } + schema.executeBlocking("{ items { data }}") shouldBe """ + {"errors":[{"message":"Not allowed","locations":[{"line":1,"column":3}],"extensions":{"type":"FORBIDDEN"}}]} + """.trimIndent() + } + + @Test + fun `resolvers should be able to return data and raise execution errors`() { + data class Item(val data: String) + class MissingItemError(message: String, node: Execution.Node) : + ExecutionError(message, node, extensions = mapOf("type" to "NOT_FOUND", "reason" to "Item is missing")) + + val schema = KGraphQL.schema { + query("items") { + resolver { node: Execution.Node, ctx: Context -> + ctx.raiseError(MissingItemError("Cannot get item 'missing'", node)) + listOf(Item("Existing 1"), Item("Existing 2")) + } + } + } + schema.executeBlocking("{ items { data }}") shouldBe """ + {"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() + } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/schema/SchemaBuilderTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/schema/SchemaBuilderTest.kt index 7d289e13..d1cce0e8 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/schema/SchemaBuilderTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/schema/SchemaBuilderTest.kt @@ -3,6 +3,7 @@ package com.apurebase.kgraphql.schema import com.apurebase.kgraphql.Account import com.apurebase.kgraphql.Actor import com.apurebase.kgraphql.Context +import com.apurebase.kgraphql.ExecutionException import com.apurebase.kgraphql.FilmType import com.apurebase.kgraphql.Id import com.apurebase.kgraphql.KGraphQL.Companion.schema @@ -11,6 +12,7 @@ import com.apurebase.kgraphql.context import com.apurebase.kgraphql.defaultSchema import com.apurebase.kgraphql.deserialize import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectExecutionError import com.apurebase.kgraphql.extract import com.apurebase.kgraphql.schema.dsl.SchemaBuilder import com.apurebase.kgraphql.schema.dsl.types.TypeDSL @@ -417,10 +419,10 @@ class SchemaBuilderTest { deserialize(schema.executeBlocking("{definedValue}")).let { it.extract("data/definedValue") shouldBe "good!" } - expect("Requested value is not defined!") { + expectExecutionError("Requested value is not defined!") { schema.executeBlocking("{undefinedValue}") } - expect("Requested value is not defined!") { + expectExecutionError("Requested value is not defined!") { schema.executeBlocking("{undefinedValueProp {value}}") } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/schema/SchemaInheritanceTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/schema/SchemaInheritanceTest.kt index d299ea34..804df68d 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/schema/SchemaInheritanceTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/schema/SchemaInheritanceTest.kt @@ -2,7 +2,7 @@ package com.apurebase.kgraphql.schema import com.apurebase.kgraphql.KGraphQL import com.apurebase.kgraphql.ValidationException -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectRequestError import org.junit.jupiter.api.Test import java.util.UUID @@ -30,11 +30,11 @@ class SchemaInheritanceTest { query("c") { resolver { -> C(name, age) } } } - expect("Property 'id' on 'B' does not exist") { + expectRequestError("Property 'id' on 'B' does not exist") { schema.executeBlocking("{b{id, name, age}}") } - expect("Property 'id' on 'C' does not exist") { + expectRequestError("Property 'id' on 'C' does not exist") { schema.executeBlocking("{c{id, name, age}}") } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/introspection/IntrospectionSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/introspection/IntrospectionSpecificationTest.kt index 6611fa4e..3533f8ce 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/introspection/IntrospectionSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/introspection/IntrospectionSpecificationTest.kt @@ -4,7 +4,7 @@ import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.assertNoErrors import com.apurebase.kgraphql.defaultSchema import com.apurebase.kgraphql.deserialize -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectRequestError import com.apurebase.kgraphql.extract import com.apurebase.kgraphql.request.Introspection import com.apurebase.kgraphql.schema.introspection.TypeKind @@ -81,7 +81,7 @@ class IntrospectionSpecificationTest { } } - expect("Property '__typename' on 'String' does not exist") { + expectRequestError("Property '__typename' on 'String' does not exist") { schema.executeBlocking("{sample{string{__typename}}}") } } @@ -100,7 +100,7 @@ class IntrospectionSpecificationTest { } } - expect("Property '__typename' on 'SampleEnum' does not exist") { + expectRequestError("Property '__typename' on 'SampleEnum' does not exist") { schema.executeBlocking("{sample{enum{__typename}}}") } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/FragmentsSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/FragmentsSpecificationTest.kt index 26375cd0..3c8156e7 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/FragmentsSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/FragmentsSpecificationTest.kt @@ -8,7 +8,7 @@ import com.apurebase.kgraphql.assertNoErrors import com.apurebase.kgraphql.defaultSchema import com.apurebase.kgraphql.deserialize import com.apurebase.kgraphql.executeEqualQueries -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectRequestError import com.apurebase.kgraphql.extract import com.apurebase.kgraphql.integration.BaseSchemaTest import com.apurebase.kgraphql.request.Introspection @@ -140,7 +140,7 @@ class FragmentsSpecificationTest : BaseSchemaTest() { @Test fun `queries with recursive fragments are denied`() { - expect("Fragment spread circular references are not allowed") { + expectRequestError("Fragment spread circular references are not allowed") { testedSchema.executeBlocking( """ query IntrospectionQuery { @@ -166,7 +166,7 @@ class FragmentsSpecificationTest : BaseSchemaTest() { @Test fun `queries with duplicated fragments are denied`() { - expect("There can be only one fragment named 'film_title'") { + expectRequestError("There can be only one fragment named 'film_title'") { testedSchema.executeBlocking( """ { @@ -192,7 +192,7 @@ class FragmentsSpecificationTest : BaseSchemaTest() { @Test fun `queries with unused fragments should be denied`() { - expect("Found unused fragments: [unused_film_title]") { + expectRequestError("Found unused fragments: [unused_film_title]") { testedSchema.executeBlocking( """ { @@ -364,7 +364,7 @@ class FragmentsSpecificationTest : BaseSchemaTest() { // https://github.com/aPureBase/KGraphQL/issues/189 @Test fun `queries with missing fragments should return proper error message`() { - expect("Fragment 'film_title_misspelled' not found") { + expectRequestError("Fragment 'film_title_misspelled' not found") { testedSchema.executeBlocking( """ { diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/InputValuesSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/InputValuesSpecificationTest.kt index f540c4e4..a0d0794e 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/InputValuesSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/InputValuesSpecificationTest.kt @@ -3,12 +3,13 @@ package com.apurebase.kgraphql.specification.language import com.apurebase.kgraphql.InvalidInputValueException import com.apurebase.kgraphql.KGraphQL import com.apurebase.kgraphql.Specification +import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.defaultSchema import com.apurebase.kgraphql.deserialize +import com.apurebase.kgraphql.expectExecutionError +import com.apurebase.kgraphql.expectRequestError import com.apurebase.kgraphql.extract -import io.kotest.assertions.throwables.shouldThrowExactly import io.kotest.matchers.shouldBe -import io.kotest.matchers.throwable.shouldHaveMessage import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource @@ -48,16 +49,12 @@ class InputValuesSpecificationTest { } @ParameterizedTest - @ValueSource(strings = ["null", "42.0", "\"foo\"", "bar"]) + @ValueSource(strings = ["null", "42.0", "\"foo\"", "bar", "[1, 2]"]) @Specification("2.9.1 Int Value") fun `Invalid Int input value`(value: String) { - val exception = shouldThrowExactly { + expectExecutionError("Cannot coerce '$value' to Int") { schema.executeBlocking("{ Int(value: $value) }") } - exception shouldHaveMessage "Cannot coerce '$value' to Int" - exception.extensions shouldBe mapOf( - "type" to "BAD_USER_INPUT" - ) } @Test @@ -111,13 +108,9 @@ class InputValuesSpecificationTest { @ValueSource(strings = ["null", "42", "\"foo\"", "[\"foo\", \"bar\"]"]) @Specification("2.9.3 Boolean Value") fun `Invalid Boolean input value`(value: String) { - val exception = shouldThrowExactly { + expectExecutionError("Cannot coerce '$value' to Boolean") { schema.executeBlocking("{ Boolean(value: $value) }") } - exception shouldHaveMessage "Cannot coerce '$value' to Boolean" - exception.extensions shouldBe mapOf( - "type" to "BAD_USER_INPUT" - ) } @Test @@ -142,13 +135,9 @@ class InputValuesSpecificationTest { @ValueSource(strings = ["null", "true", "42", "[\"foo\", \"bar\"]"]) @Specification("2.9.4 String Value") fun `Invalid String input value`(value: String) { - val exception = shouldThrowExactly { + expectExecutionError("Cannot coerce '$value' to String") { schema.executeBlocking("{ String(value: $value) }") } - exception shouldHaveMessage "Cannot coerce '$value' to String" - exception.extensions shouldBe mapOf( - "type" to "BAD_USER_INPUT" - ) } @Test @@ -169,13 +158,9 @@ class InputValuesSpecificationTest { @ValueSource(strings = ["ENUM3"]) @Specification("2.9.6 Enum Value") fun `Invalid Enum input value`(value: String) { - val exception = shouldThrowExactly { + expectExecutionError("Invalid enum ${FakeEnum::class.simpleName} value. Expected one of [ENUM1, ENUM2]") { schema.executeBlocking("{ Enum(value: $value) }") } - exception shouldHaveMessage "Invalid enum ${FakeEnum::class.simpleName} value. Expected one of [ENUM1, ENUM2]" - exception.extensions shouldBe mapOf( - "type" to "BAD_USER_INPUT" - ) } @Test @@ -189,13 +174,9 @@ class InputValuesSpecificationTest { @ValueSource(strings = ["null", "true", "\"foo\""]) @Specification("2.9.7 List Value") fun `Invalid List input value`(value: String) { - val exception = shouldThrowExactly { + expectExecutionError("Cannot coerce '$value' to Int") { schema.executeBlocking("{ List(value: $value) }") } - exception shouldHaveMessage "Cannot coerce '$value' to Int" - exception.extensions shouldBe mapOf( - "type" to "BAD_USER_INPUT" - ) } @Test @@ -211,25 +192,17 @@ class InputValuesSpecificationTest { @ValueSource(strings = ["null", "true", "42"]) @Specification("2.9.8 Object Value") fun `Invalid Literal object input value`(value: String) { - val exception = shouldThrowExactly { + expectExecutionError("Cannot coerce '$value' to String") { schema.executeBlocking("{ Object(value: { number: 232, description: \"little number\", list: $value }) }") } - exception shouldHaveMessage "Cannot coerce '$value' to String" - exception.extensions shouldBe mapOf( - "type" to "BAD_USER_INPUT" - ) } @Test @Specification("2.9.8 Object Value") fun `Invalid Literal object input value - null`() { - val exception = shouldThrowExactly { + expectExecutionError("Cannot coerce 'null' to FakeData") { schema.executeBlocking("{ Object(value: null) }") } - exception shouldHaveMessage "Cannot coerce 'null' to FakeData" - exception.extensions shouldBe mapOf( - "type" to "BAD_USER_INPUT" - ) } @Test @@ -302,14 +275,10 @@ class InputValuesSpecificationTest { @Test @Specification("2.9.8 Object Value") - fun `Unknown object input value type`() { - val exception = shouldThrowExactly { + fun `unknown object input value type`() { + expectRequestError("Invalid variable '\$object' argument type 'FakeDate', expected 'FakeData!'") { schema.executeBlocking("query(\$object: FakeDate) { Object(value: \$object) }") } - exception shouldHaveMessage "Invalid variable '\$object' argument type 'FakeDate', expected 'FakeData!'" - exception.extensions shouldBe mapOf( - "type" to "BAD_USER_INPUT" - ) } data class Dessert( @@ -386,7 +355,7 @@ class InputValuesSpecificationTest { {"data":{"append":"A1"}} """.trimIndent() - shouldThrowExactly { + expectExecutionError("Cannot coerce '\"cd\"' to Char") { schema.executeBlocking( """ mutation { @@ -394,9 +363,9 @@ class InputValuesSpecificationTest { } """.trimIndent() ) - } shouldHaveMessage "Cannot coerce '\"cd\"' to Char" + } - shouldThrowExactly { + expectExecutionError("Cannot coerce '\"\"' to Char") { schema.executeBlocking( """ mutation { @@ -404,9 +373,9 @@ class InputValuesSpecificationTest { } """.trimIndent() ) - } shouldHaveMessage "Cannot coerce '\"\"' to Char" + } - shouldThrowExactly { + expectExecutionError("Cannot coerce 'true' to Char") { schema.executeBlocking( """ mutation { @@ -414,9 +383,9 @@ class InputValuesSpecificationTest { } """.trimIndent() ) - } shouldHaveMessage "Cannot coerce 'true' to Char" + } - shouldThrowExactly { + expectExecutionError("Cannot coerce '-1' to Char") { schema.executeBlocking( """ mutation { @@ -424,9 +393,9 @@ class InputValuesSpecificationTest { } """.trimIndent() ) - } shouldHaveMessage "Cannot coerce '-1' to Char" + } - shouldThrowExactly { + expectExecutionError("Cannot coerce '65536' to Char") { schema.executeBlocking( """ mutation { @@ -434,6 +403,6 @@ class InputValuesSpecificationTest { } """.trimIndent() ) - } shouldHaveMessage "Cannot coerce '65536' to Char" + } } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/ListInputCoercionTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/ListInputCoercionTest.kt index c3588ebb..01a29193 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/ListInputCoercionTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/ListInputCoercionTest.kt @@ -4,7 +4,7 @@ import com.apurebase.kgraphql.InvalidInputValueException import com.apurebase.kgraphql.Specification import com.apurebase.kgraphql.defaultSchema import com.apurebase.kgraphql.deserialize -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectExecutionError import com.apurebase.kgraphql.extract import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test @@ -16,8 +16,10 @@ class ListInputCoercionTest { private val schema = defaultSchema { query("NullableList") { resolver { value: List? -> value } } + query("NullableListRequiredElement") { resolver { value: List? -> value } } query("NullableNestedList") { resolver { value: List?>? -> value } } query("RequiredList") { resolver { value: List -> value } } + query("RequiredListRequiredElement") { resolver { value: List -> value } } query("NullableSet") { resolver { value: Set? -> value } } query("NullableNestedSet") { resolver { value: Set?>? -> value } } query("NullableNestedSetListSet") { resolver { value: Set?>?>? -> value } } @@ -45,14 +47,14 @@ class ListInputCoercionTest { @Test fun `a list of mixed types should not be valid for a nullable list of Int`() { - expect("Cannot coerce '\"b\"' to Int") { + expectExecutionError("Cannot coerce '\"b\"' to Int") { schema.executeBlocking("{ NullableList(value: [1, \"b\", true]) }") } } @Test fun `foo should not be valid for a nullable list of Int`() { - expect("Cannot coerce '\"foo\"' to Int") { + expectExecutionError("Cannot coerce '\"foo\"' to Int") { schema.executeBlocking("{ RequiredList(value: \"foo\") }") } } @@ -77,14 +79,14 @@ class ListInputCoercionTest { @Test fun `a non-nested list should not be valid for a nullable nested list of Int`() { - expect("Cannot coerce '1' to List") { + expectExecutionError("Cannot coerce '1' to List") { schema.executeBlocking("{ NullableNestedList(value: [1, 2, 3]) }") } } @Test fun `null should not be valid for a required list of Int`() { - expect("Cannot coerce 'null' to Int") { + expectExecutionError("Cannot coerce 'null' to Int") { schema.executeBlocking("{ RequiredList(value: null) }") } } @@ -121,14 +123,14 @@ class ListInputCoercionTest { @Test fun `a list of mixed types should not be valid for a nullable set of Int`() { - expect("Cannot coerce '\"b\"' to Int") { + expectExecutionError("Cannot coerce '\"b\"' to Int") { schema.executeBlocking("{ NullableSet(value: [1, \"b\", true]) }") } } @Test fun `foo should not be valid for a nullable set of Int`() { - expect("Cannot coerce '\"foo\"' to Int") { + expectExecutionError("Cannot coerce '\"foo\"' to Int") { schema.executeBlocking("{ RequiredSet(value: \"foo\") }") } } @@ -164,14 +166,14 @@ class ListInputCoercionTest { @Test fun `a non-nested list should not be valid for a nullable nested set of Int`() { - expect("Cannot coerce '1' to List") { + expectExecutionError("Cannot coerce '1' to List") { schema.executeBlocking("{ NullableNestedSet(value: [1, 2, 3]) }") } } @Test fun `null should not be valid for a required set of Int`() { - expect("Cannot coerce 'null' to Int") { + expectExecutionError("Cannot coerce 'null' to Int") { schema.executeBlocking("{ RequiredSet(value: null) }") } } @@ -188,6 +190,24 @@ class ListInputCoercionTest { response.extract>("data/RequiredSet") shouldBe listOf(1, 2, null) } + // "null" is invalid for a list [Int!] but since the list itself is nullable, it should be coerced to null and + // report the error + @Test + fun `a list with null value should be coerced to null for an optional list of Int!`() { + schema.executeBlocking("{ NullableListRequiredElement(value: [1, 2, null]) }") shouldBe """ + {"errors":[{"message":"Cannot coerce 'null' to Int","locations":[{"line":1,"column":3}],"path":["NullableListRequiredElement"],"extensions":{"type":"BAD_USER_INPUT"}}],"data":{"NullableListRequiredElement":null}} + """.trimIndent() + } + + // "null" is invalid for a list [Int!]! but since the list itself is not nullable, the query should fail with an + // error + @Test + fun `a list with null value should throw an error for a required list of Int!`() { + schema.executeBlocking("{ RequiredListRequiredElement(value: [1, 2, null]) }") shouldBe """ + {"errors":[{"message":"Cannot coerce 'null' to Int","locations":[{"line":1,"column":3}],"path":["RequiredListRequiredElement"],"extensions":{"type":"BAD_USER_INPUT"}}],"data":null} + """.trimIndent() + } + @Test fun `list argument inside object inside list should accept list value`() { val response = deserialize(schema.executeBlocking("{ RequiredListInObjectInList(value: [{innerValue: [1]}]) { innerValue } }")) diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/OperationsSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/OperationsSpecificationTest.kt index ecea4261..c7775540 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/OperationsSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/OperationsSpecificationTest.kt @@ -4,13 +4,10 @@ import com.apurebase.kgraphql.ExecutionException import com.apurebase.kgraphql.Specification import com.apurebase.kgraphql.defaultSchema import com.apurebase.kgraphql.executeEqualQueries -import com.apurebase.kgraphql.schema.SchemaException +import com.apurebase.kgraphql.expectExecutionError import com.apurebase.kgraphql.schema.dsl.operations.subscribe import com.apurebase.kgraphql.schema.dsl.operations.unsubscribe -import com.apurebase.kgraphql.shouldBeInstanceOf -import io.kotest.assertions.throwables.shouldThrowExactly import io.kotest.matchers.shouldBe -import io.kotest.matchers.throwable.shouldHaveMessage import org.junit.jupiter.api.Test data class Actor(var name: String? = "", var age: Int? = 0) @@ -101,11 +98,10 @@ class OperationsSpecificationTest { } @Test - fun `Subscription return type must be the same as the publisher's`() { - val exception = shouldThrowExactly { + fun `subscription return type must be the same as the publisher's`() { + expectExecutionError("Subscription return type must be the same as the publisher's") { + // TODO: should fail during schema compilation already - https://github.com/stuebingerb/KGraphQL/issues/492 newSchema().executeBlocking("subscription {subscriptionActress(subscription : \"mySubscription\"){age}}") } - exception.originalError shouldBeInstanceOf SchemaException::class - exception shouldHaveMessage "Subscription return type must be the same as the publisher's" } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/QueryDocumentSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/QueryDocumentSpecificationTest.kt index 98558281..baa05968 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/QueryDocumentSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/QueryDocumentSpecificationTest.kt @@ -6,7 +6,7 @@ import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.assertNoErrors import com.apurebase.kgraphql.defaultSchema import com.apurebase.kgraphql.deserialize -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectRequestError import com.apurebase.kgraphql.extract import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test @@ -26,14 +26,14 @@ class QueryDocumentSpecificationTest { @Test fun `anonymous operation must be the only defined operation`() { - expect("Anonymous operation must be the only defined operation") { + expectRequestError("Anonymous operation must be the only defined operation") { schema.executeBlocking("query {fizz} mutation BUZZ {createActor(name : \"Kurt Russel\"){name}}") } } @Test fun `must provide operation name when multiple named operations`() { - expect("Must provide an operation name from: [FIZZ, BUZZ], found: null") { + expectRequestError("Must provide an operation name from: [FIZZ, BUZZ], found: null") { schema.executeBlocking("query FIZZ {fizz} mutation BUZZ {createActor(name : \"Kurt Russel\"){name}}") } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/SourceTextSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/SourceTextSpecificationTest.kt index 36a3e1a3..fc39091d 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/SourceTextSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/SourceTextSpecificationTest.kt @@ -8,7 +8,7 @@ import com.apurebase.kgraphql.assertNoErrors import com.apurebase.kgraphql.defaultSchema import com.apurebase.kgraphql.deserialize import com.apurebase.kgraphql.executeEqualQueries -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectRequestError import com.apurebase.kgraphql.extract import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test @@ -28,7 +28,7 @@ class SourceTextSpecificationTest { @Test fun `invalid unicode character`() { - expect("Syntax Error: Cannot contain the invalid character \"\\u0003\".") { + expectRequestError("Syntax Error: Cannot contain the invalid character \"\\u0003\".") { schema.executeBlocking("\u0003") } } @@ -97,11 +97,11 @@ class SourceTextSpecificationTest { @Test @Specification("2.1.9 Names") fun `names should be case sensitive`() { - expect("Property 'FIZZ' on 'Query' does not exist") { + expectRequestError("Property 'FIZZ' on 'Query' does not exist") { schema.executeBlocking("{FIZZ}") } - expect("Property 'Fizz' on 'Query' does not exist") { + expectRequestError("Property 'Fizz' on 'Query' does not exist") { schema.executeBlocking("{Fizz}") } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/VariablesSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/VariablesSpecificationTest.kt index 4197e37e..ab778643 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/VariablesSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/language/VariablesSpecificationTest.kt @@ -6,7 +6,8 @@ import com.apurebase.kgraphql.Specification import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.assertNoErrors import com.apurebase.kgraphql.deserialize -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectExecutionError +import com.apurebase.kgraphql.expectRequestError import com.apurebase.kgraphql.extract import com.apurebase.kgraphql.integration.BaseSchemaTest import io.kotest.matchers.shouldBe @@ -45,7 +46,7 @@ class VariablesSpecificationTest : BaseSchemaTest() { @Test fun `query with int variable should not allow floating point numbers that are not whole`() { - expect("Cannot coerce '1.01' to Int") { + expectExecutionError("Cannot coerce '1.01' to Int") { testedSchema.executeBlocking( request = "query(\$rank: Int!) {filmByRank(rank: \$rank) {title}}", variables = "{\"rank\": 1.01}" @@ -65,7 +66,7 @@ class VariablesSpecificationTest : BaseSchemaTest() { @Test fun `query with custom int scalar variable should not allow floating point numbers that are not whole`() { - expect("Cannot coerce '1.01' to Rank") { + expectExecutionError("Cannot coerce '1.01' to Rank") { testedSchema.executeBlocking( request = "query(\$rank: Rank!) {filmByCustomRank(rank: \$rank) {title}}", variables = "{\"rank\": 1.01}" @@ -87,7 +88,7 @@ class VariablesSpecificationTest : BaseSchemaTest() { @Test fun `query with long variable should not allow floating point numbers that are not whole`() { - expect("Cannot coerce '1.01' to Long") { + expectExecutionError("Cannot coerce '1.01' to Long") { testedSchema.executeBlocking( request = "query(\$rank: Long!) {filmByRankLong(rank: \$rank) {title}}", variables = "{\"rank\": 1.01}" @@ -109,7 +110,7 @@ class VariablesSpecificationTest : BaseSchemaTest() { @Test fun `query with short variable should not allow floating point numbers that are not whole`() { - expect("Cannot coerce '1.01' to Short") { + expectExecutionError("Cannot coerce '1.01' to Short") { testedSchema.executeBlocking( request = "query(\$rank: Short!) {filmByRankShort(rank: \$rank) {title}}", variables = "{\"rank\": 1.01}" @@ -163,7 +164,7 @@ class VariablesSpecificationTest : BaseSchemaTest() { @Test fun `query with boolean variable and variable default value but explicit null provided`() { - expect("Cannot coerce 'null' to Boolean") { + expectExecutionError("Cannot coerce 'null' to Boolean") { testedSchema.executeBlocking( request = "query(\$big: Boolean = true) {number(big: \$big)}", variables = "{\"big\": null}" @@ -173,7 +174,7 @@ class VariablesSpecificationTest : BaseSchemaTest() { @Test fun `query with boolean variable and location default value but explicit null provided`() { - expect("Cannot coerce 'null' to Boolean") { + expectExecutionError("Cannot coerce 'null' to Boolean") { testedSchema.executeBlocking( request = "query(\$big: Boolean) {bigNumber(big: \$big)}", variables = "{\"big\": null}" @@ -197,7 +198,7 @@ class VariablesSpecificationTest : BaseSchemaTest() { @Test fun `query with list variable and variable default value but explicit null list provided`() { - expect("Cannot coerce 'null' to String") { + expectExecutionError("Cannot coerce 'null' to String") { testedSchema.executeBlocking( request = "query(\$tags: [String] = []) {actorsByTags(tags: \$tags) { name }}", variables = "{\"tags\": null}" @@ -207,7 +208,7 @@ class VariablesSpecificationTest : BaseSchemaTest() { @Test fun `query with list variable and variable default value but explicit null element provided`() { - expect("Cannot coerce 'null' to String") { + expectExecutionError("Cannot coerce 'null' to String") { testedSchema.executeBlocking( request = "query(\$tags: [String] = []) {actorsByTags(tags: \$tags) { name }}", variables = "{\"tags\": [null]}" @@ -217,14 +218,14 @@ class VariablesSpecificationTest : BaseSchemaTest() { @Test fun `query with list variable and location default value but explicit null list provided`() { - expect("Cannot coerce 'null' to String") { + expectExecutionError("Cannot coerce 'null' to String") { testedSchema.executeBlocking(request = "query(\$tags: [String] = null) {actorsByTags(tags: \$tags) { name }}") } } @Test fun `query with list variable and location default value but explicit null element provided`() { - expect("Cannot coerce 'null' to String") { + expectExecutionError("Cannot coerce 'null' to String") { testedSchema.executeBlocking(request = "query(\$tags: [String] = [null]) {actorsByTags(tags: \$tags) { name }}") } } @@ -265,7 +266,7 @@ class VariablesSpecificationTest : BaseSchemaTest() { @Test fun `fragment with missing variable`() { - expect("Variable '\$big' was not declared for this operation") { + expectRequestError("Variable '\$big' was not declared for this operation") { testedSchema.executeBlocking( request = "mutation(\$name: String = \"BoguĊ› Linda\", \$age : Int!) {createActor(name: \$name, age: \$age){...Linda}}" + "fragment Linda on Actor {picture(big: \$big)}", @@ -431,7 +432,7 @@ class VariablesSpecificationTest : BaseSchemaTest() { """.trimIndent() - expect("Property 'readonlyExtension' on 'SampleObjectInput' does not exist") { + expectExecutionError("Property 'readonlyExtension' on 'SampleObjectInput' does not exist") { schema.executeBlocking( """ { @@ -442,7 +443,7 @@ class VariablesSpecificationTest : BaseSchemaTest() { } // It should not matter if SampleObjectInput is provided directly or via variables, the error message should be equal - expect("Property 'readonlyExtension' on 'SampleObjectInput' does not exist") { + expectRequestError("Property 'readonlyExtension' on 'SampleObjectInput' does not exist") { schema.executeBlocking( """ query (${'$'}sample: SampleObjectInput!) { diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/DirectivesSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/DirectivesSpecificationTest.kt index 70313ddc..0f47cc68 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/DirectivesSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/DirectivesSpecificationTest.kt @@ -2,7 +2,7 @@ package com.apurebase.kgraphql.specification.typesystem import com.apurebase.kgraphql.Specification import com.apurebase.kgraphql.ValidationException -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectRequestError import com.apurebase.kgraphql.extract import com.apurebase.kgraphql.integration.BaseSchemaTest import io.kotest.matchers.shouldBe @@ -82,7 +82,7 @@ class DirectivesSpecificationTest : BaseSchemaTest() { @Test fun `missing directive should result in an error`() { - expect("Directive 'nonExisting' does not exist") { + expectRequestError("Directive 'nonExisting' does not exist") { testedSchema.executeBlocking("{film{title year @nonExisting}}") } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/EnumsSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/EnumsSpecificationTest.kt index 12a499a8..0525aa8a 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/EnumsSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/EnumsSpecificationTest.kt @@ -5,6 +5,7 @@ import com.apurebase.kgraphql.KGraphQL import com.apurebase.kgraphql.Specification import com.apurebase.kgraphql.deserialize import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectExecutionError import com.apurebase.kgraphql.extract import com.apurebase.kgraphql.schema.SchemaException import io.kotest.matchers.shouldBe @@ -32,7 +33,7 @@ class EnumsSpecificationTest { @Test fun `string literals must not be accepted as an enum input`() { - expect("Cannot coerce string literal '\"COOL\"' to enum Coolness") { + expectExecutionError("Cannot coerce string literal '\"COOL\"' to enum Coolness") { schema.executeBlocking("{cool(cool : \"COOL\")}") } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/InputObjectsSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/InputObjectsSpecificationTest.kt index eb134a81..f568d1f5 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/InputObjectsSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/InputObjectsSpecificationTest.kt @@ -4,12 +4,11 @@ import com.apurebase.kgraphql.InvalidInputValueException import com.apurebase.kgraphql.KGraphQL import com.apurebase.kgraphql.deserialize import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectExecutionError import com.apurebase.kgraphql.extract import com.apurebase.kgraphql.schema.SchemaException import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.kotest.assertions.throwables.shouldThrowExactly import io.kotest.matchers.shouldBe -import io.kotest.matchers.throwable.shouldHaveMessage import org.junit.jupiter.api.Test @Suppress("unused") @@ -76,7 +75,7 @@ class InputObjectsSpecificationTest { } } - val exception = shouldThrowExactly { + expectExecutionError("Property 'valu1' on 'MyInput' does not exist") { schema.executeBlocking( """ { @@ -85,10 +84,6 @@ class InputObjectsSpecificationTest { """ ) } - exception shouldHaveMessage "Property 'valu1' on 'MyInput' does not exist" - exception.extensions shouldBe mapOf( - "type" to "BAD_USER_INPUT" - ) } // Non-data class with a constructor parameter that is not a property diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/InterfacesSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/InterfacesSpecificationTest.kt index d2686d6f..4f0d82ae 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/InterfacesSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/InterfacesSpecificationTest.kt @@ -4,7 +4,7 @@ import com.apurebase.kgraphql.KGraphQL import com.apurebase.kgraphql.Specification import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.deserialize -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectRequestError import com.apurebase.kgraphql.extract import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test @@ -30,7 +30,7 @@ class InterfacesSpecificationTest { @Test fun `When querying for fields on an interface type, only those fields declared on the interface may be queried`() { - expect("Property 'stuff' on 'SimpleInterface' does not exist") { + expectRequestError("Property 'stuff' on 'SimpleInterface' does not exist") { schema.executeBlocking("{simple{exe, stuff}}") } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/ListsSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/ListsSpecificationTest.kt index 771f3a8a..ac28b1f3 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/ListsSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/ListsSpecificationTest.kt @@ -4,7 +4,7 @@ import com.apurebase.kgraphql.InvalidInputValueException import com.apurebase.kgraphql.KGraphQL import com.apurebase.kgraphql.Specification import com.apurebase.kgraphql.deserialize -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectExecutionError import com.apurebase.kgraphql.extract import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe @@ -62,7 +62,7 @@ class ListsSpecificationTest { { "list": ["GAGA", null, "DADA", "PADA"] } """.trimIndent() - expect("Cannot coerce 'null' to String") { + expectExecutionError("Cannot coerce 'null' to String") { schema.executeBlocking("query(\$list: [String!]!) { list(list: \$list) }", variables) } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/NonNullSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/NonNullSpecificationTest.kt index a931b741..43170433 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/NonNullSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/NonNullSpecificationTest.kt @@ -6,12 +6,12 @@ import com.apurebase.kgraphql.KGraphQL import com.apurebase.kgraphql.Specification import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.deserialize -import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectExecutionError +import com.apurebase.kgraphql.expectRequestError import com.apurebase.kgraphql.extract -import com.apurebase.kgraphql.shouldBeInstanceOf -import io.kotest.assertions.throwables.shouldThrowExactly import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test +import kotlin.reflect.typeOf @Specification("3.1.8 Non-null") class NonNullSpecificationTest { @@ -20,13 +20,27 @@ class NonNullSpecificationTest { fun `if the result of non-null type is null, error should be raised`() { val schema = KGraphQL.schema { query("nonNull") { - resolver { string: String? -> string!! } + resolver { null } + setReturnType(typeOf()) + } + query("nonNullList") { + resolver?> { null } + setReturnType(typeOf>()) + } + query("nonNullListElement") { + resolver?> { listOf(null) } + setReturnType(typeOf?>()) } } - val exception = shouldThrowExactly { + expectExecutionError("Null result for non-nullable operation 'nonNull'") { schema.executeBlocking("{nonNull}") } - exception.originalError shouldBeInstanceOf java.lang.NullPointerException::class + expectExecutionError("Null result for non-nullable operation 'nonNullList'") { + schema.executeBlocking("{nonNullList}") + } + expectExecutionError("Null result for non-nullable operation 'nonNullListElement'") { + schema.executeBlocking("{nonNullListElement}") + } } @Test @@ -51,7 +65,7 @@ class NonNullSpecificationTest { resolver { input: String -> input } } } - expect("Missing value for non-nullable argument 'input' on the field 'nonNull'") { + expectRequestError("Missing value for non-nullable argument 'input' on the field 'nonNull'") { schema.executeBlocking("{nonNull}") } } @@ -64,7 +78,7 @@ class NonNullSpecificationTest { } } - expect("Invalid variable '${'$'}arg' argument type 'String', expected 'String!'\n") { + expectRequestError("Invalid variable '${'$'}arg' argument type 'String', expected 'String!'") { schema.executeBlocking("query(\$arg: String){nonNull(input: \$arg)}", "{\"arg\":\"SAD\"}") } } @@ -137,7 +151,7 @@ class NonNullSpecificationTest { } } - expect("Missing non-optional input fields: valueOne, value3") { + expectExecutionError("Missing non-optional input fields: valueOne, value3") { schema.executeBlocking( """ { diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/ScalarsSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/ScalarsSpecificationTest.kt index a8594707..99aa6493 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/ScalarsSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/ScalarsSpecificationTest.kt @@ -5,6 +5,7 @@ import com.apurebase.kgraphql.KGraphQL import com.apurebase.kgraphql.Specification import com.apurebase.kgraphql.deserialize import com.apurebase.kgraphql.expect +import com.apurebase.kgraphql.expectExecutionError import com.apurebase.kgraphql.extract import com.apurebase.kgraphql.schema.SchemaException import com.apurebase.kgraphql.schema.model.ast.ValueNode @@ -144,7 +145,7 @@ class ScalarsSpecificationTest { } } - expect("Cannot coerce '${Integer.MAX_VALUE.toLong() + 2L}' to Int as it is greater than (2^-31)-1") { + expectExecutionError("Cannot coerce '${Integer.MAX_VALUE.toLong() + 2L}' to Int as it is greater than (2^-31)-1") { schema.executeBlocking("mutation { Int(int: ${Integer.MAX_VALUE.toLong() + 2L}) }") } } @@ -243,27 +244,27 @@ class ScalarsSpecificationTest { """.trimIndent() // Double (should fail) - expect("Cannot coerce '4.0' to ID") { + expectExecutionError("Cannot coerce '4.0' to ID") { testedSchema.executeBlocking("query(\$id: ID! = 4.0) { personById(id: \$id) { id, name } }") } // Boolean (should fail) - expect("Cannot coerce 'true' to ID") { + expectExecutionError("Cannot coerce 'true' to ID") { testedSchema.executeBlocking("query(\$id: ID! = true) { personById(id: \$id) { id, name } }") } // List of strings (should fail) - expect("Cannot coerce '[\"4\", \"5\"]' to ID") { + expectExecutionError("Cannot coerce '[\"4\", \"5\"]' to ID") { testedSchema.executeBlocking("query(\$id: ID! = [\"4\", \"5\"]) { personById(id: \$id) { id, name } }") } // Object (should fail) - expect("Property 'value' on 'ID' does not exist") { + expectExecutionError("Property 'value' on 'ID' does not exist") { testedSchema.executeBlocking("query(\$id: ID! = {value: \"4\"}) { personById(id: \$id) { id, name } }") } // Null (should fail) - expect("Cannot coerce 'null' to ID") { + expectExecutionError("Cannot coerce 'null' to ID") { testedSchema.executeBlocking("query(\$id: ID! = null) { personById(id: \$id) { id, name } }") } @@ -284,7 +285,7 @@ class ScalarsSpecificationTest { } } - expect("Cannot coerce '\"223\"' to Int") { + expectExecutionError("Cannot coerce '\"223\"' to Int") { schema.executeBlocking("mutation { Int(int: \"223\") }") } } diff --git a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/UnionsSpecificationTest.kt b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/UnionsSpecificationTest.kt index 4d043bb9..83feac4a 100644 --- a/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/UnionsSpecificationTest.kt +++ b/kgraphql/src/test/kotlin/com/apurebase/kgraphql/specification/typesystem/UnionsSpecificationTest.kt @@ -7,6 +7,8 @@ import com.apurebase.kgraphql.ValidationException import com.apurebase.kgraphql.defaultSchema import com.apurebase.kgraphql.deserialize import com.apurebase.kgraphql.expect +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.integration.BaseSchemaTest @@ -63,7 +65,7 @@ class UnionsSpecificationTest : BaseSchemaTest() { @Test fun `query union property with invalid selection set`() { - expect("Invalid selection set with properties: [name] on union type property favourite : [Actor, Scenario, Director]") { + expectRequestError("Invalid selection set with properties: [name] on union type property favourite : [Actor, Scenario, Director]") { testedSchema.executeBlocking("{actors{name, favourite{ name }}}") } } @@ -116,7 +118,7 @@ class UnionsSpecificationTest : BaseSchemaTest() { @Test fun `a union type should require a selection for all potential types`() { - expect("Missing selection set for type 'Scenario'") { + expectRequestError("Missing selection set for type 'Scenario'") { testedSchema.executeBlocking( """{ actors { @@ -154,7 +156,7 @@ class UnionsSpecificationTest : BaseSchemaTest() { @Test fun `non-nullable union types should fail`() { - expect("Unexpected type of union property value, expected one of [Actor, Scenario, Director] but was 'null'") { + expectExecutionError("Unexpected type of union property value, expected one of [Actor, Scenario, Director] but was 'null'") { testedSchema.executeBlocking( """{ actors(all: true) { diff --git a/kgraphql/src/testFixtures/kotlin/com/apurebase/kgraphql/CommonTestUtils.kt b/kgraphql/src/testFixtures/kotlin/com/apurebase/kgraphql/CommonTestUtils.kt index 0bf3b372..274318dd 100644 --- a/kgraphql/src/testFixtures/kotlin/com/apurebase/kgraphql/CommonTestUtils.kt +++ b/kgraphql/src/testFixtures/kotlin/com/apurebase/kgraphql/CommonTestUtils.kt @@ -1,5 +1,8 @@ package com.apurebase.kgraphql +import io.kotest.assertions.json.shouldContainJsonKey +import io.kotest.assertions.json.shouldContainJsonKeyValue +import io.kotest.assertions.json.shouldNotContainJsonKey import io.kotest.assertions.throwables.shouldThrowExactly import io.kotest.matchers.shouldBe import io.kotest.matchers.throwable.shouldHaveMessage @@ -11,3 +14,28 @@ infix fun Any?.shouldBeInstanceOf(clazz: KClass<*>) = this shouldBe instanceOf(c inline fun expect(message: String, block: () -> Unit) { shouldThrowExactly(block) shouldHaveMessage message } + +inline fun expectExecutionError(vararg messages: String, block: () -> String) { + val response = block.invoke() + response.shouldContainJsonKey("$.data") + response.shouldContainJsonKeyValue("$.errors.length()", messages.size) + messages.forEachIndexed { index, message -> + response.shouldContainJsonKeyValue("$.errors[$index].message", message) + response.shouldContainJsonKeyValue("$.errors[$index].extensions.type", errorType()) + } +} + +inline fun expectRequestError(message: String, block: () -> String) { + val response = block.invoke() + response.shouldNotContainJsonKey("$.data") + response.shouldContainJsonKeyValue("$.errors.length()", 1) + response.shouldContainJsonKeyValue("$.errors[0].message", message) + response.shouldContainJsonKeyValue("$.errors[0].extensions.type", errorType()) +} + +inline fun errorType() = when (T::class) { + InvalidInputValueException::class -> BuiltInErrorCodes.BAD_USER_INPUT.name + ValidationException::class -> BuiltInErrorCodes.GRAPHQL_VALIDATION_FAILED.name + InvalidSyntaxException::class -> BuiltInErrorCodes.GRAPHQL_PARSE_FAILED.name + else -> BuiltInErrorCodes.INTERNAL_SERVER_ERROR.name +}