Skip to content

Commit

Permalink
[client] support optional input for jackson serializer (#1158)
Browse files Browse the repository at this point in the history
* [client] support optional input for jackson serializer

Wrap optional input arguments in new `OptionalInput` sealed class which can either be `Undefined` (omitted) or `Defined` (value or explicit null). Unfortunately it looks like we won't be able to support this functionality for `kotlinx-serialization` until Kotlin/kotlinx.serialization#1091 is resolved.

Related: #1151

* fix build

* add explicit opt-in flag for plugins to use optional input wrapper
  • Loading branch information
dariuszkuc authored May 26, 2021
1 parent 96676e3 commit 53539f5
Show file tree
Hide file tree
Showing 44 changed files with 636 additions and 44 deletions.
2 changes: 1 addition & 1 deletion clients/graphql-kotlin-client-jackson/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ tasks {
limit {
counter = "INSTRUCTION"
value = "COVEREDRATIO"
minimum = "0.85".toBigDecimal()
minimum = "0.89".toBigDecimal()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package com.expediagroup.graphql.client.jackson
import com.expediagroup.graphql.client.jackson.types.JacksonGraphQLResponse
import com.expediagroup.graphql.client.serializer.GraphQLClientSerializer
import com.expediagroup.graphql.client.types.GraphQLClientRequest
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JavaType
import com.fasterxml.jackson.databind.ObjectMapper
Expand All @@ -34,6 +35,7 @@ class GraphQLClientJacksonSerializer(private val mapper: ObjectMapper = jacksonO

init {
mapper.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE)
mapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
}

override fun serialize(request: GraphQLClientRequest<*>): String = mapper.writeValueAsString(request)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright 2021 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.expediagroup.graphql.client.jackson.serializers

import com.expediagroup.graphql.client.jackson.types.OptionalInput
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider

class OptionalInputSerializer : JsonSerializer<OptionalInput<*>>() {

override fun isEmpty(provider: SerializerProvider, value: OptionalInput<*>?): Boolean {
return value == OptionalInput.Undefined
}

override fun serialize(value: OptionalInput<*>, gen: JsonGenerator, serializers: SerializerProvider) {
when (value) {
is OptionalInput.Undefined -> return
is OptionalInput.Defined -> {
if (value.value == null) {
serializers.defaultNullValueSerializer.serialize(value.value, gen, serializers)
} else {
serializers.findValueSerializer(value.value::class.java).serialize(value.value, gen, serializers)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright 2021 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.expediagroup.graphql.client.jackson.types

import com.expediagroup.graphql.client.jackson.serializers.OptionalInputSerializer
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonValue
import com.fasterxml.jackson.databind.annotation.JsonSerialize

@JsonSerialize(using = OptionalInputSerializer::class)
sealed class OptionalInput<out T> {
/**
* Represents missing/undefined value.
*/
@JsonSerialize(using = OptionalInputSerializer::class)
object Undefined : OptionalInput<Nothing>() {
override fun toString() = "Undefined"
}

/**
* Wrapper holding explicitly specified value including NULL.
*/
@JsonSerialize(using = OptionalInputSerializer::class)
class Defined<out U> @JsonCreator constructor(@JsonValue val value: U?) : OptionalInput<U>() {
override fun toString(): String = "Defined(value=$value)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.expediagroup.graphql.client.jackson

import com.expediagroup.graphql.client.jackson.data.EnumQuery
import com.expediagroup.graphql.client.jackson.data.FirstQuery
import com.expediagroup.graphql.client.jackson.data.InputQuery
import com.expediagroup.graphql.client.jackson.data.OtherQuery
import com.expediagroup.graphql.client.jackson.data.PolymorphicQuery
import com.expediagroup.graphql.client.jackson.data.ScalarQuery
Expand All @@ -26,6 +27,7 @@ import com.expediagroup.graphql.client.jackson.data.polymorphicquery.SecondInter
import com.expediagroup.graphql.client.jackson.types.JacksonGraphQLError
import com.expediagroup.graphql.client.jackson.types.JacksonGraphQLResponse
import com.expediagroup.graphql.client.jackson.types.JacksonGraphQLSourceLocation
import com.expediagroup.graphql.client.jackson.types.OptionalInput
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Test
import java.util.UUID
Expand Down Expand Up @@ -61,8 +63,7 @@ class GraphQLClientJacksonSerializerTest {
| "variables": { "input": 1.0 }
|},{
| "query": "OTHER_QUERY",
| "operationName": "OtherQuery",
| "variables": null
| "operationName": "OtherQuery"
|}]
""".trimMargin()

Expand Down Expand Up @@ -236,4 +237,29 @@ class GraphQLClientJacksonSerializerTest {
val deserialized = serializer.deserialize(rawResponse, EnumQuery(EnumQuery.Variables()).responseType())
assertEquals(TestEnum.THREE, deserialized.data?.enumResult)
}

@Test
fun `verify we can serialize optional inputs`() {
val query = InputQuery(
variables = InputQuery.Variables(
requiredInput = 123,
optionalIntInput = OptionalInput.Defined(123),
optionalStringInput = OptionalInput.Defined(null)
)
)
val rawQuery =
"""{
| "query": "INPUT_QUERY",
| "operationName": "InputQuery",
| "variables": {
| "requiredInput": 123,
| "optionalIntInput": 123,
| "optionalStringInput": null
| }
|}
""".trimMargin()

val serialized = serializer.serialize(query)
assertEquals(testMapper.readTree(rawQuery), testMapper.readTree(serialized))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright 2021 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.expediagroup.graphql.client.jackson.data

import com.expediagroup.graphql.client.jackson.types.OptionalInput
import com.expediagroup.graphql.client.types.GraphQLClientRequest
import kotlin.reflect.KClass

class InputQuery(
override val variables: Variables
) : GraphQLClientRequest<InputQuery.Result> {
override val query: String = "INPUT_QUERY"

override val operationName: String = "InputQuery"

override fun responseType(): KClass<Result> = Result::class

data class Variables(
val requiredInput: Int,
val optionalIntInput: OptionalInput<Int> = OptionalInput.Undefined,
val optionalStringInput: OptionalInput<String> = OptionalInput.Undefined,
val optionalBooleanInput: OptionalInput<Boolean> = OptionalInput.Undefined
)

data class Result(
val stringResult: String
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright 2021 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.expediagroup.graphql.client.jackson.serializers

import com.expediagroup.graphql.client.jackson.types.OptionalInput
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals

class OptionalInputSerializerTest {

private val mapper = jacksonObjectMapper()
init {
mapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
}

@Test
fun `verify undefined value is serialized to empty JSON`() {
val undefined = InputWrapper()
assertEquals("{}", mapper.writeValueAsString(undefined))
}

@Test
fun `verify null value is serialized correctly`() {
val explicitNull = InputWrapper(
optionalInput = OptionalInput.Defined(null),
optionalInputObject = OptionalInput.Defined(null),
optionalInputList = OptionalInput.Defined(null)
)
assertEquals("""{"optionalInput":null,"optionalInputObject":null,"optionalInputList":null}""", mapper.writeValueAsString(explicitNull))
}

@Test
fun `verify defined values are serialized correctly`() {
val defined = InputWrapper(
optionalInput = OptionalInput.Defined(123),
optionalInputObject = OptionalInput.Defined(BasicInput(OptionalInput.Defined("foo"))),
optionalInputList = OptionalInput.Defined(listOf("a", "b", "c"))
)
assertEquals("""{"optionalInput":123,"optionalInputObject":{"name":"foo"},"optionalInputList":["a","b","c"]}""", mapper.writeValueAsString(defined))
}

@Test
fun `verify inner undefined values are serialized correctly`() {
val nestedUndefined = InputWrapper(optionalInputObject = OptionalInput.Defined(BasicInput(name = OptionalInput.Undefined)))
assertEquals("""{"optionalInputObject":{}}""", mapper.writeValueAsString(nestedUndefined))
}

data class InputWrapper(
val optionalInput: OptionalInput<Int> = OptionalInput.Undefined,
val optionalInputObject: OptionalInput<BasicInput> = OptionalInput.Undefined,
val optionalInputList: OptionalInput<List<String>> = OptionalInput.Undefined
)

data class BasicInput(
val name: OptionalInput<String> = OptionalInput.Undefined
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ tasks {
limit {
counter = "INSTRUCTION"
value = "COVEREDRATIO"
minimum = "0.73".toBigDecimal()
minimum = "0.77".toBigDecimal()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class GraphQLClientKotlinxSerializer(private val jsonBuilder: JsonBuilder.() ->
apply(jsonBuilder)
classDiscriminator = "__typename"
coerceInputValues = true
// encodeDefaults = false // need this for optional
encodeDefaults = true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.expediagroup.graphql.client.serialization.serializers

import com.expediagroup.graphql.client.serialization.types.OptionalInput
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
Expand Down Expand Up @@ -47,30 +48,37 @@ object AnyKSerializer : KSerializer<Any?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Any")

override fun serialize(encoder: Encoder, value: Any?) {
val jsonEncoder = encoder as JsonEncoder
val jsonElement = serializeAny(value)
jsonEncoder.encodeJsonElement(jsonElement)
serializeAny(value)?.let {
val jsonEncoder = encoder as JsonEncoder
jsonEncoder.encodeJsonElement(it)
}
}

private fun serializeAny(value: Any?): JsonElement = when (value) {
private fun serializeAny(value: Any?): JsonElement? = when (value) {
null -> JsonNull
is OptionalInput.Undefined -> null
is OptionalInput.Defined<*> -> serializeAny(value.value)
is Map<*, *> -> {
val mapContents = value.entries.associate { mapEntry ->
mapEntry.key.toString() to serializeAny(mapEntry.value)
}
val mapContents = value.mapNotNull { (key, value) ->
serializeAny(value)?.let {
key.toString() to it
}
}.toMap()
JsonObject(mapContents)
}
is List<*> -> {
val arrayContents = value.map { listEntry -> serializeAny(listEntry) }
val arrayContents = value.mapNotNull { listEntry -> serializeAny(listEntry) }
JsonArray(arrayContents)
}
is Number -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is String -> JsonPrimitive(value)
else -> {
val contents = value::class.memberProperties.associate { property ->
property.name to serializeAny(property.getter.call(value))
}
val contents = value::class.memberProperties.mapNotNull { property ->
serializeAny(property.getter.call(value))?.let {
property.name to it
}
}.toMap()
JsonObject(contents)
}
}
Expand Down
Loading

0 comments on commit 53539f5

Please sign in to comment.