From 0413950b2e7eae6fb79fec9393620e32560d7d2e Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 30 Apr 2024 19:56:58 +0100 Subject: [PATCH] Improved support and tests for Kotlin controllers Mostly it worked already due to the use of CoroutinesUtils.invokeSuspendingFunction, except for a couple of issues with BatchMapping detection on startup. Closes gh-954 --- spring-graphql/build.gradle | 3 + .../AnnotatedControllerConfigurer.java | 37 ++++- .../BatchMappingInvocationKotlinTests.kt | 131 ++++++++++++++++ .../SchemaMappingInvocationKotlinTests.kt | 148 ++++++++++++++++++ 4 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 spring-graphql/src/test/kotlin/org/springframework/graphql/data/method/annotation/support/BatchMappingInvocationKotlinTests.kt create mode 100644 spring-graphql/src/test/kotlin/org/springframework/graphql/data/method/annotation/support/SchemaMappingInvocationKotlinTests.kt diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index 636496c0..bc32bb67 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -28,6 +28,7 @@ dependencies { compileOnly 'com.google.code.findbugs:jsr305' compileOnly 'org.jetbrains.kotlin:kotlin-stdlib' + compileOnly "org.jetbrains.kotlin:kotlin-reflect" compileOnly 'org.jetbrains.kotlinx:kotlinx-coroutines-core' compileOnly 'com.fasterxml.jackson.core:jackson-databind' @@ -43,6 +44,8 @@ dependencies { testImplementation 'org.mockito:mockito-core' testImplementation 'org.awaitility:awaitility' testImplementation 'io.projectreactor:reactor-test' + testImplementation "org.jetbrains.kotlin:kotlin-reflect" + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-reactor' testImplementation 'org.springframework:spring-core-test' testImplementation 'org.springframework:spring-messaging' testImplementation 'org.springframework:spring-test' diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java index 9ab4f54f..f4dbcea8 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java @@ -45,6 +45,13 @@ import graphql.schema.FieldCoordinates; import graphql.schema.GraphQLCodeRegistry; import graphql.schema.idl.RuntimeWiring; import graphql.schema.idl.TypeDefinitionRegistry; +import kotlin.jvm.JvmClassMappingKt; +import kotlin.reflect.KFunction; +import kotlin.reflect.KType; +import kotlin.reflect.full.KClassifiers; +import kotlin.reflect.full.KTypes; +import kotlin.reflect.jvm.ReflectJvmMapping; +import kotlinx.coroutines.flow.Flow; import org.dataloader.DataLoader; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; @@ -306,7 +313,14 @@ public class AnnotatedControllerConfigurer } else { if (Collection.class.isAssignableFrom(parameter.getParameterType())) { - typeName = parameter.nested().getNestedParameterType().getSimpleName(); + Class type = parameter.nested().getNestedParameterType(); + if (Object.class.equals(type)) { + // Maybe a Kotlin List + type = ResolvableType.forMethodParameter(parameter).getNested(2).resolve(Object.class); + } + if (!Object.class.equals(type)) { + typeName = type.getSimpleName(); + } break; } } @@ -356,13 +370,16 @@ public class AnnotatedControllerConfigurer MethodParameter returnType = handlerMethod.getReturnType(); Class clazz = returnType.getParameterType(); + Method method = handlerMethod.getMethod(); if (clazz.equals(Callable.class)) { returnType = returnType.nested(); clazz = returnType.getNestedParameterType(); } - if (clazz.equals(Flux.class) || Collection.class.isAssignableFrom(clazz)) { + if (clazz.equals(Flux.class) || Collection.class.isAssignableFrom(clazz) || + (KotlinDetector.isSuspendingFunction(method) && KotlinDelegate.isFlowReturnType(method))) { + registration.registerBatchLoader(invocable::invokeForIterable); ResolvableType valueType = ResolvableType.forMethodParameter(returnType.nested()); return new BatchMappingDataFetcher(info, valueType, dataLoaderKey); @@ -614,4 +631,20 @@ public class AnnotatedControllerConfigurer } } + + /** + * Inner class to avoid a hard dependency on Kotlin at runtime. + */ + private static final class KotlinDelegate { + + private static final KType flowType = + KClassifiers.getStarProjectedType(JvmClassMappingKt.getKotlinClass(Flow.class)); + + static boolean isFlowReturnType(Method method) { + KFunction function = ReflectJvmMapping.getKotlinFunction(method); + return (function != null && KTypes.isSubtypeOf(function.getReturnType(), flowType)); + } + + } + } diff --git a/spring-graphql/src/test/kotlin/org/springframework/graphql/data/method/annotation/support/BatchMappingInvocationKotlinTests.kt b/spring-graphql/src/test/kotlin/org/springframework/graphql/data/method/annotation/support/BatchMappingInvocationKotlinTests.kt new file mode 100644 index 00000000..a80f1d39 --- /dev/null +++ b/spring-graphql/src/test/kotlin/org/springframework/graphql/data/method/annotation/support/BatchMappingInvocationKotlinTests.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * 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 org.springframework.graphql.data.method.annotation.support + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.springframework.context.annotation.AnnotationConfigApplicationContext +import org.springframework.core.task.SimpleAsyncTaskExecutor +import org.springframework.graphql.* +import org.springframework.graphql.data.method.annotation.Argument +import org.springframework.graphql.data.method.annotation.BatchMapping +import org.springframework.graphql.data.method.annotation.QueryMapping +import org.springframework.graphql.execution.BatchLoaderRegistry +import org.springframework.graphql.execution.DefaultBatchLoaderRegistry +import org.springframework.stereotype.Controller +import java.util.function.Function +import java.util.function.Supplier +import java.util.stream.Collectors + +/** + * Kotlin tests for GraphQL requests handled with {@code @BatchMapping} methods. + * + * @author Rossen Stoyanchev + */ +class BatchMappingInvocationKotlinTests { + + companion object { + + @JvmStatic + fun argumentSource() = listOf( + CoroutineBatchController::class.java, + FlowBatchController::class.java + ) + } + + @ParameterizedTest + @MethodSource("argumentSource") + fun queryWithObjectArgument(controllerClass: Class<*>) { + val document = """ + { booksByCriteria(criteria: {author:"Orwell"}) {id, name, author {firstName, lastName}}} + """ + + val responseMono = graphQlService(controllerClass).execute(document) + + val bookList = ResponseHelper.forResponse(responseMono).toList("booksByCriteria", Book::class.java) + assertThat(bookList).hasSize(2) + + assertThat(bookList[0].name).isEqualTo("Nineteen Eighty-Four") + assertThat(bookList[0].author.firstName).isEqualTo("George") + assertThat(bookList[0].author.lastName).isEqualTo("Orwell") + + assertThat(bookList[1].name).isEqualTo("Animal Farm") + assertThat(bookList[1].author.firstName).isEqualTo("George") + assertThat(bookList[1].author.lastName).isEqualTo("Orwell") + } + + + private fun graphQlService(controllerClass: Class<*>): TestExecutionGraphQlService { + val registry: BatchLoaderRegistry = DefaultBatchLoaderRegistry() + + val context = AnnotationConfigApplicationContext() + context.register(controllerClass) + context.registerBean(BatchLoaderRegistry::class.java, Supplier { registry }) + context.refresh() + + val configurer = AnnotatedControllerConfigurer() + configurer.setExecutor(SimpleAsyncTaskExecutor()) + configurer.setApplicationContext(context) + configurer.afterPropertiesSet() + + val setup = GraphQlSetup.schemaResource(BookSource.schema).runtimeWiring(configurer) + + return setup.dataLoaders(registry).toGraphQlService() + } + + + open class BookController { + @QueryMapping + fun booksByCriteria(@Argument criteria: BookCriteria): List { + return BookSource.findBooksByAuthor(criteria.author).stream() + .map { BookSource.getBookWithoutAuthor(it.id) } + .toList() + } + } + + @Controller + class CoroutineBatchController : BookController() { + + @BatchMapping + suspend fun author(books: List): Map { + delay(100) + return books.stream().collect( + Collectors.toMap(Function.identity()) { b: Book -> BookSource.getAuthor(b.authorId) } + ) + } + } + + + @Controller + class FlowBatchController : BookController() { + + @BatchMapping + suspend fun author(books: List): Flow { + return flow { + delay(100) + for (book in books) { + emit(BookSource.getAuthor(book.getAuthorId())) + } + } + } + } + +} \ No newline at end of file diff --git a/spring-graphql/src/test/kotlin/org/springframework/graphql/data/method/annotation/support/SchemaMappingInvocationKotlinTests.kt b/spring-graphql/src/test/kotlin/org/springframework/graphql/data/method/annotation/support/SchemaMappingInvocationKotlinTests.kt new file mode 100644 index 00000000..708e6bb9 --- /dev/null +++ b/spring-graphql/src/test/kotlin/org/springframework/graphql/data/method/annotation/support/SchemaMappingInvocationKotlinTests.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * 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 org.springframework.graphql.data.method.annotation.support + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.springframework.context.annotation.AnnotationConfigApplicationContext +import org.springframework.core.task.SimpleAsyncTaskExecutor +import org.springframework.graphql.* +import org.springframework.graphql.data.method.annotation.Argument +import org.springframework.graphql.data.method.annotation.QueryMapping +import org.springframework.graphql.data.method.annotation.SchemaMapping +import org.springframework.graphql.data.method.annotation.SubscriptionMapping +import org.springframework.graphql.execution.BatchLoaderRegistry +import org.springframework.graphql.execution.DefaultBatchLoaderRegistry +import org.springframework.stereotype.Controller +import reactor.test.StepVerifier +import java.util.function.Supplier + +/** + * Kotlin tests for GraphQL requests handled with {@code @SchemaMapping} methods. + * + * @author Rossen Stoyanchev + */ +class SchemaMappingInvocationKotlinTests { + + @Test + fun queryWithScalarArgument() { + val document = """ + { bookById(id:"1") {id, name, author {firstName, lastName}}} + """ + + val responseMono = graphQlService().execute(document) + + val book = ResponseHelper.forResponse(responseMono).toEntity("bookById", Book::class.java) + Assertions.assertThat(book.id).isEqualTo(1) + Assertions.assertThat(book.name).isEqualTo("Nineteen Eighty-Four") + + val author = book.author + Assertions.assertThat(author.firstName).isEqualTo("George") + Assertions.assertThat(author.lastName).isEqualTo("Orwell") + } + + @Test + fun queryWithObjectArgument() { + val document = """ + { booksByCriteria(criteria: {author:"Orwell"}) {id, name}} + """ + + val responseMono = graphQlService().execute(document) + + val bookList = ResponseHelper.forResponse(responseMono).toList("booksByCriteria", Book::class.java) + Assertions.assertThat(bookList).hasSize(2) + Assertions.assertThat(bookList[0].name).isEqualTo("Nineteen Eighty-Four") + Assertions.assertThat(bookList[1].name).isEqualTo("Animal Farm") + } + + @Test + fun subscription() { + val document = """ + subscription {bookSearch(author:"Orwell") {id, name}} + """ + + val responseMono = graphQlService().execute(document) + + val bookFlux = ResponseHelper.forSubscription(responseMono) + .map { response: ResponseHelper -> response.toEntity("bookSearch", Book::class.java) } + + StepVerifier.create(bookFlux) + .consumeNextWith { book: Book -> + Assertions.assertThat(book.id).isEqualTo(1) + Assertions.assertThat(book.name).isEqualTo("Nineteen Eighty-Four") + } + .consumeNextWith { book: Book -> + Assertions.assertThat(book.id).isEqualTo(5) + Assertions.assertThat(book.name).isEqualTo("Animal Farm") + } + .verifyComplete() + } + + private fun graphQlService(): TestExecutionGraphQlService { + val registry: BatchLoaderRegistry = DefaultBatchLoaderRegistry() + + val context = AnnotationConfigApplicationContext() + context.register(BookController::class.java) + context.registerBean(BatchLoaderRegistry::class.java, Supplier { registry }) + context.refresh() + + val configurer = AnnotatedControllerConfigurer() + configurer.setExecutor(SimpleAsyncTaskExecutor()) + configurer.setApplicationContext(context) + configurer.afterPropertiesSet() + + val setup = GraphQlSetup.schemaResource(BookSource.schema).runtimeWiring(configurer) + + return setup.dataLoaders(registry).toGraphQlService() + } + + + @Controller + class BookController { + + @QueryMapping + suspend fun bookById(@Argument id: Long): Book { + delay(50) + return BookSource.getBookWithoutAuthor(id) + } + + @QueryMapping + fun booksByCriteria(@Argument criteria: BookCriteria): List { + return BookSource.findBooksByAuthor(criteria.author) + } + + @SubscriptionMapping + suspend fun bookSearch(@Argument author : String): Flow { + return flow { + for (book in BookSource.findBooksByAuthor(author)) { + delay(10) + emit(BookSource.getBookWithoutAuthor(book.id)) + } + } + } + + @SchemaMapping + suspend fun author(book: Book): Author { + delay(50) + return BookSource.getAuthor(book.authorId) + } + } + +} \ No newline at end of file