Skip to content

Commit 0288c92

Browse files
author
Dariusz Kuc
authored
[generator] add IDValueUnboxer for correctly serializing ID value class (#1385)
Since `ID` is a value class, it may be represented at runtime as a wrapper or directly as underlying type. Due to the generic nature of the query processing logic we *always* end up with up a wrapper type when resolving the field value. As a result, in order to ensure that underlying scalar value is correctly serialized, we need to explicitly unwrap it by registering `IDValueUnboxer` with your GraphQL instance. ```kotlin // registering custom value unboxer val graphQL = GraphQL.newGraphQL(graphQLSchema) .valueUnboxer(IDValueUnboxer()) .build() ``` `IDValueUnboxer` is automatically registered by `graphql-kotlin-spring-server`.
1 parent 5affacd commit 0288c92

File tree

16 files changed

+289
-63
lines changed

16 files changed

+289
-63
lines changed

examples/client/server/src/main/kotlin/com/expediagroup/graphql/examples/client/server/scalars/ULocaleCoercing.kt

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616

1717
package com.expediagroup.graphql.examples.client.server.scalars
1818

19+
import com.ibm.icu.util.ULocale
1920
import graphql.language.StringValue
2021
import graphql.schema.Coercing
2122
import graphql.schema.CoercingParseLiteralException
2223
import graphql.schema.CoercingParseValueException
24+
import graphql.schema.CoercingSerializeException
2325
import graphql.schema.GraphQLScalarType
2426

2527
internal val graphqlULocaleType = GraphQLScalarType.newScalar()
@@ -28,12 +30,25 @@ internal val graphqlULocaleType = GraphQLScalarType.newScalar()
2830
.coercing(ULocaleCoercing)
2931
.build()
3032

31-
// We coerce between <String, String> because jackson will
32-
// take care of ser/deser for us within SchemaGenerator
33-
private object ULocaleCoercing : Coercing<String, String> {
34-
override fun parseValue(input: Any): String = input as? String ?: throw CoercingParseValueException("$input can not be cast to String")
33+
private object ULocaleCoercing : Coercing<ULocale, String> {
34+
override fun parseValue(input: Any): ULocale = runCatching {
35+
ULocale(serialize(input))
36+
}.getOrElse {
37+
throw CoercingParseValueException("Expected valid ULocale but was $input")
38+
}
3539

36-
override fun parseLiteral(input: Any): String = (input as? StringValue)?.value ?: throw CoercingParseLiteralException("$input can not be cast to StringValue")
40+
override fun parseLiteral(input: Any): ULocale {
41+
val locale = (input as? StringValue)?.value
42+
return runCatching {
43+
ULocale(locale)
44+
}.getOrElse {
45+
throw CoercingParseLiteralException("Expected valid ULocale literal but was $locale")
46+
}
47+
}
3748

38-
override fun serialize(dataFetcherResult: Any): String = dataFetcherResult.toString()
49+
override fun serialize(dataFetcherResult: Any): String = runCatching {
50+
dataFetcherResult.toString()
51+
}.getOrElse {
52+
throw CoercingSerializeException("Data fetcher result $dataFetcherResult cannot be serialized to a String")
53+
}
3954
}

examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/ktorGraphQLSchema.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import com.expediagroup.graphql.examples.server.ktor.schema.LoginMutationService
2323
import com.expediagroup.graphql.examples.server.ktor.schema.UniversityQueryService
2424
import com.expediagroup.graphql.generator.SchemaGeneratorConfig
2525
import com.expediagroup.graphql.generator.TopLevelObject
26+
import com.expediagroup.graphql.generator.scalars.IDValueUnboxer
2627
import com.expediagroup.graphql.generator.toSchema
2728
import graphql.GraphQL
2829

@@ -41,4 +42,6 @@ private val queries = listOf(
4142
private val mutations = listOf(TopLevelObject(LoginMutationService()))
4243
val graphQLSchema = toSchema(config, queries, mutations)
4344

44-
fun getGraphQLObject(): GraphQL = GraphQL.newGraphQL(graphQLSchema).build()
45+
fun getGraphQLObject(): GraphQL = GraphQL.newGraphQL(graphQLSchema)
46+
.valueUnboxer(IDValueUnboxer())
47+
.build()
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.expediagroup.graphql.generator.scalars
2+
3+
import graphql.execution.ValueUnboxer
4+
5+
/**
6+
* ID is a value class which may be represented at runtime as wrapper or directly as underlying type.
7+
*
8+
* We need to explicitly unwrap it as due to the generic nature of the query processing logic we always end up
9+
* with up a wrapper type when resolving the field value.
10+
*/
11+
open class IDValueUnboxer : ValueUnboxer {
12+
override fun unbox(`object`: Any?): Any? = if (`object` is ID) {
13+
`object`.value
14+
} else {
15+
`object`
16+
}
17+
}

plugins/graphql-kotlin-gradle-plugin/build.gradle.kts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import java.time.LocalDate
2+
13
description = "Gradle Kotlin Gradle Plugin that can generate type-safe GraphQL Kotlin client and GraphQL schema in SDL format using reflections"
24

35
plugins {
@@ -96,6 +98,12 @@ tasks {
9698
}
9799
}
98100
test {
101+
// ensure we always run tests by setting new inputs
102+
//
103+
// tests are parameterized and run IT based on projects under src/integration directories
104+
// Gradle is unaware of this and does not run tests if no sources/inputs changed
105+
inputs.property("integration.date", LocalDate.now())
106+
99107
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).takeIf { it > 0 } ?: 1
100108
dependsOn(":resolveIntegrationTestDependencies")
101109

plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/queries/OptionalWrapper.kt

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.expediagroup.scalars.queries
22

3+
import com.expediagroup.graphql.generator.annotations.GraphQLValidObjectLocations
4+
import com.expediagroup.graphql.generator.execution.OptionalInput
35
import com.expediagroup.graphql.generator.scalars.ID
46
import com.ibm.icu.util.ULocale
57
import java.util.UUID
@@ -8,10 +10,43 @@ const val UNDEFINED_BOOLEAN = false
810
const val UNDEFINED_DOUBLE = Double.MIN_VALUE
911
const val UNDEFINED_INT = Int.MIN_VALUE
1012
const val UNDEFINED_STRING = "undefined"
11-
val UNDEFINED_LOCALE = ULocale.US
12-
val UNDEFINED_OBJECT = Simple(foo = "bar")
13-
val UNDEFINED_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000")
13+
val UNDEFINED_LOCALE: ULocale = ULocale.US
14+
val UNDEFINED_OBJECT: Simple = Simple(foo = "bar")
15+
val UNDEFINED_UUID: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000")
1416

17+
@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.INPUT_OBJECT])
18+
data class OptionalWrapperInput(
19+
val optionalBoolean: OptionalInput<Boolean>? = OptionalInput.Defined(UNDEFINED_BOOLEAN),
20+
val optionalDouble: OptionalInput<Double>? = OptionalInput.Defined(UNDEFINED_DOUBLE),
21+
val optionalId: OptionalInput<ID>? = OptionalInput.Defined(ID(UNDEFINED_STRING)),
22+
val optionalInt: OptionalInput<Int>? = OptionalInput.Defined(UNDEFINED_INT),
23+
val optionalIntList: OptionalInput<List<Int>>? = OptionalInput.Defined(emptyList()),
24+
val optionalObject: OptionalInput<Simple>? = OptionalInput.Defined(UNDEFINED_OBJECT),
25+
val optionalString: OptionalInput<String>? = OptionalInput.Defined(UNDEFINED_STRING),
26+
val optionalULocale: OptionalInput<ULocale>? = OptionalInput.Defined(UNDEFINED_LOCALE),
27+
val optionalUUID: OptionalInput<UUID>? = OptionalInput.Defined(UNDEFINED_UUID),
28+
val optionalUUIDList: OptionalInput<List<UUID>>? = OptionalInput.Defined(emptyList())
29+
) {
30+
fun toOptionalWrapper(): OptionalWrapper = OptionalWrapper(
31+
optionalBoolean = optionalBoolean?.valueOrNull(UNDEFINED_BOOLEAN),
32+
optionalDouble = optionalDouble?.valueOrNull(UNDEFINED_DOUBLE),
33+
optionalId = optionalId?.valueOrNull(ID(UNDEFINED_STRING)),
34+
optionalInt = optionalInt?.valueOrNull(UNDEFINED_INT),
35+
optionalIntList = optionalIntList?.valueOrNull(emptyList()),
36+
optionalObject = optionalObject?.valueOrNull(UNDEFINED_OBJECT),
37+
optionalString = optionalString?.valueOrNull(UNDEFINED_STRING),
38+
optionalULocale = optionalULocale?.valueOrNull(UNDEFINED_LOCALE),
39+
optionalUUID = optionalUUID?.valueOrNull(UNDEFINED_UUID),
40+
optionalUUIDList = optionalUUIDList?.valueOrNull(emptyList())
41+
)
42+
43+
private inline fun <reified T> OptionalInput<T>.valueOrNull(default: T): T? = when(this) {
44+
is OptionalInput.Defined -> this.value
45+
else -> default
46+
}
47+
}
48+
49+
@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT])
1550
data class OptionalWrapper(
1651
val optionalBoolean: Boolean? = UNDEFINED_BOOLEAN,
1752
val optionalDouble: Double? = UNDEFINED_DOUBLE,

plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/queries/ScalarQuery.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ class ScalarQuery : Query {
2323
}
2424
}
2525

26-
fun optionalScalarQuery(optional: OptionalWrapper? = null): OptionalWrapper? {
26+
fun optionalScalarQuery(optional: OptionalInput<OptionalWrapperInput> = OptionalInput.Undefined): OptionalWrapper? {
2727
logger.info("optional query received: $optional")
28-
return optional
28+
return when (optional) {
29+
is OptionalInput.Defined -> optional.value?.toOptionalWrapper()
30+
is OptionalInput.Undefined -> OptionalWrapper()
31+
}
2932
}
3033

3134
fun scalarQuery(required: RequiredWrapper): RequiredWrapper {

plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/types/graphqlLocaleType.kt

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,30 @@ import graphql.schema.CoercingParseLiteralException
77
import graphql.schema.CoercingParseValueException
88
import graphql.schema.CoercingSerializeException
99
import graphql.schema.GraphQLScalarType
10-
import java.util.UUID
1110

12-
// We coerce between <String, String> due to a secondary deserialization from Jackson
13-
// see: https://github.com/ExpediaGroup/graphql-kotlin/issues/1220
1411
val graphqlLocaleType: GraphQLScalarType = GraphQLScalarType.newScalar()
1512
.name("Locale")
1613
.description("A type representing a Locale such as en_US or fr_FR")
17-
.coercing(object : Coercing<String, String> {
18-
override fun parseValue(input: Any): String = input.toString()
14+
.coercing(object : Coercing<ULocale, String> {
15+
override fun parseValue(input: Any): ULocale = runCatching {
16+
ULocale(serialize(input))
17+
}.getOrElse {
18+
throw CoercingParseValueException("Expected valid ULocale but was $input")
19+
}
1920

20-
override fun parseLiteral(input: Any): String {
21+
override fun parseLiteral(input: Any): ULocale {
2122
val locale = (input as? StringValue)?.value
22-
return locale ?: throw CoercingParseLiteralException("Expected valid Locale literal but was $locale")
23+
return runCatching {
24+
ULocale(locale)
25+
}.getOrElse {
26+
throw CoercingParseLiteralException("Expected valid ULocale literal but was $locale")
27+
}
2328
}
2429

25-
override fun serialize(dataFetcherResult: Any): String = dataFetcherResult.toString()
30+
override fun serialize(dataFetcherResult: Any): String = runCatching {
31+
dataFetcherResult.toString()
32+
}.getOrElse {
33+
throw CoercingSerializeException("Data fetcher result $dataFetcherResult cannot be serialized to a String")
34+
}
2635
})
2736
.build()

plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/test/kotlin/com/expediagroup/scalars/CustomScalarApplicationTests.kt

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,22 @@ class CustomScalarApplicationTests(@LocalServerPort private val port: Int) {
4646
}
4747

4848
@Test
49-
fun `verify optionals are correctly serialized and deserialized`() = runBlocking {
49+
fun `verify undefined optionals are correctly serialized and deserialized`() = runBlocking {
5050
val client = GraphQLWebClient(url = "http://localhost:$port/graphql")
5151

5252
val undefinedWrapperQuery = OptionalScalarQuery(variables = OptionalScalarQuery.Variables())
53-
val undefinedWrapperResult = client.execute(undefinedWrapperQuery)
54-
assertNull(undefinedWrapperResult.data?.optionalScalarQuery)
55-
56-
val nullWrapperQuery = OptionalScalarQuery(variables = OptionalScalarQuery.Variables(optional = OptionalInput.Defined(null)))
57-
val nullWrapperResult = client.execute(nullWrapperQuery)
58-
assertNull(nullWrapperResult.data?.optionalScalarQuery)
53+
val undefinedWrapperResult = client.execute(undefinedWrapperQuery).data?.optionalScalarQuery
54+
assertNotNull(undefinedWrapperResult)
55+
assertEquals(UNDEFINED_BOOLEAN, undefinedWrapperResult.optionalBoolean)
56+
assertEquals(UNDEFINED_DOUBLE, undefinedWrapperResult.optionalDouble)
57+
assertEquals(UNDEFINED_STRING, undefinedWrapperResult.optionalId)
58+
assertEquals(UNDEFINED_INT, undefinedWrapperResult.optionalInt)
59+
assertEquals(0, undefinedWrapperResult.optionalIntList?.size)
60+
assertEquals(UNDEFINED_OBJECT.foo, undefinedWrapperResult.optionalObject?.foo)
61+
assertEquals(UNDEFINED_STRING, undefinedWrapperResult.optionalString)
62+
assertEquals(UNDEFINED_LOCALE, undefinedWrapperResult.optionalULocale)
63+
assertEquals(UNDEFINED_UUID, undefinedWrapperResult.optionalUUID)
64+
assertEquals(0, undefinedWrapperResult.optionalUUIDList?.size)
5965

6066
val defaultWrapper = OptionalWrapperInput()
6167
val defaultWrapperQuery = OptionalScalarQuery(variables = OptionalScalarQuery.Variables(
@@ -73,6 +79,15 @@ class CustomScalarApplicationTests(@LocalServerPort private val port: Int) {
7379
assertEquals(UNDEFINED_LOCALE, defaultResult.optionalULocale)
7480
assertEquals(UNDEFINED_UUID, defaultResult.optionalUUID)
7581
assertEquals(0, defaultResult.optionalUUIDList?.size)
82+
}
83+
84+
@Test
85+
fun `verify null optionals are correctly serialized and deserialized`() = runBlocking {
86+
val client = GraphQLWebClient(url = "http://localhost:$port/graphql")
87+
88+
val nullWrapperQuery = OptionalScalarQuery(variables = OptionalScalarQuery.Variables(optional = OptionalInput.Defined(null)))
89+
val nullWrapperResult = client.execute(nullWrapperQuery)
90+
assertNull(nullWrapperResult.data?.optionalScalarQuery)
7691

7792
val nullWrapper = OptionalWrapperInput(
7893
optionalBoolean = OptionalInput.Defined(null),
@@ -101,6 +116,11 @@ class CustomScalarApplicationTests(@LocalServerPort private val port: Int) {
101116
assertNull(nullResult.optionalULocale)
102117
assertNull(nullResult.optionalUUID)
103118
assertNull(nullResult.optionalUUIDList)
119+
}
120+
121+
@Test
122+
fun `verify defined optionals are correctly serialized and deserialized`() = runBlocking {
123+
val client = GraphQLWebClient(url = "http://localhost:$port/graphql")
104124

105125
val randomUUID = UUID.randomUUID()
106126
val wrapper = OptionalWrapperInput(
@@ -171,5 +191,4 @@ class CustomScalarApplicationTests(@LocalServerPort private val port: Int) {
171191
assertEquals(wrapper.requiredUUIDList.size, result.requiredUUIDList.size)
172192
assertEquals(wrapper.requiredUUIDList[0], result.requiredUUIDList[0])
173193
}
174-
175194
}

plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/KtorGraphQLServer.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.expediagroup.scalars
33
import com.expediagroup.graphql.generator.SchemaGeneratorConfig
44
import com.expediagroup.graphql.generator.TopLevelObject
55
import com.expediagroup.graphql.generator.execution.GraphQLContext
6+
import com.expediagroup.graphql.generator.scalars.ID
7+
import com.expediagroup.graphql.generator.scalars.IDValueUnboxer
68
import com.expediagroup.graphql.generator.toSchema
79
import com.expediagroup.graphql.server.execution.GraphQLContextFactory
810
import com.expediagroup.graphql.server.execution.GraphQLRequestHandler
@@ -12,6 +14,8 @@ import com.expediagroup.graphql.server.types.GraphQLServerRequest
1214
import com.expediagroup.scalars.queries.ScalarQuery
1315
import com.fasterxml.jackson.databind.ObjectMapper
1416
import graphql.GraphQL
17+
import graphql.execution.DefaultValueUnboxer
18+
import graphql.execution.ValueUnboxer
1519
import io.ktor.request.ApplicationRequest
1620
import io.ktor.request.receiveText
1721
import java.io.IOException
@@ -41,7 +45,9 @@ class KtorGraphQLServer(
4145
val graphQLSchema = toSchema(config, listOf(
4246
TopLevelObject(ScalarQuery())
4347
))
44-
val graphQL: GraphQL = GraphQL.newGraphQL(graphQLSchema).build()
48+
val graphQL: GraphQL = GraphQL.newGraphQL(graphQLSchema)
49+
.valueUnboxer(IDValueUnboxer())
50+
.build()
4551
val requestHandler = GraphQLRequestHandler(graphQL)
4652

4753
return KtorGraphQLServer(requestParser, contextFactory, requestHandler)

plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/queries/OptionalWrapper.kt

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.expediagroup.scalars.queries
22

3+
import com.expediagroup.graphql.generator.annotations.GraphQLValidObjectLocations
4+
import com.expediagroup.graphql.generator.execution.OptionalInput
35
import com.expediagroup.graphql.generator.scalars.ID
46
import com.ibm.icu.util.ULocale
57
import java.util.UUID
@@ -8,10 +10,43 @@ const val UNDEFINED_BOOLEAN = false
810
const val UNDEFINED_DOUBLE = Double.MIN_VALUE
911
const val UNDEFINED_INT = Int.MIN_VALUE
1012
const val UNDEFINED_STRING = "undefined"
11-
val UNDEFINED_LOCALE = ULocale.US
12-
val UNDEFINED_OBJECT = Simple(foo = "bar")
13-
val UNDEFINED_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000")
13+
val UNDEFINED_LOCALE: ULocale = ULocale.US
14+
val UNDEFINED_OBJECT: Simple = Simple(foo = "bar")
15+
val UNDEFINED_UUID: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000")
1416

17+
@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.INPUT_OBJECT])
18+
data class OptionalWrapperInput(
19+
val optionalBoolean: OptionalInput<Boolean>? = OptionalInput.Defined(UNDEFINED_BOOLEAN),
20+
val optionalDouble: OptionalInput<Double>? = OptionalInput.Defined(UNDEFINED_DOUBLE),
21+
val optionalId: OptionalInput<ID>? = OptionalInput.Defined(ID(UNDEFINED_STRING)),
22+
val optionalInt: OptionalInput<Int>? = OptionalInput.Defined(UNDEFINED_INT),
23+
val optionalIntList: OptionalInput<List<Int>>? = OptionalInput.Defined(emptyList()),
24+
val optionalObject: OptionalInput<Simple>? = OptionalInput.Defined(UNDEFINED_OBJECT),
25+
val optionalString: OptionalInput<String>? = OptionalInput.Defined(UNDEFINED_STRING),
26+
val optionalULocale: OptionalInput<ULocale>? = OptionalInput.Defined(UNDEFINED_LOCALE),
27+
val optionalUUID: OptionalInput<UUID>? = OptionalInput.Defined(UNDEFINED_UUID),
28+
val optionalUUIDList: OptionalInput<List<UUID>>? = OptionalInput.Defined(emptyList())
29+
) {
30+
fun toOptionalWrapper(): OptionalWrapper = OptionalWrapper(
31+
optionalBoolean = optionalBoolean?.valueOrNull(UNDEFINED_BOOLEAN),
32+
optionalDouble = optionalDouble?.valueOrNull(UNDEFINED_DOUBLE),
33+
optionalId = optionalId?.valueOrNull(ID(UNDEFINED_STRING)),
34+
optionalInt = optionalInt?.valueOrNull(UNDEFINED_INT),
35+
optionalIntList = optionalIntList?.valueOrNull(emptyList()),
36+
optionalObject = optionalObject?.valueOrNull(UNDEFINED_OBJECT),
37+
optionalString = optionalString?.valueOrNull(UNDEFINED_STRING),
38+
optionalULocale = optionalULocale?.valueOrNull(UNDEFINED_LOCALE),
39+
optionalUUID = optionalUUID?.valueOrNull(UNDEFINED_UUID),
40+
optionalUUIDList = optionalUUIDList?.valueOrNull(emptyList())
41+
)
42+
43+
private inline fun <reified T> OptionalInput<T>.valueOrNull(default: T): T? = when(this) {
44+
is OptionalInput.Defined -> this.value
45+
else -> default
46+
}
47+
}
48+
49+
@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT])
1550
data class OptionalWrapper(
1651
val optionalBoolean: Boolean? = UNDEFINED_BOOLEAN,
1752
val optionalDouble: Double? = UNDEFINED_DOUBLE,

0 commit comments

Comments
 (0)