From e65437abb69f65d3813cfd31b0dd504c60cde241 Mon Sep 17 00:00:00 2001 From: Samuel Vazquez Date: Thu, 14 Mar 2024 09:49:00 -0700 Subject: [PATCH] feat: kotlinx serialization for GraphQLServerRequest (#1937) Switch from `jackson` to `kotlinx.serialization` for serialization/deserialization of `GraphQLServerRequest` types. After running benchmarks we where able to identify that deserializing `GraphQLServerRequest` with `kotlinx.serialization` is quite faster than doing it with `jackson`, the reason ? possibly because jackson relies on reflections to identify deserialization process. On the other hand, serialization/deserialization of `GraphQLServerReponse` type is still faster if done with `jackson`, possibly because of how `kotlinx.serialization` library was designed and the poor support for serializing `Any` type: https://github.com/Kotlin/kotlinx.serialization/issues/296, which causes a lot of memory comsumption. As part of this PR also including the benchmarks. For that, i created a separate set of types that are marked with both `jackson` and `kotlinx.serialization` annotations. Benchmarks results: Executed on a MacBookPro 2.6 GHz 6-Core Intel Core i7. `GraphQLBatchRequest` 4 batched operations, each operation is aprox: 30kb image `GraphQLRequest` image `GraphQLBatchResponse` image `GraphQLResponse` image --- .../graphql-kotlin-server/build.gradle.kts | 9 +- ...QLServerRequestDeserializationBenchmark.kt | 74 ++ ...phQLServerRequestSerializationBenchmark.kt | 70 + ...LServerResponseDeserializationBenchmark.kt | 65 + ...hQLServerResponseSerializationBenchmark.kt | 72 + ...estBenchmark.kt => IsMutationBenchmark.kt} | 7 +- .../kotlin/testtypes/GraphQLServerRequest.kt | 118 ++ .../kotlin/testtypes/GraphQLServerResponse.kt | 155 +++ .../resources/StarWarsDetails.graphql | 1156 +++++++++++++++++ .../resources/StarWarsDetailsResponse.json | 411 ++++++ .../resources/StarWarsDetailsVariables.json | 94 ++ .../server/types/GraphQLServerRequest.kt | 54 +- .../serializers/AnyNullableKSerializer.kt | 72 + .../server/types/GraphQLServerRequestTest.kt | 20 +- 14 files changed, 2354 insertions(+), 23 deletions(-) create mode 100644 servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerRequestDeserializationBenchmark.kt create mode 100644 servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerRequestSerializationBenchmark.kt create mode 100644 servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerResponseDeserializationBenchmark.kt create mode 100644 servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerResponseSerializationBenchmark.kt rename servers/graphql-kotlin-server/src/benchmarks/kotlin/{GraphQLRequestBenchmark.kt => IsMutationBenchmark.kt} (95%) create mode 100644 servers/graphql-kotlin-server/src/benchmarks/kotlin/testtypes/GraphQLServerRequest.kt create mode 100644 servers/graphql-kotlin-server/src/benchmarks/kotlin/testtypes/GraphQLServerResponse.kt create mode 100644 servers/graphql-kotlin-server/src/benchmarks/resources/StarWarsDetails.graphql create mode 100644 servers/graphql-kotlin-server/src/benchmarks/resources/StarWarsDetailsResponse.json create mode 100644 servers/graphql-kotlin-server/src/benchmarks/resources/StarWarsDetailsVariables.json create mode 100644 servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/types/serializers/AnyNullableKSerializer.kt diff --git a/servers/graphql-kotlin-server/build.gradle.kts b/servers/graphql-kotlin-server/build.gradle.kts index a17cff0f4c..35cc22fe59 100644 --- a/servers/graphql-kotlin-server/build.gradle.kts +++ b/servers/graphql-kotlin-server/build.gradle.kts @@ -7,14 +7,17 @@ val kotlinxBenchmarkVersion: String by project plugins { id("org.jetbrains.kotlinx.benchmark") + kotlin("plugin.serialization") } val jacksonVersion: String by project +val kotlinxSerializationVersion: String by project dependencies { api(project(path = ":graphql-kotlin-schema-generator")) api(project(path = ":graphql-kotlin-dataloader-instrumentation")) api(project(path = ":graphql-kotlin-automatic-persisted-queries")) api("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + api("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinCoroutinesVersion") } @@ -43,14 +46,10 @@ tasks { jacocoTestCoverageVerification { violationRules { rule { + excludes = listOf("com.expediagroup.graphql.server.testtypes.*") limit { counter = "INSTRUCTION" value = "COVEREDRATIO" - minimum = "0.95".toBigDecimal() - } - limit { - counter = "BRANCH" - value = "COVEREDRATIO" minimum = "0.84".toBigDecimal() } } diff --git a/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerRequestDeserializationBenchmark.kt b/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerRequestDeserializationBenchmark.kt new file mode 100644 index 0000000000..29ef6cb568 --- /dev/null +++ b/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerRequestDeserializationBenchmark.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.server + +import com.expediagroup.graphql.server.testtypes.GraphQLServerRequest +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import kotlinx.serialization.json.Json +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Measurement +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Warmup +import java.util.concurrent.TimeUnit + +@State(Scope.Benchmark) +@Fork(5) +@Warmup(iterations = 1, time = 5, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS) +open class GraphQLServerRequestDeserializationBenchmark { + private val mapper = jacksonObjectMapper() + private lateinit var request: String + private lateinit var batchRequest: String + + @Setup + fun setUp() { + val loader = this::class.java.classLoader + val operation = loader.getResource("StarWarsDetails.graphql")!!.readText().replace("\n", "\\n") + val variables = loader.getResource("StarWarsDetailsVariables.json")!!.readText() + request = """ + { + "operationName": "StarWarsDetails", + "query": "$operation", + "variables": $variables + } + """.trimIndent() + batchRequest = """ + [ + { "operationName": "StarWarsDetails", "query": "$operation", "variables": $variables }, + { "operationName": "StarWarsDetails", "query": "$operation", "variables": $variables }, + { "operationName": "StarWarsDetails", "query": "$operation", "variables": $variables }, + { "operationName": "StarWarsDetails", "query": "$operation", "variables": $variables } + ] + """.trimIndent() + } + + @Benchmark + fun JacksonDeserializeGraphQLRequest(): GraphQLServerRequest = mapper.readValue(request) + + @Benchmark + fun JacksonDeserializeGraphQLBatchRequest(): GraphQLServerRequest = mapper.readValue(batchRequest) + + @Benchmark + fun KSerializationDeserializeGraphQLRequest(): GraphQLServerRequest = Json.decodeFromString(request) + + @Benchmark + fun KSerializationDeserializeGraphQLBatchRequest(): GraphQLServerRequest = Json.decodeFromString(batchRequest) +} diff --git a/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerRequestSerializationBenchmark.kt b/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerRequestSerializationBenchmark.kt new file mode 100644 index 0000000000..02d2e80547 --- /dev/null +++ b/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerRequestSerializationBenchmark.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2024 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.server + +import com.expediagroup.graphql.server.testtypes.GraphQLBatchRequest +import com.expediagroup.graphql.server.testtypes.GraphQLRequest +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Measurement +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Warmup +import java.util.concurrent.TimeUnit + +@State(Scope.Benchmark) +@Fork(5) +@Warmup(iterations = 1, time = 5, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS) +open class GraphQLServerRequestSerializationBenchmark { + private val mapper = jacksonObjectMapper() + private lateinit var request: GraphQLRequest + private lateinit var batchRequest: GraphQLBatchRequest + + @Setup + fun setUp() { + val loader = this::class.java.classLoader + val operation = loader.getResource("StarWarsDetails.graphql")!!.readText().replace("\n", "\\n") + val variables = mapper.readValue>( + loader.getResourceAsStream("StarWarsDetailsVariables.json")!! + ) + request = GraphQLRequest(operation, "StarWarsDetails", variables) + batchRequest = GraphQLBatchRequest( + GraphQLRequest(operation, "StarWarsDetails", variables), + GraphQLRequest(operation, "StarWarsDetails", variables), + GraphQLRequest(operation, "StarWarsDetails", variables), + GraphQLRequest(operation, "StarWarsDetails", variables) + ) + } + + @Benchmark + fun JacksonSerializeGraphQLRequest(): String = mapper.writeValueAsString(request) + + @Benchmark + fun JacksonSerializeGraphQLBatchRequest(): String = mapper.writeValueAsString(batchRequest) + + @Benchmark + fun KSerializationSerializeGraphQLRequest(): String = Json.encodeToString(request) + + @Benchmark + fun KSerializationSerializeGraphQLBatchRequest(): String = Json.encodeToString(batchRequest) +} diff --git a/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerResponseDeserializationBenchmark.kt b/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerResponseDeserializationBenchmark.kt new file mode 100644 index 0000000000..1121136c1d --- /dev/null +++ b/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerResponseDeserializationBenchmark.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2024 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.server + +import com.expediagroup.graphql.server.testtypes.GraphQLServerResponse +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import kotlinx.serialization.json.Json +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Measurement +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Warmup +import java.util.concurrent.TimeUnit + +@State(Scope.Benchmark) +@Fork(5) +@Warmup(iterations = 1, time = 5, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS) +open class GraphQLServerResponseDeserializationBenchmark { + private val mapper = jacksonObjectMapper() + private lateinit var response: String + private lateinit var batchResponse: String + + @Setup + fun setUp() { + response = this::class.java.classLoader.getResource("StarWarsDetailsResponse.json")!!.readText() + batchResponse = """ + [ + $response, + $response, + $response, + $response + ] + """.trimIndent() + } + + @Benchmark + fun JacksonDeserializeGraphQLResponse(): GraphQLServerResponse = mapper.readValue(response) + + @Benchmark + fun JacksonDeserializeGraphQLBatchResponse(): GraphQLServerResponse = mapper.readValue(batchResponse) + + @Benchmark + fun KSerializationDeserializeGraphQLResponse(): GraphQLServerResponse = Json.decodeFromString(response) + + @Benchmark + fun KSerializationDeserializeGraphQLBatchResponse(): GraphQLServerResponse = Json.decodeFromString(batchResponse) +} diff --git a/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerResponseSerializationBenchmark.kt b/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerResponseSerializationBenchmark.kt new file mode 100644 index 0000000000..c3b78b0129 --- /dev/null +++ b/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLServerResponseSerializationBenchmark.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.server + +import com.expediagroup.graphql.server.testtypes.GraphQLBatchResponse +import com.expediagroup.graphql.server.testtypes.GraphQLResponse +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Measurement +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Warmup +import java.util.concurrent.TimeUnit + +@State(Scope.Benchmark) +@Fork(5) +@Warmup(iterations = 1, time = 5, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS) +open class GraphQLServerResponseSerializationBenchmark { + private val mapper = jacksonObjectMapper() + private lateinit var response: GraphQLResponse + private lateinit var batchResponse: GraphQLBatchResponse + + @Setup + fun setUp() { + val data = mapper.readValue>( + this::class.java.classLoader.getResourceAsStream("StarWarsDetailsResponse.json")!! + ) + response = GraphQLResponse( + mapper.readValue>( + this::class.java.classLoader.getResourceAsStream("StarWarsDetailsResponse.json")!! + ) + ) + batchResponse = GraphQLBatchResponse( + GraphQLResponse(data), + GraphQLResponse(data), + GraphQLResponse(data), + GraphQLResponse(data) + ) + } + + @Benchmark + fun JacksonSerializeGraphQLResponse(): String = mapper.writeValueAsString(response) + + @Benchmark + fun JacksonSerializeGraphQLBatchResponse(): String = mapper.writeValueAsString(batchResponse) + + @Benchmark + fun KSerializationSerializeGraphQLResponse(): String = Json.encodeToString(response) + + @Benchmark + fun KSerializationSerializeGraphQLBatchResponse(): String = Json.encodeToString(batchResponse) +} diff --git a/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLRequestBenchmark.kt b/servers/graphql-kotlin-server/src/benchmarks/kotlin/IsMutationBenchmark.kt similarity index 95% rename from servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLRequestBenchmark.kt rename to servers/graphql-kotlin-server/src/benchmarks/kotlin/IsMutationBenchmark.kt index 45ca756781..69ffd26ca2 100644 --- a/servers/graphql-kotlin-server/src/benchmarks/kotlin/GraphQLRequestBenchmark.kt +++ b/servers/graphql-kotlin-server/src/benchmarks/kotlin/IsMutationBenchmark.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2024 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,6 @@ package com.expediagroup.graphql.server import com.expediagroup.graphql.server.extensions.isMutation import com.expediagroup.graphql.server.types.GraphQLRequest -import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State import org.openjdk.jmh.annotations.Scope @@ -32,7 +31,7 @@ import kotlin.random.Random @Fork(1) @Warmup(iterations = 2) @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) -open class GraphQLRequestBenchmark { +open class IsMutationBenchmark { private val requests = mutableListOf() @Setup @@ -68,7 +67,7 @@ open class GraphQLRequestBenchmark { requests.add(GraphQLRequest(mutation)) } - @Benchmark + // @Benchmark fun isMutationBenchmark(): Boolean { return requests.any(GraphQLRequest::isMutation) } diff --git a/servers/graphql-kotlin-server/src/benchmarks/kotlin/testtypes/GraphQLServerRequest.kt b/servers/graphql-kotlin-server/src/benchmarks/kotlin/testtypes/GraphQLServerRequest.kt new file mode 100644 index 0000000000..2d3e3cc6e2 --- /dev/null +++ b/servers/graphql-kotlin-server/src/benchmarks/kotlin/testtypes/GraphQLServerRequest.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2024 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.server.testtypes + +import com.expediagroup.graphql.server.types.serializers.AnyNullableKSerializer +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonValue +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement + +@JsonDeserialize(using = GraphQLServerRequestDeserializer::class) +@Serializable(with = GraphQLServerRequestKSerializer::class) +sealed class GraphQLServerRequest + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonDeserialize(using = JsonDeserializer.None::class) +@Serializable +data class GraphQLRequest( + val query: String = "", + val operationName: String? = null, + val variables: Map? = null, + val extensions: Map? = null +) : GraphQLServerRequest() + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonDeserialize(using = JsonDeserializer.None::class) +@Serializable(with = GraphQLBatchRequestKSerializer::class) +data class GraphQLBatchRequest @JsonCreator constructor(@get:JsonValue val requests: List) : GraphQLServerRequest() { + constructor(vararg requests: GraphQLRequest) : this(requests.toList()) +} + +class GraphQLServerRequestDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): GraphQLServerRequest { + val codec = parser.codec + val jsonNode = codec.readTree(parser) + return if (jsonNode.isArray) { + codec.treeToValue(jsonNode, GraphQLBatchRequest::class.java) + } else { + codec.treeToValue(jsonNode, GraphQLRequest::class.java) + } + } +} + +object GraphQLServerRequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("GraphQLServerRequest") + + override fun deserialize(decoder: Decoder): GraphQLServerRequest { + val jsonDecoder = decoder as JsonDecoder + return when (val jsonElement = jsonDecoder.decodeJsonElement()) { + is JsonObject -> { + Json.decodeFromJsonElement(jsonElement) + } + is JsonArray -> { + GraphQLBatchRequest(Json.decodeFromJsonElement>(jsonElement)) + } + else -> throw SerializationException("Unknown JSON element found") + } + } + + override fun serialize( + encoder: Encoder, + value: GraphQLServerRequest, + ) { + when (value) { + is GraphQLRequest -> { + encoder.encodeSerializableValue(GraphQLRequest.serializer(), value) + } + is GraphQLBatchRequest -> { + encoder.encodeSerializableValue(ListSerializer(GraphQLRequest.serializer()), value.requests) + } + } + } +} + +object GraphQLBatchRequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("GraphQLBatchRequest") + + override fun deserialize(decoder: Decoder): GraphQLBatchRequest = + GraphQLBatchRequest(decoder.decodeSerializableValue(ListSerializer(GraphQLRequest.serializer()))) + + override fun serialize(encoder: Encoder, value: GraphQLBatchRequest) { + encoder.encodeSerializableValue(ListSerializer(GraphQLRequest.serializer()), value.requests) + } +} diff --git a/servers/graphql-kotlin-server/src/benchmarks/kotlin/testtypes/GraphQLServerResponse.kt b/servers/graphql-kotlin-server/src/benchmarks/kotlin/testtypes/GraphQLServerResponse.kt new file mode 100644 index 0000000000..e3a54975e9 --- /dev/null +++ b/servers/graphql-kotlin-server/src/benchmarks/kotlin/testtypes/GraphQLServerResponse.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2024 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.server.testtypes + +import com.expediagroup.graphql.server.types.serializers.AnyNullableKSerializer +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonValue +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement + +@JsonDeserialize(using = GraphQLServerResponseDeserializer::class) +@Serializable(with = GraphQLServerResponseKSerializer::class) +sealed class GraphQLServerResponse + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonDeserialize(using = JsonDeserializer.None::class) +@Serializable +data class GraphQLResponse( + val data: Map? = null, + val errors: List? = null, + val extensions: Map? = null +) : GraphQLServerResponse() + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonDeserialize(using = JsonDeserializer.None::class) +@Serializable(with = GraphQLBatchResponseKSerializer::class) +data class GraphQLBatchResponse @JsonCreator constructor(@get:JsonValue val responses: List) : GraphQLServerResponse() { + constructor(vararg responses: GraphQLResponse) : this(responses.toList()) +} + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Serializable +data class GraphQLServerError( + val message: String, + val locations: List? = null, + val path: List<@Serializable(with = GraphQLErrorPathKSerializer::class) Any>? = null, + val extensions: Map? = null +) + +@JsonIgnoreProperties(ignoreUnknown = true) +@Serializable +data class GraphQLSourceLocation( + val line: Int, + val column: Int +) + +class GraphQLServerResponseDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): GraphQLServerResponse { + val codec = parser.codec + val jsonNode = codec.readTree(parser) + return if (jsonNode.isArray) { + codec.treeToValue(jsonNode, GraphQLBatchResponse::class.java) + } else { + codec.treeToValue(jsonNode, GraphQLResponse::class.java) + } + } +} + +object GraphQLServerResponseKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("GraphQLServerResponse") + + override fun deserialize(decoder: Decoder): GraphQLServerResponse { + val jsonDecoder = decoder as JsonDecoder + return when (val jsonElement = jsonDecoder.decodeJsonElement()) { + is JsonObject -> Json.decodeFromJsonElement(jsonElement) + is JsonArray -> GraphQLBatchResponse(Json.decodeFromJsonElement>(jsonElement)) + else -> throw SerializationException("Unknown JSON element found") + } + } + + override fun serialize( + encoder: Encoder, + value: GraphQLServerResponse, + ) { + when (value) { + is GraphQLResponse -> encoder.encodeSerializableValue(GraphQLResponse.serializer(), value) + is GraphQLBatchResponse -> encoder.encodeSerializableValue(ListSerializer(GraphQLResponse.serializer()), value.responses) + } + } +} + +object GraphQLBatchResponseKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("GraphQLBatchResponse") + + override fun deserialize(decoder: Decoder): GraphQLBatchResponse = + GraphQLBatchResponse(decoder.decodeSerializableValue(ListSerializer(GraphQLResponse.serializer()))) + + override fun serialize(encoder: Encoder, value: GraphQLBatchResponse) { + encoder.encodeSerializableValue(ListSerializer(GraphQLResponse.serializer()), value.responses) + } +} + +class GraphQLErrorPathKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("GraphQLErrorPath") + + override fun serialize(encoder: Encoder, value: Any) { + val jsonEncoder = encoder as JsonEncoder + val jsonElement = when (value) { + is Int -> JsonPrimitive(value) + is String -> JsonPrimitive(value) + else -> { + // should never be the case + JsonPrimitive(value.toString()) + } + } + jsonEncoder.encodeJsonElement(jsonElement) + } + + override fun deserialize(decoder: Decoder): Any { + val jsonDecoder = decoder as JsonDecoder + val element = jsonDecoder.decodeJsonElement() as JsonPrimitive + return if (!element.isString) { + element.content.toIntOrNull() ?: element.content + } else { + element.content + } + } +} diff --git a/servers/graphql-kotlin-server/src/benchmarks/resources/StarWarsDetails.graphql b/servers/graphql-kotlin-server/src/benchmarks/resources/StarWarsDetails.graphql new file mode 100644 index 0000000000..761d0290a5 --- /dev/null +++ b/servers/graphql-kotlin-server/src/benchmarks/resources/StarWarsDetails.graphql @@ -0,0 +1,1156 @@ +query StarWarsDetails { + person1: person(id: $personId1) { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + person2: person(id: $personId2) { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + person3: person(id: $personId3) { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + person4: person(id: $personId4) { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + person5: person(id: $personId5) { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + person6: person(id: $personId6) { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + person7: person(id: $personId7) { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + person8: person(id: $personId8) { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + person9: person(id: $personId9) { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + person10: person(id: $personId10) { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + film1: film(id: $filmId1) { + ... on Film { + title + episodeID + director + producers + releaseDate + characterConnection { + edges { + node { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + } + } + starshipConnection { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + planetConnection { + edges { + node { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + } + } + } + } + film2: film(id: $filmId2) { + ... on Film { + title + episodeID + director + producers + releaseDate + characterConnection { + edges { + node { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + } + } + starshipConnection { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + planetConnection { + edges { + node { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + } + } + } + } + film3: film(id: $filmId3) { + ... on Film { + title + episodeID + director + producers + releaseDate + characterConnection { + edges { + node { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + } + } + starshipConnection { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + planetConnection { + edges { + node { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + } + } + } + } + film4: film(id: $filmId4) { + ... on Film { + title + episodeID + director + producers + releaseDate + characterConnection { + edges { + node { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + } + } + starshipConnection { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + planetConnection { + edges { + node { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + } + } + } + } + film5: film(id: $filmId5) { + ... on Film { + title + episodeID + director + producers + releaseDate + characterConnection { + edges { + node { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + } + } + starshipConnection { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + planetConnection { + edges { + node { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + } + } + } + } + film6: film(id: $filmId6) { + ... on Film { + title + episodeID + director + producers + releaseDate + characterConnection { + edges { + node { + ... on Person { + name + birthYear + eyeColor + gender + hairColor + height + mass + skinColor + starships { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + homeworld { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + filmConnection { + edges { + node { + title + } + } + } + } + } + } + } + starshipConnection { + edges { + node { + ... on Starship { + name + model + starshipClass + manufacturers + costInCredits + length + crew + passengers + maxAtmospheringSpeed + hyperdriveRating + MGLT + cargoCapacity + consumables + } + } + } + } + planetConnection { + edges { + node { + ... on Planet { + name + diameter + rotationPeriod + orbitalPeriod + gravity + population + climates + terrains + surfaceWater + } + } + } + } + } + } +} diff --git a/servers/graphql-kotlin-server/src/benchmarks/resources/StarWarsDetailsResponse.json b/servers/graphql-kotlin-server/src/benchmarks/resources/StarWarsDetailsResponse.json new file mode 100644 index 0000000000..8e9fa58af7 --- /dev/null +++ b/servers/graphql-kotlin-server/src/benchmarks/resources/StarWarsDetailsResponse.json @@ -0,0 +1,411 @@ +{ + "data": { + "person1": { + "name": "Luke Skywalker", + "birthYear": "19BBY", + "eyeColor": "blue", + "gender": "male", + "hairColor": "blond", + "height": 172, + "mass": 77, + "skinColor": "fair", + "starships": { + "edges": [ + { + "node": { + "name": "X-wing", + "model": "T-65 X-wing", + "starshipClass": "Starfighter", + "manufacturers": ["Incom Corporation"], + "costInCredits": 149999, + "length": 13.4, + "crew": 1, + "passengers": 0, + "maxAtmospheringSpeed": 1050, + "hyperdriveRating": 1.0, + "MGLT": 100, + "cargoCapacity": 110, + "consumables": "1 week" + } + } + ] + }, + "homeworld": { + "name": "Tatooine", + "diameter": 10465, + "rotationPeriod": 23, + "orbitalPeriod": 304, + "gravity": "1 standard", + "population": 200000, + "climates": ["arid"], + "terrains": ["desert"], + "surfaceWater": 1 + }, + "filmConnection": { + "edges": [ + { + "node": { + "title": "A New Hope" + } + } + ] + } + }, + "person2": { + "name": "Leia Organa", + "birthYear": "19BBY", + "eyeColor": "brown", + "gender": "female", + "hairColor": "brown", + "height": 150, + "mass": 49, + "skinColor": "light", + "starships": { + "edges": [] + }, + "homeworld": { + "name": "Alderaan", + "diameter": 12500, + "rotationPeriod": 24, + "orbitalPeriod": 364, + "gravity": "1 standard", + "population": 2000000000, + "climates": ["temperate"], + "terrains": ["grasslands", "mountains"], + "surfaceWater": 40 + }, + "filmConnection": { + "edges": [ + { + "node": { + "title": "The Empire Strikes Back" + } + } + ] + } + }, + "person3": { + "name": "Luke Skywalker", + "birthYear": "19BBY", + "eyeColor": "blue", + "gender": "male", + "hairColor": "blond", + "height": 172, + "mass": 77, + "skinColor": "fair", + "starships": { + "edges": [ + { + "node": { + "name": "X-wing", + "model": "T-65 X-wing", + "starshipClass": "Starfighter", + "manufacturers": ["Incom Corporation"], + "costInCredits": 149999, + "length": 13.4, + "crew": 1, + "passengers": 0, + "maxAtmospheringSpeed": 1050, + "hyperdriveRating": 1.0, + "MGLT": 100, + "cargoCapacity": 110, + "consumables": "1 week" + } + } + ] + }, + "homeworld": { + "name": "Tatooine", + "diameter": 10465, + "rotationPeriod": 23, + "orbitalPeriod": 304, + "gravity": "1 standard", + "population": 200000, + "climates": ["arid"], + "terrains": ["desert"], + "surfaceWater": 1 + }, + "filmConnection": { + "edges": [ + { + "node": { + "title": "A New Hope" + } + } + ] + } + }, + "person4": { + "name": "Luke Skywalker", + "birthYear": "19BBY", + "eyeColor": "blue", + "gender": "male", + "hairColor": "blond", + "height": 172, + "mass": 77, + "skinColor": "fair", + "starships": { + "edges": [ + { + "node": { + "name": "X-wing", + "model": "T-65 X-wing", + "starshipClass": "Starfighter", + "manufacturers": ["Incom Corporation"], + "costInCredits": 149999, + "length": 13.4, + "crew": 1, + "passengers": 0, + "maxAtmospheringSpeed": 1050, + "hyperdriveRating": 1.0, + "MGLT": 100, + "cargoCapacity": 110, + "consumables": "1 week" + } + } + ] + }, + "homeworld": { + "name": "Tatooine", + "diameter": 10465, + "rotationPeriod": 23, + "orbitalPeriod": 304, + "gravity": "1 standard", + "population": 200000, + "climates": ["arid"], + "terrains": ["desert"], + "surfaceWater": 1 + }, + "filmConnection": { + "edges": [ + { + "node": { + "title": "A New Hope" + } + } + ] + } + }, + "person5": { + "name": "Luke Skywalker", + "birthYear": "19BBY", + "eyeColor": "blue", + "gender": "male", + "hairColor": "blond", + "height": 172, + "mass": 77, + "skinColor": "fair", + "starships": { + "edges": [ + { + "node": { + "name": "X-wing", + "model": "T-65 X-wing", + "starshipClass": "Starfighter", + "manufacturers": ["Incom Corporation"], + "costInCredits": 149999, + "length": 13.4, + "crew": 1, + "passengers": 0, + "maxAtmospheringSpeed": 1050, + "hyperdriveRating": 1.0, + "MGLT": 100, + "cargoCapacity": 110, + "consumables": "1 week" + } + } + ] + }, + "homeworld": { + "name": "Tatooine", + "diameter": 10465, + "rotationPeriod": 23, + "orbitalPeriod": 304, + "gravity": "1 standard", + "population": 200000, + "climates": ["arid"], + "terrains": ["desert"], + "surfaceWater": 1 + }, + "filmConnection": { + "edges": [ + { + "node": { + "title": "A New Hope" + } + } + ] + } + }, + "film1": { + "title": "A New Hope", + "episodeID": 4, + "director": "George Lucas", + "producers": ["Gary Kurtz", "Rick McCallum"], + "releaseDate": "1977-05-25", + "characterConnection": { + "edges": [ + { + "node": { + "name": "Luke Skywalker" + } + } + ] + }, + "starshipConnection": { + "edges": [ + { + "node": { + "name": "X-wing" + } + } + ] + }, + "planetConnection": { + "edges": [ + { + "node": { + "name": "Tatooine" + } + } + ] + } + }, + "film2": { + "title": "A New Hope", + "episodeID": 4, + "director": "George Lucas", + "producers": ["Gary Kurtz", "Rick McCallum"], + "releaseDate": "1977-05-25", + "characterConnection": { + "edges": [ + { + "node": { + "name": "Luke Skywalker" + } + } + ] + }, + "starshipConnection": { + "edges": [ + { + "node": { + "name": "X-wing" + } + } + ] + }, + "planetConnection": { + "edges": [ + { + "node": { + "name": "Tatooine" + } + } + ] + } + }, + "film3": { + "title": "A New Hope", + "episodeID": 4, + "director": "George Lucas", + "producers": ["Gary Kurtz", "Rick McCallum"], + "releaseDate": "1977-05-25", + "characterConnection": { + "edges": [ + { + "node": { + "name": "Luke Skywalker" + } + } + ] + }, + "starshipConnection": { + "edges": [ + { + "node": { + "name": "X-wing" + } + } + ] + }, + "planetConnection": { + "edges": [ + { + "node": { + "name": "Tatooine" + } + } + ] + } + }, + "film4": { + "title": "A New Hope", + "episodeID": 4, + "director": "George Lucas", + "producers": ["Gary Kurtz", "Rick McCallum"], + "releaseDate": "1977-05-25", + "characterConnection": { + "edges": [ + { + "node": { + "name": "Luke Skywalker" + } + } + ] + }, + "starshipConnection": { + "edges": [ + { + "node": { + "name": "X-wing" + } + } + ] + }, + "planetConnection": { + "edges": [ + { + "node": { + "name": "Tatooine" + } + } + ] + } + }, + "film5": { + "title": "A New Hope", + "episodeID": 4, + "director": "George Lucas", + "producers": ["Gary Kurtz", "Rick McCallum"], + "releaseDate": "1977-05-25", + "characterConnection": { + "edges": [ + { + "node": { + "name": "Luke Skywalker" + } + } + ] + }, + "starshipConnection": { + "edges": [ + { + "node": { + "name": "X-wing" + } + } + ] + }, + "planetConnection": { + "edges": [ + { + "node": { + "name": "Tatooine" + } + } + ] + } + } + } +} diff --git a/servers/graphql-kotlin-server/src/benchmarks/resources/StarWarsDetailsVariables.json b/servers/graphql-kotlin-server/src/benchmarks/resources/StarWarsDetailsVariables.json new file mode 100644 index 0000000000..37430509e3 --- /dev/null +++ b/servers/graphql-kotlin-server/src/benchmarks/resources/StarWarsDetailsVariables.json @@ -0,0 +1,94 @@ +{ + "key_5_0": { + "key_4_0": { + "key_3_0": { + "key_2_0": { + "key_1_0": 82, + "key_1_1": 72 + }, + "key_2_1": { + "key_1_0": 8, + "key_1_1": 94 + } + }, + "key_3_1": { + "key_2_0": { + "key_1_0": 89, + "key_1_1": 24 + }, + "key_2_1": { + "key_1_0": 2, + "key_1_1": 12 + } + } + }, + "key_4_1": { + "key_3_0": { + "key_2_0": { + "key_1_0": 4, + "key_1_1": 27 + }, + "key_2_1": { + "key_1_0": 75, + "key_1_1": 6 + } + }, + "key_3_1": { + "key_2_0": { + "key_1_0": 93, + "key_1_1": 74 + }, + "key_2_1": { + "key_1_0": 31, + "key_1_1": 19 + } + } + } + }, + "key_5_1": { + "key_4_0": { + "key_3_0": { + "key_2_0": { + "key_1_0": 65, + "key_1_1": 17 + }, + "key_2_1": { + "key_1_0": 17, + "key_1_1": 25 + } + }, + "key_3_1": { + "key_2_0": { + "key_1_0": 66, + "key_1_1": 85 + }, + "key_2_1": { + "key_1_0": 20, + "key_1_1": 76 + } + } + }, + "key_4_1": { + "key_3_0": { + "key_2_0": { + "key_1_0": 29, + "key_1_1": 1 + }, + "key_2_1": { + "key_1_0": 21, + "key_1_1": 97 + } + }, + "key_3_1": { + "key_2_0": { + "key_1_0": 26, + "key_1_1": 43 + }, + "key_2_1": { + "key_1_0": 25, + "key_1_1": 43 + } + } + } + } +} diff --git a/servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/types/GraphQLServerRequest.kt b/servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/types/GraphQLServerRequest.kt index f985bcd19a..d8adb1e4a3 100644 --- a/servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/types/GraphQLServerRequest.kt +++ b/servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/types/GraphQLServerRequest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2024 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package com.expediagroup.graphql.server.types +import com.expediagroup.graphql.server.types.serializers.AnyNullableKSerializer import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonInclude @@ -25,11 +26,25 @@ import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonDeserializer import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement /** * GraphQL server request abstraction that provides a convenient way to handle both single and batch requests. */ @JsonDeserialize(using = GraphQLServerRequestDeserializer::class) +@Serializable(with = GraphQLServerRequestKSerializer::class) sealed class GraphQLServerRequest /** @@ -38,11 +53,12 @@ sealed class GraphQLServerRequest @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) @JsonDeserialize(using = JsonDeserializer.None::class) +@Serializable data class GraphQLRequest( val query: String = "", val operationName: String? = null, - val variables: Map? = null, - val extensions: Map? = null + val variables: Map? = null, + val extensions: Map? = null ) : GraphQLServerRequest() /** @@ -51,6 +67,7 @@ data class GraphQLRequest( @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) @JsonDeserialize(using = JsonDeserializer.None::class) +@Serializable(with = GraphQLBatchRequestKSerializer::class) data class GraphQLBatchRequest @JsonCreator constructor(@get:JsonValue val requests: List) : GraphQLServerRequest() { constructor(vararg requests: GraphQLRequest) : this(requests.toList()) } @@ -66,3 +83,34 @@ class GraphQLServerRequestDeserializer : JsonDeserializer( } } } + +object GraphQLServerRequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("GraphQLServerRequest") + + override fun deserialize(decoder: Decoder): GraphQLServerRequest { + val jsonDecoder = decoder as JsonDecoder + return when (val jsonElement = jsonDecoder.decodeJsonElement()) { + is JsonObject -> Json.decodeFromJsonElement(jsonElement) + is JsonArray -> GraphQLBatchRequest(Json.decodeFromJsonElement>(jsonElement)) + else -> throw SerializationException("Unknown JSON element found") + } + } + + override fun serialize(encoder: Encoder, value: GraphQLServerRequest) { + when (value) { + is GraphQLRequest -> encoder.encodeSerializableValue(GraphQLRequest.serializer(), value) + is GraphQLBatchRequest -> encoder.encodeSerializableValue(ListSerializer(GraphQLRequest.serializer()), value.requests) + } + } +} + +object GraphQLBatchRequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("GraphQLBatchRequest") + + override fun deserialize(decoder: Decoder): GraphQLBatchRequest = + GraphQLBatchRequest(decoder.decodeSerializableValue(ListSerializer(GraphQLRequest.serializer()))) + + override fun serialize(encoder: Encoder, value: GraphQLBatchRequest) { + encoder.encodeSerializableValue(ListSerializer(GraphQLRequest.serializer()), value.requests) + } +} diff --git a/servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/types/serializers/AnyNullableKSerializer.kt b/servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/types/serializers/AnyNullableKSerializer.kt new file mode 100644 index 0000000000..d799f268bd --- /dev/null +++ b/servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/types/serializers/AnyNullableKSerializer.kt @@ -0,0 +1,72 @@ +package com.expediagroup.graphql.server.types.serializers + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +object AnyNullableKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("AnyNullable") + + override fun serialize( + encoder: Encoder, + value: Any?, + ) { + val jsonEncoder = encoder as JsonEncoder + jsonEncoder.encodeJsonElement(serializeAny(value)) + } + + private fun serializeAny(value: Any?): JsonElement = + when (value) { + null -> JsonNull + is Map<*, *> -> { + val mapContents = + value.mapNotNull { (key, value) -> + key.toString() to serializeAny(value) + }.toMap() + JsonObject(mapContents) + } + is List<*> -> { + val arrayContents = value.mapNotNull { listEntry -> serializeAny(listEntry) } + JsonArray(arrayContents) + } + is Number -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is String -> JsonPrimitive(value) + else -> JsonNull + } + + override fun deserialize(decoder: Decoder): Any? { + val jsonDecoder = decoder as JsonDecoder + val element = jsonDecoder.decodeJsonElement() + return deserializeJsonElement(element) + } + + private fun deserializeJsonElement(element: JsonElement): Any? = + when (element) { + is JsonNull -> null + is JsonObject -> { + element.mapValues { deserializeJsonElement(it.value) } + } + is JsonArray -> { + element.map { deserializeJsonElement(it) } + } + is JsonPrimitive -> + when { + element.isString -> element.content + element.content == "true" -> true + element.content == "false" -> false + else -> { + element.content.toIntOrNull() ?: element.content.toLongOrNull() ?: element.content.toDoubleOrNull() ?: element.content + } + } + } +} diff --git a/servers/graphql-kotlin-server/src/test/kotlin/com/expediagroup/graphql/server/types/GraphQLServerRequestTest.kt b/servers/graphql-kotlin-server/src/test/kotlin/com/expediagroup/graphql/server/types/GraphQLServerRequestTest.kt index 6e7cb9a736..5b8579cd30 100644 --- a/servers/graphql-kotlin-server/src/test/kotlin/com/expediagroup/graphql/server/types/GraphQLServerRequestTest.kt +++ b/servers/graphql-kotlin-server/src/test/kotlin/com/expediagroup/graphql/server/types/GraphQLServerRequestTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Expedia, Inc + * Copyright 2024 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,8 +16,8 @@ package com.expediagroup.graphql.server.types -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -25,8 +25,6 @@ import kotlin.test.assertTrue class GraphQLServerRequestTest { - private val mapper = jacksonObjectMapper() - @Test fun `verify simple serialization`() { val request = GraphQLRequest( @@ -36,7 +34,7 @@ class GraphQLServerRequestTest { val expectedJson = """{"query":"{ foo }"}""" - assertEquals(expectedJson, mapper.writeValueAsString(request)) + assertEquals(expectedJson, Json.encodeToString(request)) } @Test @@ -50,7 +48,7 @@ class GraphQLServerRequestTest { val expectedJson = """{"query":"query FooQuery(${'$'}input: Int) { foo(${'$'}input) }","operationName":"FooQuery","variables":{"input":1}}""" - assertEquals(expectedJson, mapper.writeValueAsString(request)) + assertEquals(expectedJson, Json.encodeToString(request)) } @Test @@ -69,7 +67,7 @@ class GraphQLServerRequestTest { ) val expectedJson = """[{"query":"query FooQuery(${'$'}input: Int) { foo(${'$'}input) }","operationName":"FooQuery","variables":{"input":1}},{"query":"query BarQuery { bar }"}]""" - assertEquals(expectedJson, mapper.writeValueAsString(request)) + assertEquals(expectedJson, Json.encodeToString(request)) } @Test @@ -77,7 +75,7 @@ class GraphQLServerRequestTest { val input = """{"query":"{ foo }"}""" - val request = mapper.readValue(input) + val request = Json.decodeFromString(input) assertTrue(request is GraphQLRequest) assertEquals("{ foo }", request.query) @@ -90,7 +88,7 @@ class GraphQLServerRequestTest { val input = """{"query":"query FooQuery(${'$'}input: Int) { foo(${'$'}input) }","operationName":"FooQuery","variables":{"input":1}}""" - val request = mapper.readValue(input) + val request = Json.decodeFromString(input) assertTrue(request is GraphQLRequest) assertEquals("query FooQuery(\$input: Int) { foo(\$input) }", request.query) @@ -103,7 +101,7 @@ class GraphQLServerRequestTest { val input = """[{"query":"query FooQuery(${'$'}input: Int) { foo(${'$'}input) }","operationName":"FooQuery","variables":{"input":1}},{"query":"query BarQuery { bar }"}]""" - val request = mapper.readValue(input) + val request = Json.decodeFromString(input) assertTrue(request is GraphQLBatchRequest) assertEquals(2, request.requests.size)