diff --git a/examples/spring/src/main/kotlin/com/expediagroup/graphql/sample/query/DataAndErrors.kt b/examples/spring/src/main/kotlin/com/expediagroup/graphql/sample/query/DataAndErrors.kt new file mode 100644 index 000000000..9fce59b7e --- /dev/null +++ b/examples/spring/src/main/kotlin/com/expediagroup/graphql/sample/query/DataAndErrors.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2019 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.sample.query + +import graphql.ExceptionWhileDataFetching +import graphql.execution.DataFetcherResult +import graphql.execution.ExecutionPath +import graphql.language.SourceLocation +import org.springframework.stereotype.Component +import java.util.concurrent.CompletableFuture + +@Component +class DataAndErrors : Query { + + fun returnDataAndErrors(): DataFetcherResult { + val error = ExceptionWhileDataFetching(ExecutionPath.rootPath(), RuntimeException(), SourceLocation(1, 1)) + return DataFetcherResult.newResult() + .data("Hello from data fetcher") + .error(error) + .build() + } + + fun completableFutureDataAndErrors(): CompletableFuture> { + val error = ExceptionWhileDataFetching(ExecutionPath.rootPath(), RuntimeException(), SourceLocation(1, 1)) + val dataFetcherResult = DataFetcherResult.newResult() + .data("Hello from data fetcher") + .error(error) + .build() + + return CompletableFuture.completedFuture(dataFetcherResult) + } +} diff --git a/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/extensions/kClassExtensions.kt b/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/extensions/kClassExtensions.kt index 92e0a0571..878f97902 100644 --- a/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/extensions/kClassExtensions.kt +++ b/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/extensions/kClassExtensions.kt @@ -62,11 +62,7 @@ internal fun KClass<*>.isUnion(): Boolean = internal fun KClass<*>.isEnum(): Boolean = this.isSubclassOf(Enum::class) -internal fun KClass<*>.isList(): Boolean = this.isSubclassOf(List::class) - -internal fun KClass<*>.isArray(): Boolean = this.java.isArray - -internal fun KClass<*>.isListType(): Boolean = this.isList() || this.isArray() +internal fun KClass<*>.isListType(): Boolean = this.isSubclassOf(List::class) || this.java.isArray @Throws(CouldNotGetNameOfKClassException::class) internal fun KClass<*>.getSimpleName(isInputClass: Boolean = false): String { diff --git a/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/extensions/kTypeExtensions.kt b/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/extensions/kTypeExtensions.kt index cafdb46fc..09cae1d65 100644 --- a/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/extensions/kTypeExtensions.kt +++ b/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/extensions/kTypeExtensions.kt @@ -17,8 +17,10 @@ package com.expediagroup.graphql.generator.extensions import com.expediagroup.graphql.exceptions.InvalidListTypeException +import kotlin.reflect.KClass import kotlin.reflect.KType import kotlin.reflect.full.createType +import kotlin.reflect.full.isSubclassOf import kotlin.reflect.jvm.jvmErasure private val primitiveArrayTypes = mapOf( @@ -33,6 +35,8 @@ private val primitiveArrayTypes = mapOf( internal fun KType.getKClass() = this.jvmErasure +internal fun KType.isSubclassOf(kClass: KClass<*>) = this.getKClass().isSubclassOf(kClass) + @Throws(InvalidListTypeException::class) internal fun KType.getTypeOfFirstArgument(): KType = this.arguments.firstOrNull()?.type ?: throw InvalidListTypeException(this) diff --git a/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/types/FunctionBuilder.kt b/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/types/FunctionBuilder.kt index 6174c23c9..1408d744e 100644 --- a/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/types/FunctionBuilder.kt +++ b/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/types/FunctionBuilder.kt @@ -22,18 +22,13 @@ import com.expediagroup.graphql.generator.TypeBuilder import com.expediagroup.graphql.generator.extensions.getDeprecationReason import com.expediagroup.graphql.generator.extensions.getFunctionName import com.expediagroup.graphql.generator.extensions.getGraphQLDescription -import com.expediagroup.graphql.generator.extensions.getKClass -import com.expediagroup.graphql.generator.extensions.getTypeOfFirstArgument import com.expediagroup.graphql.generator.extensions.getValidArguments import com.expediagroup.graphql.generator.extensions.safeCast +import com.expediagroup.graphql.generator.types.utils.getWrappedReturnType import graphql.schema.FieldCoordinates import graphql.schema.GraphQLFieldDefinition import graphql.schema.GraphQLOutputType -import org.reactivestreams.Publisher -import java.util.concurrent.CompletableFuture import kotlin.reflect.KFunction -import kotlin.reflect.KType -import kotlin.reflect.full.isSubclassOf internal class FunctionBuilder(generator: SchemaGenerator) : TypeBuilder(generator) { @@ -58,10 +53,10 @@ internal class FunctionBuilder(generator: SchemaGenerator) : TypeBuilder(generat val typeFromHooks = config.hooks.willResolveMonad(fn.returnType) val returnType = getWrappedReturnType(typeFromHooks) - builder.type(graphQLTypeOf(returnType).safeCast()) - val graphQLType = builder.build() - + val graphQLOutputType = graphQLTypeOf(returnType).safeCast() + val graphQLType = builder.type(graphQLOutputType).build() val coordinates = FieldCoordinates.coordinates(parentName, functionName) + if (!abstract) { val dataFetcherFactory = config.dataFetcherFactoryProvider.functionDataFetcherFactory(target = target, kFunction = fn) generator.codeRegistry.dataFetcher(coordinates, dataFetcherFactory) @@ -69,11 +64,4 @@ internal class FunctionBuilder(generator: SchemaGenerator) : TypeBuilder(generat return config.hooks.onRewireGraphQLType(graphQLType, coordinates, codeRegistry).safeCast() } - - private fun getWrappedReturnType(returnType: KType): KType = - when { - returnType.getKClass().isSubclassOf(Publisher::class) -> returnType.getTypeOfFirstArgument() - returnType.classifier == CompletableFuture::class -> returnType.getTypeOfFirstArgument() - else -> returnType - } } diff --git a/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/types/utils/functionReturnTypes.kt b/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/types/utils/functionReturnTypes.kt new file mode 100644 index 000000000..536bcb37d --- /dev/null +++ b/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/types/utils/functionReturnTypes.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2019 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.generator.types.utils + +import com.expediagroup.graphql.generator.extensions.getTypeOfFirstArgument +import com.expediagroup.graphql.generator.extensions.isSubclassOf +import graphql.execution.DataFetcherResult +import org.reactivestreams.Publisher +import java.util.concurrent.CompletableFuture +import kotlin.reflect.KType + +/** + * These are the classes that can be returned from data fetchers (ie functions) + * but we only want to expose the wrapped type in the schema. + * + * [Publisher] is used for subscriptions + * [CompletableFuture] is used for asynchronous results + * [DataFetcherResult] is used for returning data and errors in the same response + * + * We can return the following combination of types: + * Valid type T + * Publisher + * DataFetcherResult + * CompletableFuture + * CompletableFuture> + */ +internal fun getWrappedReturnType(returnType: KType): KType { + return when { + returnType.isSubclassOf(Publisher::class) -> returnType.getTypeOfFirstArgument() + returnType.isSubclassOf(DataFetcherResult::class) -> returnType.getTypeOfFirstArgument() + returnType.isSubclassOf(CompletableFuture::class) -> { + val wrappedType = returnType.getTypeOfFirstArgument() + + if (wrappedType.isSubclassOf(DataFetcherResult::class)) { + return wrappedType.getTypeOfFirstArgument() + } + + wrappedType + } + else -> returnType + } +} diff --git a/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/SchemaGeneratorTest.kt b/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/SchemaGeneratorTest.kt index b3588f010..86077d94b 100644 --- a/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/SchemaGeneratorTest.kt +++ b/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/SchemaGeneratorTest.kt @@ -27,8 +27,12 @@ import com.expediagroup.graphql.exceptions.InvalidIdTypeException import com.expediagroup.graphql.extensions.deepName import com.expediagroup.graphql.testSchemaConfig import com.expediagroup.graphql.toSchema +import graphql.ExceptionWhileDataFetching import graphql.GraphQL import graphql.Scalars +import graphql.execution.DataFetcherResult +import graphql.execution.ExecutionPath +import graphql.language.SourceLocation import graphql.schema.GraphQLFieldDefinition import graphql.schema.GraphQLNonNull import graphql.schema.GraphQLObjectType @@ -353,6 +357,23 @@ class SchemaGeneratorTest { assertEquals(Scalars.GraphQLID, serialField?.wrappedType) } + @Test + fun `SchemaGenerator supports DataFetcherResult as a return type`() { + val schema = toSchema(queries = listOf(TopLevelObject(QueryWithDataFetcherResult())), config = testSchemaConfig) + + val graphQL = GraphQL.newGraphQL(schema).build() + val result = graphQL.execute("{ dataAndErrors }") + val data = result.getData>() + val errors = result.errors + + assertNotNull(data) + val res: String? = data["dataAndErrors"] + assertEquals(actual = res, expected = "Hello") + + assertNotNull(errors) + assertEquals(expected = 1, actual = errors.size) + } + class QueryObject { @GraphQLDescription("A GraphQL query method") fun query(@GraphQLDescription("A GraphQL value") value: Int): Geography = Geography(value, GeoType.CITY, listOf()) @@ -550,4 +571,11 @@ class SchemaGeneratorTest { @GraphQLID val serial: UUID, val type: String ) + + class QueryWithDataFetcherResult { + fun dataAndErrors(): DataFetcherResult { + val error = ExceptionWhileDataFetching(ExecutionPath.rootPath(), RuntimeException(), SourceLocation(1, 1)) + return DataFetcherResult.newResult().data("Hello").error(error).build() + } + } } diff --git a/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/extensions/KClassExtensionsTest.kt b/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/extensions/KClassExtensionsTest.kt index 1c1aa2641..31ae2fd91 100644 --- a/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/extensions/KClassExtensionsTest.kt +++ b/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/extensions/KClassExtensionsTest.kt @@ -187,22 +187,6 @@ open class KClassExtensionsTest { assertFalse(MyTestClass::class.isEnum()) } - @Test - fun `test list extension`() { - assertTrue(listOf(1)::class.isList()) - assertTrue(arrayListOf(1)::class.isList()) - assertFalse(arrayOf(1)::class.isList()) - assertFalse(MyTestClass::class.isList()) - } - - @Test - fun `test array extension`() { - assertTrue(arrayOf(1)::class.isArray()) - assertTrue(intArrayOf(1)::class.isArray()) - assertFalse(listOf(1)::class.isArray()) - assertFalse(MyTestClass::class.isArray()) - } - @Test fun `test listType extension`() { assertTrue(arrayOf(1)::class.isListType()) diff --git a/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/extensions/KTypeExtensionsKtTest.kt b/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/extensions/KTypeExtensionsKtTest.kt index bb02e1e36..de400b54c 100644 --- a/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/extensions/KTypeExtensionsKtTest.kt +++ b/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/extensions/KTypeExtensionsKtTest.kt @@ -27,6 +27,8 @@ import kotlin.reflect.full.findParameterByName import kotlin.reflect.full.starProjectedType import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue internal class KTypeExtensionsKtTest { @@ -40,6 +42,10 @@ internal class KTypeExtensionsKtTest { fun stringFun(string: String) = "hello $string" } + internal interface SimpleInterface + + internal class SimpleClass(val id: String) : SimpleInterface + @Test fun getTypeOfFirstArgument() { assertEquals(String::class.starProjectedType, MyClass::listFun.findParameterByName("list")?.type?.getTypeOfFirstArgument()) @@ -72,6 +78,14 @@ internal class KTypeExtensionsKtTest { assertEquals(MyClass::class, MyClass::class.starProjectedType.getKClass()) } + @Test + fun isSubclassOf() { + assertTrue(MyClass::class.starProjectedType.isSubclassOf(MyClass::class)) + assertTrue(SimpleClass::class.starProjectedType.isSubclassOf(SimpleInterface::class)) + assertFalse(SimpleInterface::class.starProjectedType.isSubclassOf(SimpleClass::class)) + assertFalse(MyClass::class.starProjectedType.isSubclassOf(SimpleInterface::class)) + } + @Test fun getArrayType() { assertEquals(Int::class.starProjectedType, IntArray::class.starProjectedType.getWrappedType()) diff --git a/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/types/FunctionBuilderTest.kt b/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/types/FunctionBuilderTest.kt index d1b16c727..69f4b4bc5 100644 --- a/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/types/FunctionBuilderTest.kt +++ b/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/types/FunctionBuilderTest.kt @@ -21,18 +21,26 @@ import com.expediagroup.graphql.annotations.GraphQLDescription import com.expediagroup.graphql.annotations.GraphQLDirective import com.expediagroup.graphql.annotations.GraphQLIgnore import com.expediagroup.graphql.annotations.GraphQLName +import com.expediagroup.graphql.exceptions.TypeNotSupportedException import com.expediagroup.graphql.execution.FunctionDataFetcher +import graphql.ExceptionWhileDataFetching import graphql.Scalars +import graphql.execution.DataFetcherResult +import graphql.execution.ExecutionPath import graphql.introspection.Introspection +import graphql.language.SourceLocation import graphql.schema.DataFetchingEnvironment import graphql.schema.FieldCoordinates +import graphql.schema.GraphQLList import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLTypeUtil import io.reactivex.Flowable import org.junit.jupiter.api.Test import org.reactivestreams.Publisher import java.util.UUID import java.util.concurrent.CompletableFuture import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue @Suppress("Detekt.UnusedPrivateClass") @@ -78,6 +86,25 @@ internal class FunctionBuilderTest : TypeTestHelper() { fun completableFuture(num: Int): CompletableFuture = CompletableFuture.completedFuture(num) fun dataFetchingEnvironment(environment: DataFetchingEnvironment): String = environment.field.name + + fun dataFetcherResult(): DataFetcherResult { + val error = ExceptionWhileDataFetching(ExecutionPath.rootPath(), RuntimeException(), SourceLocation(1, 1)) + return DataFetcherResult.newResult().data("Hello").error(error).build() + } + + fun listDataFetcherResult(): DataFetcherResult> = DataFetcherResult.newResult>().data(listOf("Hello")).build() + + fun nullalbeListDataFetcherResult(): DataFetcherResult?> = DataFetcherResult.newResult?>().data(listOf("Hello")).build() + + fun dataFetcherCompletableFutureResult(): DataFetcherResult> { + val completedFuture = CompletableFuture.completedFuture("Hello") + return DataFetcherResult.newResult>().data(completedFuture).build() + } + + fun completableFutureDataFetcherResult(): CompletableFuture> { + val dataFetcherResult = DataFetcherResult.newResult().data("Hello").build() + return CompletableFuture.completedFuture(dataFetcherResult) + } } @Test @@ -205,4 +232,54 @@ internal class FunctionBuilderTest : TypeTestHelper() { assertEquals(expected = 0, actual = result.arguments.size) assertEquals("String", (result.type as? GraphQLNonNull)?.wrappedType?.name) } + + @Test + fun `DataFetcherResult return type is valid and unwrapped in the schema`() { + val kFunction = Happy::dataFetcherResult + val result = builder.function(fn = kFunction, parentName = "Query", target = null, abstract = false) + + assertEquals("String", (result.type as? GraphQLNonNull)?.wrappedType?.name) + } + + @Test + fun `DataFetcherResult of a List is valid and unwrapped in the schema`() { + val kFunction = Happy::listDataFetcherResult + val result = builder.function(fn = kFunction, parentName = "Query", target = null, abstract = false) + + assertTrue(result.type is GraphQLNonNull) + val listType = GraphQLTypeUtil.unwrapNonNull(result.type) + assertTrue(listType is GraphQLList) + val stringType = GraphQLTypeUtil.unwrapNonNull(GraphQLTypeUtil.unwrapOne(listType)) + assertEquals("String", stringType.name) + } + + @Test + fun `DataFetcherResult of a nullable List is valid and unwrapped in the schema`() { + val kFunction = Happy::nullalbeListDataFetcherResult + val result = builder.function(fn = kFunction, parentName = "Query", target = null, abstract = false) + + val listType = result.type + assertTrue(listType is GraphQLList) + val stringType = listType.wrappedType + assertEquals("String", stringType.name) + } + + @Test + fun `DataFetcherResult of a CompletableFuture is invalid`() { + val kFunction = Happy::dataFetcherCompletableFutureResult + + assertFailsWith(TypeNotSupportedException::class) { + builder.function(fn = kFunction, parentName = "Query", target = null, abstract = false) + } + } + + @Test + fun `CompletableFuture of a DataFetcherResult is valid and unwrapped in the schema`() { + val kFunction = Happy::completableFutureDataFetcherResult + val result = builder.function(fn = kFunction, parentName = "Query", target = null, abstract = false) + + assertTrue(result.type is GraphQLNonNull) + val stringType = GraphQLTypeUtil.unwrapNonNull(result.type) + assertEquals("String", stringType.name) + } } diff --git a/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/types/utils/FunctionReturnTypesKtTest.kt b/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/types/utils/FunctionReturnTypesKtTest.kt new file mode 100644 index 000000000..357447ccd --- /dev/null +++ b/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/types/utils/FunctionReturnTypesKtTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2019 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.generator.types.utils + +import graphql.execution.DataFetcherResult +import io.reactivex.Flowable +import org.junit.jupiter.api.Test +import org.reactivestreams.Publisher +import java.util.concurrent.CompletableFuture +import kotlin.test.assertEquals + +internal class FunctionReturnTypesKtTest { + + internal class MyClass { + val string = "my string" + + val publisher: Publisher = Flowable.just(string) + val flowable: Flowable = Flowable.just(string) + val dataFetcherResult: DataFetcherResult = DataFetcherResult.newResult().data(string).build() + val completableFuture: CompletableFuture = CompletableFuture.completedFuture(string) + + val invalidPublisher: Publisher> = Flowable.just(dataFetcherResult) + val invalidDataFetcherResult: DataFetcherResult> = DataFetcherResult.newResult>().data(completableFuture).build() + val validCompletableFutureDataFetcher: CompletableFuture> = CompletableFuture.completedFuture(dataFetcherResult) + val invalidCompletableFuture: CompletableFuture> = CompletableFuture.completedFuture(publisher) + } + + @Test + fun `getWrappedReturnType of Publisher`() { + assertEquals(MyClass::string.returnType, actual = getWrappedReturnType(MyClass::publisher.returnType)) + assertEquals(MyClass::dataFetcherResult.returnType, actual = getWrappedReturnType(MyClass::invalidPublisher.returnType)) + } + + @Test + fun `getWrappedReturnType of DataFetcherResult`() { + assertEquals(MyClass::string.returnType, actual = getWrappedReturnType(MyClass::dataFetcherResult.returnType)) + assertEquals(MyClass::completableFuture.returnType, actual = getWrappedReturnType(MyClass::invalidDataFetcherResult.returnType)) + } + + @Test + fun `getWrappedReturnType of CompletableFuture`() { + assertEquals(MyClass::string.returnType, actual = getWrappedReturnType(MyClass::completableFuture.returnType)) + assertEquals(MyClass::string.returnType, actual = getWrappedReturnType(MyClass::validCompletableFutureDataFetcher.returnType)) + assertEquals(MyClass::publisher.returnType, actual = getWrappedReturnType(MyClass::invalidCompletableFuture.returnType)) + } + + @Test + fun `getWrappedReturnType of String`() { + assertEquals(MyClass::string.returnType, actual = getWrappedReturnType(MyClass::string.returnType)) + } + + @Test + fun `getWrappedReturnType of type that implements Publisher`() { + assertEquals(MyClass::string.returnType, actual = getWrappedReturnType(MyClass::flowable.returnType)) + } +}