Skip to content

Commit a9ad9fc

Browse files
authored
[client] create new GraphQL client serialization abstraction (#1066)
* [client] create new GraphQL client serialization abstraction This is a first PR in a series that will introduce support for both `Jackson` and `kotlinx.serialization` data formats for GraphQL Kotlin clients. This PR provides general refactoring of GraphQL client request and response types and introduces new GraphQL client serialization abstraction. We need this additional abstraction as we need to provide some default configurations for both `Jackson` and `kotlinx.serialization` serializers and we cannot rely on the provided defaults. By serializing requests to raw String (and deserializing responses from String) allows us to simplify the configuration logic that would be required to support both formats across Ktor and Spring based clients. If serializer is not explicitly specified, then default one will be automatically loaded from a classpath using `Java ServiceLoader` mechanism. NOTE: clients still default to use Jackson which is currently only supported format from the generator (support for generation of `kotlinx.serialization` data models will be added in subsequent PRs) Changes: * Update GraphQL client request and response types to support both `Jackson` and `kotlinx.serialization` formats. * Create new `GraphQLClientSerializer` abstraction that is used by the GraphQL clients to serialize requests and deserialize responses * Create `Jackson` and `kotlinx.serialization` compatible GraphQL serializers * Drop the generation of unnecessary `executeXQuery` extension functions Related: * #1048 - this is an attempt to split it up to more manageable pieces * #929 - adds initial support for `kotlinx.serialization` * add missing copyright headers to test files
1 parent 4a89a8b commit a9ad9fc

File tree

70 files changed

+2639
-801
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+2639
-801
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# GraphQL Kotlin Client Jackson
2+
[![Maven Central](https://img.shields.io/maven-central/v/com.expediagroup/graphql-kotlin-client-jackson.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:%22com.expediagroup%22%20AND%20a:%22graphql-kotlin-client-jackson%22)
3+
[![Javadocs](https://img.shields.io/maven-central/v/com.expediagroup/graphql-kotlin-client-jackson.svg?label=javadoc&colorB=brightgreen)](https://www.javadoc.io/doc/com.expediagroup/graphql-kotlin-client-jackson)
4+
5+
`graphql-kotlin-client-jackson` is a [Jackson](https://github.com/FasterXML/jackson) based GraphQL client serializer. GraphQL
6+
client serializers provide a serializer/deserializer abstraction to the GraphQL clients and handle actual request/response
7+
serialization logic allowing clients to POST and receive raw Strings.
8+
9+
## Documentation
10+
For the latest documentation, see our GitHub pages docs site: [https://expediagroup.github.io/graphql-kotlin](https://expediagroup.github.io/graphql-kotlin)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
description = "GraphQL client serializer based on Jackson"
2+
3+
val jacksonVersion: String by project
4+
5+
dependencies {
6+
api(project(path = ":graphql-kotlin-client"))
7+
api("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
8+
}
9+
10+
tasks {
11+
jacocoTestCoverageVerification {
12+
violationRules {
13+
rule {
14+
limit {
15+
counter = "INSTRUCTION"
16+
value = "COVEREDRATIO"
17+
minimum = "0.85".toBigDecimal()
18+
}
19+
}
20+
}
21+
}
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright 2021 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.client.jackson
18+
19+
import com.expediagroup.graphql.client.jackson.types.JacksonGraphQLResponse
20+
import com.expediagroup.graphql.client.serializer.GraphQLClientSerializer
21+
import com.fasterxml.jackson.databind.DeserializationFeature
22+
import com.fasterxml.jackson.databind.JavaType
23+
import com.fasterxml.jackson.databind.ObjectMapper
24+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
25+
import java.util.concurrent.ConcurrentHashMap
26+
import kotlin.reflect.KClass
27+
28+
/**
29+
* Jackson based GraphQL request/response serializer.
30+
*/
31+
class GraphQLClientJacksonSerializer(private val mapper: ObjectMapper = jacksonObjectMapper()) : GraphQLClientSerializer {
32+
private val typeCache = ConcurrentHashMap<KClass<*>, JavaType>()
33+
34+
init {
35+
mapper.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE)
36+
}
37+
38+
override fun serialize(request: Any): String = mapper.writeValueAsString(request)
39+
40+
override fun <T : Any> deserialize(rawResponse: String, responseType: KClass<T>): JacksonGraphQLResponse<T> =
41+
mapper.readValue(rawResponse, parameterizedType(responseType))
42+
43+
override fun deserialize(rawResponses: String, responseTypes: List<KClass<*>>): List<JacksonGraphQLResponse<*>> {
44+
val jsonResponse = mapper.readTree(rawResponses)
45+
46+
return if (jsonResponse.isArray) {
47+
jsonResponse.withIndex().map { (index, element) ->
48+
val singleResponse: JacksonGraphQLResponse<*> = mapper.convertValue(element, parameterizedType(responseTypes[index]))
49+
singleResponse
50+
}
51+
} else {
52+
// should never be the case
53+
listOf(mapper.convertValue(jsonResponse, parameterizedType(responseTypes.first())))
54+
}
55+
}
56+
57+
private fun <T : Any> parameterizedType(resultType: KClass<T>): JavaType =
58+
typeCache.computeIfAbsent(resultType) {
59+
mapper.typeFactory.constructParametricType(JacksonGraphQLResponse::class.java, resultType.java)
60+
}
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright 2021 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.client.jackson.types
18+
19+
import com.expediagroup.graphql.client.types.GraphQLError
20+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
21+
import com.fasterxml.jackson.annotation.JsonInclude
22+
23+
@JsonIgnoreProperties(ignoreUnknown = true)
24+
@JsonInclude(JsonInclude.Include.NON_NULL)
25+
data class JacksonGraphQLError(
26+
override val message: String,
27+
override val locations: List<JacksonSourceLocation>? = null,
28+
override val path: List<Any>? = null,
29+
override val extensions: Map<String, Any?>? = null
30+
) : GraphQLError
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2021 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.client.jackson.types
18+
19+
import com.expediagroup.graphql.client.types.GraphQLClientResponse
20+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
21+
import com.fasterxml.jackson.annotation.JsonInclude
22+
23+
@JsonIgnoreProperties(ignoreUnknown = true)
24+
@JsonInclude(JsonInclude.Include.NON_NULL)
25+
data class JacksonGraphQLResponse<T>(
26+
override val data: T? = null,
27+
override val errors: List<JacksonGraphQLError>? = null,
28+
override val extensions: Map<String, Any?>? = null
29+
) : GraphQLClientResponse<T>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright 2021 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.client.jackson.types
18+
19+
import com.expediagroup.graphql.client.types.SourceLocation
20+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
21+
import com.fasterxml.jackson.annotation.JsonInclude
22+
23+
@JsonIgnoreProperties(ignoreUnknown = true)
24+
@JsonInclude(JsonInclude.Include.NON_NULL)
25+
data class JacksonSourceLocation(
26+
override val line: Int,
27+
override val column: Int
28+
) : SourceLocation
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
com.expediagroup.graphql.client.jackson.GraphQLClientJacksonSerializer
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* Copyright 2021 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.client.jackson
18+
19+
import com.expediagroup.graphql.client.jackson.data.EnumQuery
20+
import com.expediagroup.graphql.client.jackson.data.FirstQuery
21+
import com.expediagroup.graphql.client.jackson.data.OtherQuery
22+
import com.expediagroup.graphql.client.jackson.data.PolymorphicQuery
23+
import com.expediagroup.graphql.client.jackson.data.ScalarQuery
24+
import com.expediagroup.graphql.client.jackson.data.polymorphicquery.SecondInterfaceImplementation
25+
import com.expediagroup.graphql.client.jackson.types.JacksonGraphQLError
26+
import com.expediagroup.graphql.client.jackson.types.JacksonGraphQLResponse
27+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
28+
import org.junit.jupiter.api.Test
29+
import java.util.UUID
30+
import kotlin.test.assertEquals
31+
32+
class GraphQLClientJacksonSerializerTest {
33+
34+
@Test
35+
fun `verify we can serialize GraphQLClientRequest`() {
36+
val testQuery = FirstQuery(FirstQuery.Variables(input = 1.0f))
37+
val expected =
38+
"""{
39+
| "query": "FIRST_QUERY",
40+
| "operationName": "FirstQuery",
41+
| "variables": { "input": 1.0 }
42+
|}
43+
""".trimMargin()
44+
45+
val mapper = jacksonObjectMapper()
46+
val serializer = GraphQLClientJacksonSerializer(mapper)
47+
val result = serializer.serialize(testQuery)
48+
assertEquals(mapper.readTree(expected), mapper.readTree(result))
49+
}
50+
51+
@Test
52+
fun `verify we can serialize batch GraphQLClientRequest`() {
53+
val queries = listOf(FirstQuery(FirstQuery.Variables(input = 1.0f)), OtherQuery())
54+
val expected =
55+
"""[{
56+
| "query": "FIRST_QUERY",
57+
| "operationName": "FirstQuery",
58+
| "variables": { "input": 1.0 }
59+
|},{
60+
| "query": "OTHER_QUERY",
61+
| "operationName": "OtherQuery",
62+
| "variables": null
63+
|}]
64+
""".trimMargin()
65+
66+
val mapper = jacksonObjectMapper()
67+
val serializer = GraphQLClientJacksonSerializer(mapper)
68+
val result = serializer.serialize(queries)
69+
assertEquals(mapper.readTree(expected), mapper.readTree(result))
70+
}
71+
72+
@Test
73+
fun `verify we can deserialize JacksonGraphQLResponse`() {
74+
val testQuery = FirstQuery(variables = FirstQuery.Variables())
75+
val expected = JacksonGraphQLResponse(
76+
data = FirstQuery.Result("hello world"),
77+
errors = listOf(JacksonGraphQLError(message = "test error message")),
78+
extensions = mapOf("extVal" to 123, "extList" to listOf("ext1", "ext2"), "extMap" to mapOf("1" to 1, "2" to 2))
79+
)
80+
val rawResponse =
81+
"""{
82+
| "data": { "stringResult" : "hello world" },
83+
| "errors": [{ "message" : "test error message" }],
84+
| "extensions" : { "extVal" : 123, "extList" : ["ext1", "ext2"], "extMap" : { "1" : 1, "2" : 2} }
85+
|}
86+
""".trimMargin()
87+
88+
val serializer = GraphQLClientJacksonSerializer()
89+
val result = serializer.deserialize(rawResponse, testQuery.responseType())
90+
assertEquals(expected, result)
91+
}
92+
93+
@Test
94+
fun `verify we can deserialize batch JacksonGraphQLResponse`() {
95+
val testQuery = FirstQuery(variables = FirstQuery.Variables())
96+
val otherQuery = OtherQuery()
97+
val expected = listOf(
98+
JacksonGraphQLResponse(
99+
data = FirstQuery.Result("hello world"),
100+
errors = listOf(JacksonGraphQLError(message = "test error message")),
101+
extensions = mapOf("extVal" to 123, "extList" to listOf("ext1", "ext2"), "extMap" to mapOf("1" to 1, "2" to 2))
102+
),
103+
JacksonGraphQLResponse(
104+
data = OtherQuery.Result(stringResult = "goodbye world", integerResult = 42)
105+
)
106+
)
107+
val rawResponses =
108+
"""[{
109+
| "data": { "stringResult" : "hello world" },
110+
| "errors": [{ "message" : "test error message" }],
111+
| "extensions" : { "extVal" : 123, "extList" : ["ext1", "ext2"], "extMap" : { "1" : 1, "2" : 2} }
112+
|}, {
113+
| "data": { "stringResult" : "goodbye world", "integerResult" : 42 }
114+
|}]
115+
""".trimMargin()
116+
117+
val serializer = GraphQLClientJacksonSerializer()
118+
val result = serializer.deserialize(rawResponses, listOf(testQuery.responseType(), otherQuery.responseType()))
119+
assertEquals(expected, result)
120+
}
121+
122+
@Test
123+
fun `verify we can deserialize polymorphic response`() {
124+
val polymorphicResponse =
125+
"""{
126+
| "data": {
127+
| "polymorphicResult": {
128+
| "__typename": "SecondInterfaceImplementation",
129+
| "id": 123,
130+
| "floatValue": 1.2
131+
| }
132+
| }
133+
|}
134+
""".trimMargin()
135+
val serializer = GraphQLClientJacksonSerializer()
136+
val result = serializer.deserialize(polymorphicResponse, PolymorphicQuery().responseType())
137+
assertEquals(SecondInterfaceImplementation(123, 1.2f), result.data?.polymorphicResult)
138+
}
139+
140+
@Test
141+
fun `verify we can deserialize custom scalars`() {
142+
val expectedUUID = UUID.randomUUID()
143+
val scalarResponse =
144+
"""{
145+
| "data": {
146+
| "scalarAlias": "1234",
147+
| "customScalar": "$expectedUUID"
148+
| }
149+
|}
150+
""".trimMargin()
151+
val serializer = GraphQLClientJacksonSerializer()
152+
val result = serializer.deserialize(scalarResponse, ScalarQuery().responseType())
153+
assertEquals("1234", result.data?.scalarAlias)
154+
assertEquals(expectedUUID, result.data?.customScalar?.value)
155+
}
156+
157+
@Test
158+
fun `verify we can deserialize unknown enums`() {
159+
val unknownResponse =
160+
"""{
161+
| "data": { "enumResult": "INVALID" }
162+
|}
163+
""".trimMargin()
164+
165+
val serializer = GraphQLClientJacksonSerializer()
166+
val result = serializer.deserialize(unknownResponse, EnumQuery().responseType())
167+
assertEquals(EnumQuery.TestEnum.__UNKNOWN, result.data?.enumResult)
168+
}
169+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2021 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.client.jackson.data
18+
19+
import com.expediagroup.graphql.client.types.GraphQLClientRequest
20+
import com.fasterxml.jackson.annotation.JsonEnumDefaultValue
21+
import kotlin.reflect.KClass
22+
23+
class EnumQuery : GraphQLClientRequest<EnumQuery.Result> {
24+
override val query: String = "ENUM_QUERY"
25+
26+
override val operationName: String = "EnumQuery"
27+
28+
override fun responseType(): KClass<Result> = Result::class
29+
30+
enum class TestEnum {
31+
ONE,
32+
TWO,
33+
@JsonEnumDefaultValue
34+
__UNKNOWN
35+
}
36+
37+
data class Result(
38+
val enumResult: TestEnum = TestEnum.__UNKNOWN
39+
)
40+
}

0 commit comments

Comments
 (0)