Skip to content

Commit f114833

Browse files
authored
[generator] built-in support for optional input (#808)
In the GraphQL world, input types can be optional which means that the client can either: * Not specify a value at all * Send null explicitly * Send the non-null type This is in contrast with the JVM world where objects can either have some specific value or don't have any value (i.e. are `null`). As a result, when using default serialization logic it is not possible to distinguish between missing/unspecified value and explicit `null` value. This PR introduces new `OptionalInput` sealed class wrapper that when used for query arguments will be automatically deserialized to `Undefined` object if argument was not specified OR to `Defined` wrapper class when argument was specified (including explicit null). Resolves: #783
1 parent 050549a commit f114833

File tree

10 files changed

+280
-28
lines changed

10 files changed

+280
-28
lines changed

docs/schema-generator/execution/optional-undefined-arguments.md

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,44 @@ id: optional-undefined-arguments
33
title: Optional Undefined Arguments
44
---
55

6-
In GraphQL, input types can be optional which means that the client can either:
6+
In the GraphQL world, input types can be optional which means that the client can either:
77

88
* Not specify a value at all
9-
* Send null explictly
9+
* Send null explicitly
1010
* Send the non-null type
1111

12-
Optional input types are represented as nullable parameters in Kotlin
12+
This is in contrast with the JVM world where objects can either have some specific value or don't have any value (i.e.
13+
are `null`). As a result, when using default serialization logic it is not possible to distinguish between missing/unspecified
14+
value and explicit `null` value.
15+
16+
## Using OptionalInput wrapper
17+
18+
`OptionalInput` sealed class is a convenient wrapper that provides easy distinction between unspecified, `null` and non-null
19+
value. If target argument is not specified in the request it will be represented as `Undefined` object, otherwise actual
20+
value will be wrapped in `Defined` class.
21+
22+
```kotlin
23+
fun optionalInput(input: OptionalInput<String>): String = when (input) {
24+
is OptionalInput.Undefined -> "input was not specified"
25+
is OptionalInput.Defined<String> -> "input value: ${input.value}"
26+
}
27+
```
28+
29+
```graphql
30+
query OptionalInputQuery {
31+
undefined: optionalInput
32+
null: optionalInput(value: null)
33+
foo: optionalInput(value: "foo")
34+
}
35+
```
36+
37+
> NOTE: Regardless whether generic type of `OptionalInput` is specified as nullable or not it will always result in nullable
38+
> value in `Defined` class.
39+
40+
## Using DataFetchingEnvironment
41+
42+
Optional input types can be represented as nullable parameters in Kotlin
43+
1344
```kotlin
1445
fun optionalInput(value: String?): String? = value
1546
```
@@ -23,9 +54,10 @@ query OptionalInputQuery {
2354
```
2455

2556
By default, if an optional input value is not specified, then the execution engine will set the argument in Kotlin to `null`.
26-
This means that you can not tell, by just the value alone, whether the request did not contain any argument or the client explicitly passed in `null`.
57+
This means that you can not tell, by just the value alone, whether the request did not contain any argument or the client
58+
explicitly passed in `null`.
2759

28-
Instead, you should inspect the [DataFetchingEnvironment](./data-fetching-environment.md) where you can see if the request had the variable defined and even check parent arguments as well.
60+
Instead, you can inspect all passed in arguments using the [DataFetchingEnvironment](./data-fetching-environment.md).
2961

3062
```kotlin
3163
fun optionalInput(value: String?, dataFetchingEnvironment: DataFetchingEnvironment): String =

graphql-kotlin-schema-generator/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ val jacksonVersion: String by project
66
val kotlinVersion: String by project
77
val kotlinCoroutinesVersion: String by project
88
val rxjavaVersion: String by project
9+
val junitVersion: String by project
910

1011
dependencies {
1112
api("com.graphql-java:graphql-java:$graphQLJavaVersion")
@@ -14,6 +15,7 @@ dependencies {
1415
implementation(kotlin("reflect", kotlinVersion))
1516
implementation("io.github.classgraph:classgraph:$classGraphVersion")
1617
testImplementation("io.reactivex.rxjava3:rxjava:$rxjavaVersion")
18+
testImplementation("org.junit.jupiter:junit-jupiter-params:$junitVersion")
1719
}
1820

1921
tasks {

graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/execution/FunctionDataFetcher.kt

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2019 Expedia, Inc
2+
* Copyright 2020 Expedia, Inc
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -22,6 +22,7 @@ import com.expediagroup.graphql.generator.extensions.getTypeOfFirstArgument
2222
import com.expediagroup.graphql.generator.extensions.isDataFetchingEnvironment
2323
import com.expediagroup.graphql.generator.extensions.isGraphQLContext
2424
import com.expediagroup.graphql.generator.extensions.isList
25+
import com.expediagroup.graphql.generator.extensions.isOptionalInputType
2526
import com.fasterxml.jackson.databind.ObjectMapper
2627
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
2728
import graphql.schema.DataFetcher
@@ -103,13 +104,27 @@ open class FunctionDataFetcher(
103104
val name = param.getName()
104105
val argument = environment.arguments[name]
105106

106-
return if (param.isList()) {
107-
val argumentClass = param.type.getTypeOfFirstArgument().getJavaClass()
108-
val jacksonCollectionType = objectMapper.typeFactory.constructCollectionType(List::class.java, argumentClass)
109-
objectMapper.convertValue(argument, jacksonCollectionType)
110-
} else {
111-
val javaClass = param.type.getJavaClass()
112-
objectMapper.convertValue(argument, javaClass)
107+
return when {
108+
param.isList() -> {
109+
val argumentClass = param.type.getTypeOfFirstArgument().getJavaClass()
110+
val jacksonCollectionType = objectMapper.typeFactory.constructCollectionType(List::class.java, argumentClass)
111+
objectMapper.convertValue(argument, jacksonCollectionType)
112+
}
113+
param.type.isOptionalInputType() -> {
114+
when {
115+
!environment.containsArgument(name) -> OptionalInput.Undefined
116+
argument == null -> OptionalInput.Defined(null)
117+
else -> {
118+
val argumentClass = param.type.getTypeOfFirstArgument().getJavaClass()
119+
val value = objectMapper.convertValue(argument, argumentClass)
120+
OptionalInput.Defined(value)
121+
}
122+
}
123+
}
124+
else -> {
125+
val javaClass = param.type.getJavaClass()
126+
objectMapper.convertValue(argument, javaClass)
127+
}
113128
}
114129
}
115130

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright 2020 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.execution
18+
19+
import com.fasterxml.jackson.annotation.JsonCreator
20+
import com.fasterxml.jackson.annotation.JsonValue
21+
import com.fasterxml.jackson.core.JsonParser
22+
import com.fasterxml.jackson.databind.BeanProperty
23+
import com.fasterxml.jackson.databind.DeserializationContext
24+
import com.fasterxml.jackson.databind.JsonDeserializer
25+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
26+
import com.fasterxml.jackson.databind.deser.ContextualDeserializer
27+
import com.fasterxml.jackson.databind.util.AccessPattern
28+
29+
/**
30+
* Wrapper used to represent optionally defined input arguments that allows us to distinguish between undefined value, explicit NULL value
31+
* and specified value.
32+
*/
33+
@JsonDeserialize(using = OptionalInputDeserializer::class)
34+
sealed class OptionalInput<out T> {
35+
36+
/**
37+
* Represents missing/undefined value.
38+
*/
39+
object Undefined : OptionalInput<Nothing>()
40+
41+
/**
42+
* Wrapper holding explicitly specified value including NULL.
43+
*/
44+
class Defined<out U> @JsonCreator constructor(@JsonValue val value: U?) : OptionalInput<U>()
45+
}
46+
47+
/**
48+
* Null aware deserializer that distinguishes between undefined value, explicit NULL value
49+
* and specified value.
50+
*/
51+
class OptionalInputDeserializer(private val klazz: Class<*>? = null) : JsonDeserializer<OptionalInput<*>>(), ContextualDeserializer {
52+
53+
override fun createContextual(ctxt: DeserializationContext, property: BeanProperty?): JsonDeserializer<*> {
54+
val type = if (property != null) {
55+
property.type.containedType(0)
56+
} else {
57+
ctxt.contextualType
58+
}
59+
return OptionalInputDeserializer(type.rawClass)
60+
}
61+
62+
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OptionalInput<*> {
63+
val result: Any = ctxt.readValue(p, klazz)
64+
return OptionalInput.Defined(result)
65+
}
66+
67+
override fun getNullAccessPattern(): AccessPattern = AccessPattern.CONSTANT
68+
69+
override fun getNullValue(ctxt: DeserializationContext): OptionalInput<*> = if (ctxt.parser.parsingContext.currentName != null) {
70+
OptionalInput.Defined(null)
71+
} else {
72+
OptionalInput.Undefined
73+
}
74+
}

graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/extensions/kTypeExtensions.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2019 Expedia, Inc
2+
* Copyright 2020 Expedia, Inc
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,10 +17,12 @@
1717
package com.expediagroup.graphql.generator.extensions
1818

1919
import com.expediagroup.graphql.exceptions.InvalidListTypeException
20+
import com.expediagroup.graphql.execution.OptionalInput
2021
import kotlin.reflect.KClass
2122
import kotlin.reflect.KType
2223
import kotlin.reflect.full.createType
2324
import kotlin.reflect.full.isSubclassOf
25+
import kotlin.reflect.full.withNullability
2426
import kotlin.reflect.jvm.jvmErasure
2527

2628
private val primitiveArrayTypes = mapOf(
@@ -39,6 +41,16 @@ internal fun KType.getJavaClass(): Class<*> = this.getKClass().java
3941

4042
internal fun KType.isSubclassOf(kClass: KClass<*>) = this.getKClass().isSubclassOf(kClass)
4143

44+
internal fun KType.isListType() = this.isSubclassOf(List::class) || this.getJavaClass().isArray
45+
46+
internal fun KType.isOptionalInputType() = this.isSubclassOf(OptionalInput::class)
47+
48+
internal fun KType.unwrapOptionalInputType() = if (this.isOptionalInputType()) {
49+
this.getWrappedType().withNullability(true)
50+
} else {
51+
this
52+
}
53+
4254
@Throws(InvalidListTypeException::class)
4355
internal fun KType.getTypeOfFirstArgument(): KType =
4456
this.arguments.firstOrNull()?.type ?: throw InvalidListTypeException(this)

graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/types/generateArgument.kt

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2019 Expedia, Inc
2+
* Copyright 2020 Expedia, Inc
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -26,22 +26,27 @@ import com.expediagroup.graphql.generator.extensions.isInterface
2626
import com.expediagroup.graphql.generator.extensions.isListType
2727
import com.expediagroup.graphql.generator.extensions.isUnion
2828
import com.expediagroup.graphql.generator.extensions.safeCast
29+
import com.expediagroup.graphql.generator.extensions.unwrapOptionalInputType
2930
import graphql.schema.GraphQLArgument
3031
import kotlin.reflect.KClass
3132
import kotlin.reflect.KParameter
33+
import kotlin.reflect.KType
3234

3335
@Throws(InvalidInputFieldTypeException::class)
3436
internal fun generateArgument(generator: SchemaGenerator, parameter: KParameter): GraphQLArgument {
3537

38+
val inputTypeFromHooks = generator.config.hooks.willResolveInputMonad(parameter.type)
39+
val unwrappedType = inputTypeFromHooks.unwrapOptionalInputType()
40+
3641
// Validate that the input is not a polymorphic type
3742
// This is not currently supported by the GraphQL spec
3843
// https://github.com/graphql/graphql-spec/blob/master/rfcs/InputUnion.md
39-
val unwrappedClass = getUnwrappedClass(parameter)
44+
val unwrappedClass = getUnwrappedClass(unwrappedType)
4045
if (unwrappedClass.isUnion() || unwrappedClass.isInterface()) {
4146
throw InvalidInputFieldTypeException(parameter)
4247
}
4348

44-
val graphQLType = generateGraphQLType(generator = generator, type = parameter.type, inputType = true)
49+
val graphQLType = generateGraphQLType(generator = generator, type = unwrappedType, inputType = true)
4550

4651
// Deprecation of arguments is currently unsupported: https://github.com/facebook/graphql/issues/197
4752
val builder = GraphQLArgument.newArgument()
@@ -56,9 +61,9 @@ internal fun generateArgument(generator: SchemaGenerator, parameter: KParameter)
5661
return generator.config.hooks.onRewireGraphQLType(builder.build()).safeCast()
5762
}
5863

59-
private fun getUnwrappedClass(parameter: KParameter): KClass<*> =
60-
if (parameter.isListType()) {
61-
parameter.type.getWrappedType().getKClass()
64+
private fun getUnwrappedClass(parameterType: KType): KClass<*> =
65+
if (parameterType.isListType()) {
66+
parameterType.getWrappedType().getKClass()
6267
} else {
63-
parameter.type.getKClass()
68+
parameterType.getKClass()
6469
}

graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/types/generateInputProperty.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2019 Expedia, Inc
2+
* Copyright 2020 Expedia, Inc
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -20,6 +20,7 @@ import com.expediagroup.graphql.generator.SchemaGenerator
2020
import com.expediagroup.graphql.generator.extensions.getPropertyDescription
2121
import com.expediagroup.graphql.generator.extensions.getPropertyName
2222
import com.expediagroup.graphql.generator.extensions.safeCast
23+
import com.expediagroup.graphql.generator.extensions.unwrapOptionalInputType
2324
import graphql.schema.GraphQLInputObjectField
2425
import graphql.schema.GraphQLInputType
2526
import kotlin.reflect.KClass
@@ -29,7 +30,9 @@ internal fun generateInputProperty(generator: SchemaGenerator, prop: KProperty<*
2930
val builder = GraphQLInputObjectField.newInputObjectField()
3031

3132
// Verfiy that the unwrapped GraphQL type is a valid input type
32-
val graphQLInputType = generateGraphQLType(generator = generator, type = prop.returnType, inputType = true).safeCast<GraphQLInputType>()
33+
val inputTypeFromHooks = generator.config.hooks.willResolveInputMonad(prop.returnType)
34+
val unwrappedType = inputTypeFromHooks.unwrapOptionalInputType()
35+
val graphQLInputType = generateGraphQLType(generator = generator, type = unwrappedType, inputType = true).safeCast<GraphQLInputType>()
3336

3437
builder.description(prop.getPropertyDescription(parentClass))
3538
builder.name(prop.getPropertyName(parentClass))

graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/hooks/SchemaGeneratorHooks.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ interface SchemaGeneratorHooks {
7373
*/
7474
fun willResolveMonad(type: KType): KType = type
7575

76+
/**
77+
* Called before resolving a KType to the input GraphQL type.
78+
* This allows resolvers for custom deserialization logic of wrapped input values, like in an Optional.
79+
*/
80+
fun willResolveInputMonad(type: KType): KType = type
81+
7682
/**
7783
* Called when looking at the KClass superclasses to determine if it valid for adding to the generated schema.
7884
* If any filter returns false, it is rejected.

0 commit comments

Comments
 (0)