Skip to content

Commit

Permalink
[generator] add IDValueUnboxer for correctly serializing ID value cla…
Browse files Browse the repository at this point in the history
…ss (#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`.
  • Loading branch information
Dariusz Kuc authored Mar 9, 2022
1 parent 5affacd commit 0288c92
Show file tree
Hide file tree
Showing 16 changed files with 289 additions and 63 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@

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

import com.ibm.icu.util.ULocale
import graphql.language.StringValue
import graphql.schema.Coercing
import graphql.schema.CoercingParseLiteralException
import graphql.schema.CoercingParseValueException
import graphql.schema.CoercingSerializeException
import graphql.schema.GraphQLScalarType

internal val graphqlULocaleType = GraphQLScalarType.newScalar()
Expand All @@ -28,12 +30,25 @@ internal val graphqlULocaleType = GraphQLScalarType.newScalar()
.coercing(ULocaleCoercing)
.build()

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

override fun parseLiteral(input: Any): String = (input as? StringValue)?.value ?: throw CoercingParseLiteralException("$input can not be cast to StringValue")
override fun parseLiteral(input: Any): ULocale {
val locale = (input as? StringValue)?.value
return runCatching {
ULocale(locale)
}.getOrElse {
throw CoercingParseLiteralException("Expected valid ULocale literal but was $locale")
}
}

override fun serialize(dataFetcherResult: Any): String = dataFetcherResult.toString()
override fun serialize(dataFetcherResult: Any): String = runCatching {
dataFetcherResult.toString()
}.getOrElse {
throw CoercingSerializeException("Data fetcher result $dataFetcherResult cannot be serialized to a String")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.expediagroup.graphql.examples.server.ktor.schema.LoginMutationService
import com.expediagroup.graphql.examples.server.ktor.schema.UniversityQueryService
import com.expediagroup.graphql.generator.SchemaGeneratorConfig
import com.expediagroup.graphql.generator.TopLevelObject
import com.expediagroup.graphql.generator.scalars.IDValueUnboxer
import com.expediagroup.graphql.generator.toSchema
import graphql.GraphQL

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

fun getGraphQLObject(): GraphQL = GraphQL.newGraphQL(graphQLSchema).build()
fun getGraphQLObject(): GraphQL = GraphQL.newGraphQL(graphQLSchema)
.valueUnboxer(IDValueUnboxer())
.build()
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.expediagroup.graphql.generator.scalars

import graphql.execution.ValueUnboxer

/**
* ID is a value class which may be represented at runtime as wrapper or directly as underlying type.
*
* We need to explicitly unwrap it as due to the generic nature of the query processing logic we always end up
* with up a wrapper type when resolving the field value.
*/
open class IDValueUnboxer : ValueUnboxer {
override fun unbox(`object`: Any?): Any? = if (`object` is ID) {
`object`.value
} else {
`object`
}
}
8 changes: 8 additions & 0 deletions plugins/graphql-kotlin-gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import java.time.LocalDate

description = "Gradle Kotlin Gradle Plugin that can generate type-safe GraphQL Kotlin client and GraphQL schema in SDL format using reflections"

plugins {
Expand Down Expand Up @@ -96,6 +98,12 @@ tasks {
}
}
test {
// ensure we always run tests by setting new inputs
//
// tests are parameterized and run IT based on projects under src/integration directories
// Gradle is unaware of this and does not run tests if no sources/inputs changed
inputs.property("integration.date", LocalDate.now())

maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).takeIf { it > 0 } ?: 1
dependsOn(":resolveIntegrationTestDependencies")

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.expediagroup.scalars.queries

import com.expediagroup.graphql.generator.annotations.GraphQLValidObjectLocations
import com.expediagroup.graphql.generator.execution.OptionalInput
import com.expediagroup.graphql.generator.scalars.ID
import com.ibm.icu.util.ULocale
import java.util.UUID
Expand All @@ -8,10 +10,43 @@ const val UNDEFINED_BOOLEAN = false
const val UNDEFINED_DOUBLE = Double.MIN_VALUE
const val UNDEFINED_INT = Int.MIN_VALUE
const val UNDEFINED_STRING = "undefined"
val UNDEFINED_LOCALE = ULocale.US
val UNDEFINED_OBJECT = Simple(foo = "bar")
val UNDEFINED_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000")
val UNDEFINED_LOCALE: ULocale = ULocale.US
val UNDEFINED_OBJECT: Simple = Simple(foo = "bar")
val UNDEFINED_UUID: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000")

@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.INPUT_OBJECT])
data class OptionalWrapperInput(
val optionalBoolean: OptionalInput<Boolean>? = OptionalInput.Defined(UNDEFINED_BOOLEAN),
val optionalDouble: OptionalInput<Double>? = OptionalInput.Defined(UNDEFINED_DOUBLE),
val optionalId: OptionalInput<ID>? = OptionalInput.Defined(ID(UNDEFINED_STRING)),
val optionalInt: OptionalInput<Int>? = OptionalInput.Defined(UNDEFINED_INT),
val optionalIntList: OptionalInput<List<Int>>? = OptionalInput.Defined(emptyList()),
val optionalObject: OptionalInput<Simple>? = OptionalInput.Defined(UNDEFINED_OBJECT),
val optionalString: OptionalInput<String>? = OptionalInput.Defined(UNDEFINED_STRING),
val optionalULocale: OptionalInput<ULocale>? = OptionalInput.Defined(UNDEFINED_LOCALE),
val optionalUUID: OptionalInput<UUID>? = OptionalInput.Defined(UNDEFINED_UUID),
val optionalUUIDList: OptionalInput<List<UUID>>? = OptionalInput.Defined(emptyList())
) {
fun toOptionalWrapper(): OptionalWrapper = OptionalWrapper(
optionalBoolean = optionalBoolean?.valueOrNull(UNDEFINED_BOOLEAN),
optionalDouble = optionalDouble?.valueOrNull(UNDEFINED_DOUBLE),
optionalId = optionalId?.valueOrNull(ID(UNDEFINED_STRING)),
optionalInt = optionalInt?.valueOrNull(UNDEFINED_INT),
optionalIntList = optionalIntList?.valueOrNull(emptyList()),
optionalObject = optionalObject?.valueOrNull(UNDEFINED_OBJECT),
optionalString = optionalString?.valueOrNull(UNDEFINED_STRING),
optionalULocale = optionalULocale?.valueOrNull(UNDEFINED_LOCALE),
optionalUUID = optionalUUID?.valueOrNull(UNDEFINED_UUID),
optionalUUIDList = optionalUUIDList?.valueOrNull(emptyList())
)

private inline fun <reified T> OptionalInput<T>.valueOrNull(default: T): T? = when(this) {
is OptionalInput.Defined -> this.value
else -> default
}
}

@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT])
data class OptionalWrapper(
val optionalBoolean: Boolean? = UNDEFINED_BOOLEAN,
val optionalDouble: Double? = UNDEFINED_DOUBLE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ class ScalarQuery : Query {
}
}

fun optionalScalarQuery(optional: OptionalWrapper? = null): OptionalWrapper? {
fun optionalScalarQuery(optional: OptionalInput<OptionalWrapperInput> = OptionalInput.Undefined): OptionalWrapper? {
logger.info("optional query received: $optional")
return optional
return when (optional) {
is OptionalInput.Defined -> optional.value?.toOptionalWrapper()
is OptionalInput.Undefined -> OptionalWrapper()
}
}

fun scalarQuery(required: RequiredWrapper): RequiredWrapper {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,30 @@ import graphql.schema.CoercingParseLiteralException
import graphql.schema.CoercingParseValueException
import graphql.schema.CoercingSerializeException
import graphql.schema.GraphQLScalarType
import java.util.UUID

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

override fun parseLiteral(input: Any): String {
override fun parseLiteral(input: Any): ULocale {
val locale = (input as? StringValue)?.value
return locale ?: throw CoercingParseLiteralException("Expected valid Locale literal but was $locale")
return runCatching {
ULocale(locale)
}.getOrElse {
throw CoercingParseLiteralException("Expected valid ULocale literal but was $locale")
}
}

override fun serialize(dataFetcherResult: Any): String = dataFetcherResult.toString()
override fun serialize(dataFetcherResult: Any): String = runCatching {
dataFetcherResult.toString()
}.getOrElse {
throw CoercingSerializeException("Data fetcher result $dataFetcherResult cannot be serialized to a String")
}
})
.build()
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,22 @@ class CustomScalarApplicationTests(@LocalServerPort private val port: Int) {
}

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

val undefinedWrapperQuery = OptionalScalarQuery(variables = OptionalScalarQuery.Variables())
val undefinedWrapperResult = client.execute(undefinedWrapperQuery)
assertNull(undefinedWrapperResult.data?.optionalScalarQuery)

val nullWrapperQuery = OptionalScalarQuery(variables = OptionalScalarQuery.Variables(optional = OptionalInput.Defined(null)))
val nullWrapperResult = client.execute(nullWrapperQuery)
assertNull(nullWrapperResult.data?.optionalScalarQuery)
val undefinedWrapperResult = client.execute(undefinedWrapperQuery).data?.optionalScalarQuery
assertNotNull(undefinedWrapperResult)
assertEquals(UNDEFINED_BOOLEAN, undefinedWrapperResult.optionalBoolean)
assertEquals(UNDEFINED_DOUBLE, undefinedWrapperResult.optionalDouble)
assertEquals(UNDEFINED_STRING, undefinedWrapperResult.optionalId)
assertEquals(UNDEFINED_INT, undefinedWrapperResult.optionalInt)
assertEquals(0, undefinedWrapperResult.optionalIntList?.size)
assertEquals(UNDEFINED_OBJECT.foo, undefinedWrapperResult.optionalObject?.foo)
assertEquals(UNDEFINED_STRING, undefinedWrapperResult.optionalString)
assertEquals(UNDEFINED_LOCALE, undefinedWrapperResult.optionalULocale)
assertEquals(UNDEFINED_UUID, undefinedWrapperResult.optionalUUID)
assertEquals(0, undefinedWrapperResult.optionalUUIDList?.size)

val defaultWrapper = OptionalWrapperInput()
val defaultWrapperQuery = OptionalScalarQuery(variables = OptionalScalarQuery.Variables(
Expand All @@ -73,6 +79,15 @@ class CustomScalarApplicationTests(@LocalServerPort private val port: Int) {
assertEquals(UNDEFINED_LOCALE, defaultResult.optionalULocale)
assertEquals(UNDEFINED_UUID, defaultResult.optionalUUID)
assertEquals(0, defaultResult.optionalUUIDList?.size)
}

@Test
fun `verify null optionals are correctly serialized and deserialized`() = runBlocking {
val client = GraphQLWebClient(url = "http://localhost:$port/graphql")

val nullWrapperQuery = OptionalScalarQuery(variables = OptionalScalarQuery.Variables(optional = OptionalInput.Defined(null)))
val nullWrapperResult = client.execute(nullWrapperQuery)
assertNull(nullWrapperResult.data?.optionalScalarQuery)

val nullWrapper = OptionalWrapperInput(
optionalBoolean = OptionalInput.Defined(null),
Expand Down Expand Up @@ -101,6 +116,11 @@ class CustomScalarApplicationTests(@LocalServerPort private val port: Int) {
assertNull(nullResult.optionalULocale)
assertNull(nullResult.optionalUUID)
assertNull(nullResult.optionalUUIDList)
}

@Test
fun `verify defined optionals are correctly serialized and deserialized`() = runBlocking {
val client = GraphQLWebClient(url = "http://localhost:$port/graphql")

val randomUUID = UUID.randomUUID()
val wrapper = OptionalWrapperInput(
Expand Down Expand Up @@ -171,5 +191,4 @@ class CustomScalarApplicationTests(@LocalServerPort private val port: Int) {
assertEquals(wrapper.requiredUUIDList.size, result.requiredUUIDList.size)
assertEquals(wrapper.requiredUUIDList[0], result.requiredUUIDList[0])
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.expediagroup.scalars
import com.expediagroup.graphql.generator.SchemaGeneratorConfig
import com.expediagroup.graphql.generator.TopLevelObject
import com.expediagroup.graphql.generator.execution.GraphQLContext
import com.expediagroup.graphql.generator.scalars.ID
import com.expediagroup.graphql.generator.scalars.IDValueUnboxer
import com.expediagroup.graphql.generator.toSchema
import com.expediagroup.graphql.server.execution.GraphQLContextFactory
import com.expediagroup.graphql.server.execution.GraphQLRequestHandler
Expand All @@ -12,6 +14,8 @@ import com.expediagroup.graphql.server.types.GraphQLServerRequest
import com.expediagroup.scalars.queries.ScalarQuery
import com.fasterxml.jackson.databind.ObjectMapper
import graphql.GraphQL
import graphql.execution.DefaultValueUnboxer
import graphql.execution.ValueUnboxer
import io.ktor.request.ApplicationRequest
import io.ktor.request.receiveText
import java.io.IOException
Expand Down Expand Up @@ -41,7 +45,9 @@ class KtorGraphQLServer(
val graphQLSchema = toSchema(config, listOf(
TopLevelObject(ScalarQuery())
))
val graphQL: GraphQL = GraphQL.newGraphQL(graphQLSchema).build()
val graphQL: GraphQL = GraphQL.newGraphQL(graphQLSchema)
.valueUnboxer(IDValueUnboxer())
.build()
val requestHandler = GraphQLRequestHandler(graphQL)

return KtorGraphQLServer(requestParser, contextFactory, requestHandler)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.expediagroup.scalars.queries

import com.expediagroup.graphql.generator.annotations.GraphQLValidObjectLocations
import com.expediagroup.graphql.generator.execution.OptionalInput
import com.expediagroup.graphql.generator.scalars.ID
import com.ibm.icu.util.ULocale
import java.util.UUID
Expand All @@ -8,10 +10,43 @@ const val UNDEFINED_BOOLEAN = false
const val UNDEFINED_DOUBLE = Double.MIN_VALUE
const val UNDEFINED_INT = Int.MIN_VALUE
const val UNDEFINED_STRING = "undefined"
val UNDEFINED_LOCALE = ULocale.US
val UNDEFINED_OBJECT = Simple(foo = "bar")
val UNDEFINED_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000")
val UNDEFINED_LOCALE: ULocale = ULocale.US
val UNDEFINED_OBJECT: Simple = Simple(foo = "bar")
val UNDEFINED_UUID: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000")

@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.INPUT_OBJECT])
data class OptionalWrapperInput(
val optionalBoolean: OptionalInput<Boolean>? = OptionalInput.Defined(UNDEFINED_BOOLEAN),
val optionalDouble: OptionalInput<Double>? = OptionalInput.Defined(UNDEFINED_DOUBLE),
val optionalId: OptionalInput<ID>? = OptionalInput.Defined(ID(UNDEFINED_STRING)),
val optionalInt: OptionalInput<Int>? = OptionalInput.Defined(UNDEFINED_INT),
val optionalIntList: OptionalInput<List<Int>>? = OptionalInput.Defined(emptyList()),
val optionalObject: OptionalInput<Simple>? = OptionalInput.Defined(UNDEFINED_OBJECT),
val optionalString: OptionalInput<String>? = OptionalInput.Defined(UNDEFINED_STRING),
val optionalULocale: OptionalInput<ULocale>? = OptionalInput.Defined(UNDEFINED_LOCALE),
val optionalUUID: OptionalInput<UUID>? = OptionalInput.Defined(UNDEFINED_UUID),
val optionalUUIDList: OptionalInput<List<UUID>>? = OptionalInput.Defined(emptyList())
) {
fun toOptionalWrapper(): OptionalWrapper = OptionalWrapper(
optionalBoolean = optionalBoolean?.valueOrNull(UNDEFINED_BOOLEAN),
optionalDouble = optionalDouble?.valueOrNull(UNDEFINED_DOUBLE),
optionalId = optionalId?.valueOrNull(ID(UNDEFINED_STRING)),
optionalInt = optionalInt?.valueOrNull(UNDEFINED_INT),
optionalIntList = optionalIntList?.valueOrNull(emptyList()),
optionalObject = optionalObject?.valueOrNull(UNDEFINED_OBJECT),
optionalString = optionalString?.valueOrNull(UNDEFINED_STRING),
optionalULocale = optionalULocale?.valueOrNull(UNDEFINED_LOCALE),
optionalUUID = optionalUUID?.valueOrNull(UNDEFINED_UUID),
optionalUUIDList = optionalUUIDList?.valueOrNull(emptyList())
)

private inline fun <reified T> OptionalInput<T>.valueOrNull(default: T): T? = when(this) {
is OptionalInput.Defined -> this.value
else -> default
}
}

@GraphQLValidObjectLocations(locations = [GraphQLValidObjectLocations.Locations.OBJECT])
data class OptionalWrapper(
val optionalBoolean: Boolean? = UNDEFINED_BOOLEAN,
val optionalDouble: Double? = UNDEFINED_DOUBLE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ class ScalarQuery : Query {
}
}

fun optionalScalarQuery(optional: OptionalWrapper? = null): OptionalWrapper? {
fun optionalScalarQuery(optional: OptionalInput<OptionalWrapperInput> = OptionalInput.Undefined): OptionalWrapper? {
logger.info("optional query received: $optional")
return optional
return when (optional) {
is OptionalInput.Defined -> optional.value?.toOptionalWrapper()
is OptionalInput.Undefined -> OptionalWrapper()
}
}

fun scalarQuery(required: RequiredWrapper): RequiredWrapper {
logger.info("required query received: $required")
return required
}
}
}
Loading

0 comments on commit 0288c92

Please sign in to comment.