diff --git a/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/GraphQLQueryRequest.kt b/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/GraphQLQueryRequest.kt index 4ff24394e..a97ffb231 100644 --- a/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/GraphQLQueryRequest.kt +++ b/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/GraphQLQueryRequest.kt @@ -26,16 +26,27 @@ import graphql.schema.Coercing class GraphQLQueryRequest @JvmOverloads constructor( val query: GraphQLQuery, val projection: BaseProjectionNode? = null, - scalars: Map, Coercing<*, *>>? = null + options: GraphQLQueryRequestOptions? = null ) { private var selectionSet: SelectionSet? = null - - @JvmOverloads constructor(query: GraphQLQuery, selectionSet: SelectionSet, scalars: Map, Coercing<*, *>>? = null) : this(query = query, scalars = scalars) { + constructor(query: GraphQLQuery, projection: BaseProjectionNode, scalars: Map, Coercing<*, *>>) : this(query = query, projection = projection, options = GraphQLQueryRequestOptions(scalars = scalars)) + constructor(query: GraphQLQuery, selectionSet: SelectionSet, scalars: Map, Coercing<*, *>>? = null) : this(query = query, projection = null, options = GraphQLQueryRequestOptions(scalars = scalars ?: emptyMap())) { this.selectionSet = selectionSet } + class GraphQLQueryRequestOptions(val scalars: Map, Coercing<*, *>> = emptyMap()) { + // When enabled, input values that are derived from properties + // whose values are null will be serialized in the query request + val allowNullablePropertyInputValues = false + } + + val inputValueSerializer = + if (options?.allowNullablePropertyInputValues == true) { + NullableInputValueSerializer(options.scalars) + } else { + InputValueSerializer(options?.scalars ?: emptyMap()) + } - val inputValueSerializer = InputValueSerializer(scalars ?: emptyMap()) val projectionSerializer = ProjectionSerializer(inputValueSerializer) fun serialize(): String { diff --git a/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/InputValueSerializer.kt b/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/InputValueSerializer.kt index 0b5ee8d0b..aebe3828f 100644 --- a/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/InputValueSerializer.kt +++ b/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/InputValueSerializer.kt @@ -35,25 +35,15 @@ import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.OffsetDateTime -import java.util.Currency -import java.util.Date -import java.util.TimeZone +import java.util.* +import kotlin.reflect.KClass import kotlin.reflect.full.allSuperclasses import kotlin.reflect.full.hasAnnotation import kotlin.reflect.full.memberProperties import kotlin.reflect.jvm.isAccessible -/** - * Marks this property invisible for input value serialization. - */ -@Target(AnnotationTarget.PROPERTY) -internal annotation class Transient - -interface InputValue { - fun inputValues(): List> -} - -class InputValueSerializer(private val scalars: Map, Coercing<*, *>> = emptyMap()) { +open class InputValueSerializer(private val scalars: Map, Coercing<*, *>> = emptyMap()) : + InputValueSerializerInterface { companion object { private val toStringClasses = setOf( String::class, @@ -68,84 +58,115 @@ class InputValueSerializer(private val scalars: Map, Coercing<*, *>> = ) } - fun serialize(input: Any?): String { + override fun serialize(input: Any?): String { return AstPrinter.printAst(toValue(input)) } - fun toValue(input: Any?): Value<*> { + override fun toValue(input: Any?): Value<*> { if (input == null) { return NullValue.newNullValue().build() } + val optionalValue = getOptionalValue(input) + + if (optionalValue.isPresent) { + return optionalValue.get() + } + + val classes = (sequenceOf(input::class) + input::class.allSuperclasses.asSequence()) - Any::class + val propertyValues = getPropertyValues(classes, input) + + val objectFields = propertyValues.asSequence() + .filter { (_, value) -> value != null } + .map { (name, value) -> ObjectField(name, toValue(value)) } + .toList() + return ObjectValue.newObjectValue() + .objectFields(objectFields) + .build() + } + + protected fun getOptionalValue(input: Any): Optional> { if (input is Value<*>) { - return input + return Optional.of(input) } for (scalar in scalars.keys) { if (input::class.java == scalar || scalar.isAssignableFrom(input::class.java)) { - return scalars[scalar]!!.valueToLiteral(input) + return Optional.of(scalars[scalar]!!.valueToLiteral(input)) } } if (input::class in toStringClasses) { - return StringValue.of(input.toString()) + return Optional.of(StringValue.of(input.toString())) } if (input is String) { - return StringValue.of(input) + return Optional.of(StringValue.of(input)) } if (input is Float) { - return FloatValue.of(input.toDouble()) + return Optional.of(FloatValue.of(input.toDouble())) } if (input is Double) { - return FloatValue.of(input) + return Optional.of(FloatValue.of(input)) } if (input is BigDecimal) { - return FloatValue.newFloatValue(input).build() + return Optional.of(FloatValue.newFloatValue(input).build()) } if (input is BigInteger) { - return IntValue.newIntValue(input).build() + return Optional.of(IntValue.newIntValue(input).build()) } if (input is Int) { - return IntValue.of(input) + return Optional.of(IntValue.of(input)) } if (input is Number) { - return IntValue.newIntValue(BigInteger.valueOf(input.toLong())).build() + return Optional.of(IntValue.newIntValue(BigInteger.valueOf(input.toLong())).build()) } if (input is Boolean) { - return BooleanValue.of(input) + return Optional.of(BooleanValue.of(input)) } if (input is Enum<*>) { - return EnumValue.newEnumValue(input.name).build() + return Optional.of(EnumValue.newEnumValue(input.name).build()) } if (input is Collection<*>) { - return ArrayValue.newArrayValue() - .values(input.map { toValue(it) }) - .build() + return Optional.of( + ArrayValue.newArrayValue() + .values(input.map { toValue(it) }) + .build() + ) } if (input is Map<*, *>) { - return ObjectValue.newObjectValue() - .objectFields(input.map { (key, value) -> ObjectField(key.toString(), toValue(value)) }) - .build() + return Optional.of( + ObjectValue.newObjectValue() + .objectFields(input.map { (key, value) -> ObjectField(key.toString(), toValue(value)) }) + .build() + ) } if (input is InputValue) { - return ObjectValue.newObjectValue() - .objectFields(input.inputValues().map { (name, value) -> ObjectField(name, toValue(value)) }) - .build() + return Optional.of( + ObjectValue.newObjectValue() + .objectFields(input.inputValues().map { (name, value) -> ObjectField(name, toValue(value)) }) + .build() + ) } - val classes = sequenceOf(input::class) + input::class.allSuperclasses.asSequence() - Any::class + return Optional.empty() + } + + protected fun getPropertyValues( + classes: Sequence>, + input: Any? + ): MutableMap { val propertyValues = mutableMapOf() for (klass in classes) { @@ -158,13 +179,6 @@ class InputValueSerializer(private val scalars: Map, Coercing<*, *>> = propertyValues[property.name] = property.call(input) } } - - val objectFields = propertyValues.asSequence() - .filter { (_, value) -> value != null } - .map { (name, value) -> ObjectField(name, toValue(value)) } - .toList() - return ObjectValue.newObjectValue() - .objectFields(objectFields) - .build() + return propertyValues } } diff --git a/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/InputValueSerializerInterface.kt b/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/InputValueSerializerInterface.kt new file mode 100644 index 000000000..bb8bd4b8d --- /dev/null +++ b/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/InputValueSerializerInterface.kt @@ -0,0 +1,36 @@ +/* + * + * Copyright 2020 Netflix, 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 + * + * http://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.netflix.graphql.dgs.client.codegen + +import graphql.language.Value + +/** + * Marks this property invisible for input value serialization. + */ +@Target(AnnotationTarget.PROPERTY) +internal annotation class Transient + +interface InputValueSerializerInterface { + fun serialize(input: Any?): String + fun toValue(input: Any?): Value<*> +} + +interface InputValue { + fun inputValues(): List> +} diff --git a/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/NullableInputValueSerializer.kt b/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/NullableInputValueSerializer.kt new file mode 100644 index 000000000..81d6d156e --- /dev/null +++ b/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/NullableInputValueSerializer.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2021 Netflix, 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 + * + * http://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.netflix.graphql.dgs.client.codegen + +import graphql.language.NullValue +import graphql.language.ObjectField +import graphql.language.ObjectValue +import graphql.language.Value +import graphql.schema.Coercing +import kotlin.reflect.full.allSuperclasses + +class NullableInputValueSerializer(scalars: Map, Coercing<*, *>> = emptyMap()) : + InputValueSerializer(scalars) { + + override fun toValue(input: Any?): Value<*> { + if (input == null) { + return NullValue.newNullValue().build() + } + + val optionalValue = getOptionalValue(input) + + if (optionalValue.isPresent) { + return optionalValue.get() + } + + val classes = (sequenceOf(input::class) + input::class.allSuperclasses.asSequence()) - Any::class + val propertyValues = getPropertyValues(classes, input) + + val objectFields = propertyValues.asSequence() + .map { (name, value) -> ObjectField(name, toValue(value)) } + .toList() + return ObjectValue.newObjectValue() + .objectFields(objectFields) + .build() + } +} diff --git a/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/ProjectionSerializer.kt b/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/ProjectionSerializer.kt index d0267a44c..6cfbb9ca6 100644 --- a/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/ProjectionSerializer.kt +++ b/graphql-dgs-codegen-shared-core/src/main/kotlin/com/netflix/graphql/dgs/client/codegen/ProjectionSerializer.kt @@ -23,7 +23,7 @@ import graphql.language.InlineFragment import graphql.language.SelectionSet import graphql.language.TypeName -class ProjectionSerializer(private val inputValueSerializer: InputValueSerializer) { +class ProjectionSerializer(private val inputValueSerializer: InputValueSerializerInterface) { fun toSelectionSet(projection: BaseProjectionNode): SelectionSet { val selectionSet = SelectionSet.newSelectionSet() diff --git a/graphql-dgs-codegen-shared-core/src/test/kotlin/com/netflix/graphql/dgs/client/codegen/InputValueSerializerTest.kt b/graphql-dgs-codegen-shared-core/src/test/kotlin/com/netflix/graphql/dgs/client/codegen/InputValueSerializerTest.kt index 40c81a106..59897eab4 100644 --- a/graphql-dgs-codegen-shared-core/src/test/kotlin/com/netflix/graphql/dgs/client/codegen/InputValueSerializerTest.kt +++ b/graphql-dgs-codegen-shared-core/src/test/kotlin/com/netflix/graphql/dgs/client/codegen/InputValueSerializerTest.kt @@ -60,11 +60,27 @@ class InputValueSerializerTest { } @Test - fun `Null values should be skipped`() { - val movieInput = MovieInput(1) + fun `Null values should be serialized except when properties of a POJO`() { + class ExamplePojo { + private val movieId: String? = null + private val movieTitle: String = "Bojack Horseman" + } - val serialize = InputValueSerializer(mapOf(DateRange::class.java to DateRangeScalar())).serialize(movieInput) - assertThat(serialize).isEqualTo("{movieId : 1}") + assertThat(InputValueSerializer(mapOf()).serialize(null)).isEqualTo("null") + assertThat(InputValueSerializer(mapOf()).serialize(mapOf("hello" to null))).isEqualTo("{hello : null}") + assertThat(InputValueSerializer(mapOf()).serialize(ExamplePojo())).isEqualTo("{movieTitle : \"Bojack Horseman\"}") + } + + @Test + fun `NullableInputValueSerializer allows null values from POJO`() { + class ExamplePojo { + private val movieId: String? = null + private val movieTitle: String = "Bojack Horseman" + } + + assertThat(NullableInputValueSerializer(mapOf()).serialize(null)).isEqualTo("null") + assertThat(NullableInputValueSerializer(mapOf()).serialize(mapOf("hello" to null))).isEqualTo("{hello : null}") + assertThat(NullableInputValueSerializer(mapOf()).serialize(ExamplePojo())).isEqualTo("{movieId : null, movieTitle : \"Bojack Horseman\"}") } @Test