diff --git a/examples/client/server/src/main/kotlin/com/expediagroup/graphql/examples/client/server/scalars/ULocaleCoercing.kt b/examples/client/server/src/main/kotlin/com/expediagroup/graphql/examples/client/server/scalars/ULocaleCoercing.kt index 46e4501dff..dc14e019ec 100644 --- a/examples/client/server/src/main/kotlin/com/expediagroup/graphql/examples/client/server/scalars/ULocaleCoercing.kt +++ b/examples/client/server/src/main/kotlin/com/expediagroup/graphql/examples/client/server/scalars/ULocaleCoercing.kt @@ -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() @@ -28,12 +30,25 @@ internal val graphqlULocaleType = GraphQLScalarType.newScalar() .coercing(ULocaleCoercing) .build() -// We coerce between because jackson will -// take care of ser/deser for us within SchemaGenerator -private object ULocaleCoercing : Coercing { - override fun parseValue(input: Any): String = input as? String ?: throw CoercingParseValueException("$input can not be cast to String") +private object ULocaleCoercing : Coercing { + 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") + } } diff --git a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/ktorGraphQLSchema.kt b/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/ktorGraphQLSchema.kt index 5dcefe8059..7fe8cf8ec5 100644 --- a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/ktorGraphQLSchema.kt +++ b/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/ktorGraphQLSchema.kt @@ -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 @@ -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() diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/scalars/IDValueUnboxer.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/scalars/IDValueUnboxer.kt new file mode 100644 index 0000000000..d1dcff016b --- /dev/null +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/scalars/IDValueUnboxer.kt @@ -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` + } +} diff --git a/plugins/graphql-kotlin-gradle-plugin/build.gradle.kts b/plugins/graphql-kotlin-gradle-plugin/build.gradle.kts index 97a5d200eb..41a72fc726 100644 --- a/plugins/graphql-kotlin-gradle-plugin/build.gradle.kts +++ b/plugins/graphql-kotlin-gradle-plugin/build.gradle.kts @@ -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 { @@ -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") diff --git a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/queries/OptionalWrapper.kt b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/queries/OptionalWrapper.kt index 3a182382d2..ac8f1e2e7b 100644 --- a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/queries/OptionalWrapper.kt +++ b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/queries/OptionalWrapper.kt @@ -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 @@ -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? = OptionalInput.Defined(UNDEFINED_BOOLEAN), + val optionalDouble: OptionalInput? = OptionalInput.Defined(UNDEFINED_DOUBLE), + val optionalId: OptionalInput? = OptionalInput.Defined(ID(UNDEFINED_STRING)), + val optionalInt: OptionalInput? = OptionalInput.Defined(UNDEFINED_INT), + val optionalIntList: OptionalInput>? = OptionalInput.Defined(emptyList()), + val optionalObject: OptionalInput? = OptionalInput.Defined(UNDEFINED_OBJECT), + val optionalString: OptionalInput? = OptionalInput.Defined(UNDEFINED_STRING), + val optionalULocale: OptionalInput? = OptionalInput.Defined(UNDEFINED_LOCALE), + val optionalUUID: OptionalInput? = OptionalInput.Defined(UNDEFINED_UUID), + val optionalUUIDList: OptionalInput>? = 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 OptionalInput.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, diff --git a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/queries/ScalarQuery.kt b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/queries/ScalarQuery.kt index 84d8edd5a5..3ec16e4a8f 100644 --- a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/queries/ScalarQuery.kt +++ b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/queries/ScalarQuery.kt @@ -23,9 +23,12 @@ class ScalarQuery : Query { } } - fun optionalScalarQuery(optional: OptionalWrapper? = null): OptionalWrapper? { + fun optionalScalarQuery(optional: OptionalInput = 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 { diff --git a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/types/graphqlLocaleType.kt b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/types/graphqlLocaleType.kt index 1bf22d77dd..1d400c3545 100644 --- a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/types/graphqlLocaleType.kt +++ b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/main/kotlin/com/expediagroup/scalars/types/graphqlLocaleType.kt @@ -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 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 { - override fun parseValue(input: Any): String = input.toString() + .coercing(object : Coercing { + 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() diff --git a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/test/kotlin/com/expediagroup/scalars/CustomScalarApplicationTests.kt b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/test/kotlin/com/expediagroup/scalars/CustomScalarApplicationTests.kt index a4893392ef..291cd8200d 100644 --- a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/test/kotlin/com/expediagroup/scalars/CustomScalarApplicationTests.kt +++ b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_jackson/src/test/kotlin/com/expediagroup/scalars/CustomScalarApplicationTests.kt @@ -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( @@ -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), @@ -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( @@ -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]) } - } diff --git a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/KtorGraphQLServer.kt b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/KtorGraphQLServer.kt index 84fe1f53e3..b5d2ed473b 100644 --- a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/KtorGraphQLServer.kt +++ b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/KtorGraphQLServer.kt @@ -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 @@ -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 @@ -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) diff --git a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/queries/OptionalWrapper.kt b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/queries/OptionalWrapper.kt index 3a182382d2..ac8f1e2e7b 100644 --- a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/queries/OptionalWrapper.kt +++ b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/queries/OptionalWrapper.kt @@ -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 @@ -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? = OptionalInput.Defined(UNDEFINED_BOOLEAN), + val optionalDouble: OptionalInput? = OptionalInput.Defined(UNDEFINED_DOUBLE), + val optionalId: OptionalInput? = OptionalInput.Defined(ID(UNDEFINED_STRING)), + val optionalInt: OptionalInput? = OptionalInput.Defined(UNDEFINED_INT), + val optionalIntList: OptionalInput>? = OptionalInput.Defined(emptyList()), + val optionalObject: OptionalInput? = OptionalInput.Defined(UNDEFINED_OBJECT), + val optionalString: OptionalInput? = OptionalInput.Defined(UNDEFINED_STRING), + val optionalULocale: OptionalInput? = OptionalInput.Defined(UNDEFINED_LOCALE), + val optionalUUID: OptionalInput? = OptionalInput.Defined(UNDEFINED_UUID), + val optionalUUIDList: OptionalInput>? = 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 OptionalInput.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, diff --git a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/queries/ScalarQuery.kt b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/queries/ScalarQuery.kt index df565a8cc1..02941fad0a 100644 --- a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/queries/ScalarQuery.kt +++ b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/queries/ScalarQuery.kt @@ -21,13 +21,16 @@ class ScalarQuery : Query { } } - fun optionalScalarQuery(optional: OptionalWrapper? = null): OptionalWrapper? { + fun optionalScalarQuery(optional: OptionalInput = 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 } -} \ No newline at end of file +} diff --git a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/types/graphqlLocaleType.kt b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/types/graphqlLocaleType.kt index 7167a49be5..1d400c3545 100644 --- a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/types/graphqlLocaleType.kt +++ b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/main/kotlin/com/expediagroup/scalars/types/graphqlLocaleType.kt @@ -1,23 +1,36 @@ package com.expediagroup.scalars.types +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 -// We coerce between 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 { - override fun parseValue(input: Any): String = input.toString() + .coercing(object : Coercing { + 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() diff --git a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/test/kotlin/com/expediagroup/scalars/CustomScalarKotlinxTests.kt b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/test/kotlin/com/expediagroup/scalars/CustomScalarKotlinxTests.kt index 9f4105b084..b9129691a3 100644 --- a/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/test/kotlin/com/expediagroup/scalars/CustomScalarKotlinxTests.kt +++ b/plugins/graphql-kotlin-gradle-plugin/src/integration/client-generator/custom_scalars_kotlinx/src/test/kotlin/com/expediagroup/scalars/CustomScalarKotlinxTests.kt @@ -55,7 +55,7 @@ class CustomScalarKotlinxTests { } @Test - fun `verify optionals are correctly serialized and deserialized`() { + fun `verify undefined optionals are correctly serialized and deserialized`() { val engine = embeddedServer(CIO, port = 8080, module = Application::graphQLModule) try { engine.start() @@ -63,12 +63,18 @@ class CustomScalarKotlinxTests { val client = GraphQLKtorClient(url = URL("http://localhost:8080/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( @@ -86,6 +92,23 @@ class CustomScalarKotlinxTests { assertEquals(UNDEFINED_LOCALE, defaultResult.optionalULocale) assertEquals(UNDEFINED_UUID, defaultResult.optionalUUID) assertEquals(0, defaultResult.optionalUUIDList?.size) + } + } finally { + engine.stop(1000, 1000) + } + } + + @Test + fun `verify null optionals are correctly serialized and deserialized`() { + val engine = embeddedServer(CIO, port = 8080, module = Application::graphQLModule) + try { + engine.start() + runBlocking { + val client = GraphQLKtorClient(url = URL("http://localhost:8080/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), @@ -114,6 +137,19 @@ class CustomScalarKotlinxTests { assertNull(nullResult.optionalULocale) assertNull(nullResult.optionalUUID) assertNull(nullResult.optionalUUIDList) + } + } finally { + engine.stop(1000, 1000) + } + } + + @Test + fun `verify defined optionals are correctly serialized and deserialized`() { + val engine = embeddedServer(CIO, port = 8080, module = Application::graphQLModule) + try { + engine.start() + runBlocking { + val client = GraphQLKtorClient(url = URL("http://localhost:8080/graphql")) val randomUUID = UUID.randomUUID() val wrapper = OptionalWrapperInput( diff --git a/servers/graphql-kotlin-spring-server/src/main/kotlin/com/expediagroup/graphql/server/spring/GraphQLSchemaConfiguration.kt b/servers/graphql-kotlin-spring-server/src/main/kotlin/com/expediagroup/graphql/server/spring/GraphQLSchemaConfiguration.kt index 5f1e884529..b5623cf021 100644 --- a/servers/graphql-kotlin-spring-server/src/main/kotlin/com/expediagroup/graphql/server/spring/GraphQLSchemaConfiguration.kt +++ b/servers/graphql-kotlin-spring-server/src/main/kotlin/com/expediagroup/graphql/server/spring/GraphQLSchemaConfiguration.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Expedia, Inc + * Copyright 2022 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package com.expediagroup.graphql.server.spring import com.expediagroup.graphql.generator.execution.FlowSubscriptionExecutionStrategy +import com.expediagroup.graphql.generator.scalars.IDValueUnboxer import com.expediagroup.graphql.server.execution.DataLoaderRegistryFactory import com.expediagroup.graphql.server.execution.GraphQLRequestHandler import com.expediagroup.graphql.server.spring.execution.DefaultSpringGraphQLContextFactory @@ -68,12 +69,14 @@ class GraphQLSchemaConfiguration { instrumentations: Optional>, executionIdProvider: Optional, preparsedDocumentProvider: Optional, - config: GraphQLConfigurationProperties + config: GraphQLConfigurationProperties, + idValueUnboxer: IDValueUnboxer ): GraphQL { val graphQL = GraphQL.newGraphQL(schema) .queryExecutionStrategy(AsyncExecutionStrategy(dataFetcherExceptionHandler)) .mutationExecutionStrategy(AsyncSerialExecutionStrategy(dataFetcherExceptionHandler)) .subscriptionExecutionStrategy(FlowSubscriptionExecutionStrategy(dataFetcherExceptionHandler)) + .valueUnboxer(idValueUnboxer) instrumentations.ifPresent { unordered -> if (unordered.size == 1) { @@ -98,6 +101,10 @@ class GraphQLSchemaConfiguration { return graphQL.build() } + @Bean + @ConditionalOnMissingBean + fun idValueUnboxer(): IDValueUnboxer = IDValueUnboxer() + @Bean @ConditionalOnMissingBean fun springGraphQLRequestParser(objectMapper: ObjectMapper): SpringGraphQLRequestParser = SpringGraphQLRequestParser(objectMapper) diff --git a/website/docs/schema-generator/writing-schemas/scalars.md b/website/docs/schema-generator/writing-schemas/scalars.md index becc7bfda1..5ba92892e9 100644 --- a/website/docs/schema-generator/writing-schemas/scalars.md +++ b/website/docs/schema-generator/writing-schemas/scalars.md @@ -16,12 +16,12 @@ extended scalar types provided by `graphql-java`. | `kotlin.Float` | `Float` | :::note -The GraphQL spec uses the term `Float` for signed doubleā€precision fractional values. `graphql-java` maps this to a `java.lang.Double` for the execution. The generator will map both `kotlin.Double` and `kotlin.Float` to GraphQL `Float` but we reccomend you use `kotlin.Double` +The GraphQL spec uses the term `Float` for signed doubleā€precision fractional values. `graphql-java` maps this to a `java.lang.Double` for the execution. The generator will map both `kotlin.Double` and `kotlin.Float` to GraphQL `Float` but we recommend you use `kotlin.Double`. ::: ## GraphQL ID -GraphQL supports the scalar type `ID`, a unique identifier that is not intended to be human readable. IDs are +GraphQL supports the scalar type `ID`, a unique identifier that is not intended to be human-readable. IDs are serialized as a `String`. To expose a GraphQL `ID` field, you must use the `com.expediagroup.graphql.generator.scalars.ID` class, which is an *inline value class* that wraps the underlying `String` value. @@ -29,6 +29,22 @@ class, which is an *inline value class* that wraps the underlying `String` value `graphql-java` supports additional types (`String`, `Int`, `Long`, or `UUID`) but [due to serialization issues](https://github.com/ExpediaGroup/graphql-kotlin/issues/317) we can only directly support Strings. ::: +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() +``` + +:::note +`IDValueUnboxer` is automatically configured by `graphql-kotlin-spring-server`. +::: + ```kotlin data class Person( val id: ID, diff --git a/website/docs/server/spring-server/spring-beans.md b/website/docs/server/spring-server/spring-beans.md index 3595d92353..36314ab54a 100644 --- a/website/docs/server/spring-server/spring-beans.md +++ b/website/docs/server/spring-server/spring-beans.md @@ -45,16 +45,17 @@ _Created only if federation is **enabled**_ ## GraphQL Configuration -| Bean | Description | -| :----------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Instrumentation (optional) | Any number of beans created that implement `graphql-java` [Instrumentation](https://www.graphql-java.com/documentation/v16/instrumentation/) will be pulled in. The beans can be ordered by implementing the Spring `Ordered` interface. | -| ExecutionIdProvider (optional) | Any number of beans created that implement `graphql-java` [ExecutionIdProvider](https://github.com/graphql-java/graphql-java/blob/master/src/main/java/graphql/execution/ExecutionIdProvider.java) will be pulled in. | -| PreparsedDocumentProvider (optional) | Any number of beans created that implement `graphql-java` [PreparsedDocumentProvider](https://github.com/graphql-java/graphql-java/blob/master/src/main/java/graphql/execution/preparsed/PreparsedDocumentProvider.java) will be pulled in. | -| GraphQL | GraphQL execution object generated using `GraphQLSchema` with default async execution strategies. The GraphQL object can be customized by optionally providing the above beans in the application context. | -| SpringGraphQLRequestParser | Provides the Spring specific logic for parsing the HTTP request into a common GraphQLRequest. See [GraphQLRequestParser](../graphql-request-parser.md) | -| SpringGraphQLContextFactory | Spring specific factory that uses the `ServerRequest`. The `GraphQLContext` generated can be any object. See [GraphQLContextFactory](../graphql-context-factory.md). | -| GraphQLRequestHandler | Handler invoked from `GraphQLServer` that executes the incoming request, defaults to [GraphQLRequestHandler](../graphql-request-handler.md). | -| SpringGraphQLServer | Spring specific object that takes in a `ServerRequest` and returns a `GraphQLResponse` using all the above implementations. See [GraphQLServer](../graphql-server.md) | +| Bean | Description | +|:--------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Instrumentation (optional) | Any number of beans created that implement `graphql-java` [Instrumentation](https://www.graphql-java.com/documentation/v16/instrumentation/) will be pulled in. The beans can be ordered by implementing the Spring `Ordered` interface. | +| ExecutionIdProvider (optional) | Any number of beans created that implement `graphql-java` [ExecutionIdProvider](https://github.com/graphql-java/graphql-java/blob/master/src/main/java/graphql/execution/ExecutionIdProvider.java) will be pulled in. | +| PreparsedDocumentProvider (optional) | Any number of beans created that implement `graphql-java` [PreparsedDocumentProvider](https://github.com/graphql-java/graphql-java/blob/master/src/main/java/graphql/execution/preparsed/PreparsedDocumentProvider.java) will be pulled in. | +| GraphQL | GraphQL execution object generated using `GraphQLSchema` with default async execution strategies. The GraphQL object can be customized by optionally providing the above beans in the application context. | +| SpringGraphQLRequestParser | Provides the Spring specific logic for parsing the HTTP request into a common GraphQLRequest. See [GraphQLRequestParser](../graphql-request-parser.md) | +| SpringGraphQLContextFactory | Spring specific factory that uses the `ServerRequest`. The `GraphQLContext` generated can be any object. See [GraphQLContextFactory](../graphql-context-factory.md). | +| GraphQLRequestHandler | Handler invoked from `GraphQLServer` that executes the incoming request, defaults to [GraphQLRequestHandler](../graphql-request-handler.md). | +| SpringGraphQLServer | Spring specific object that takes in a `ServerRequest` and returns a `GraphQLResponse` using all the above implementations. See [GraphQLServer](../graphql-server.md) | +| IDValueUnboxer | Value unboxer that provides support for handling ID value class | ## Subscriptions