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
This commit is contained in:
rstoyanchev
2024-04-30 19:56:58 +01:00
parent a280215e1f
commit 0413950b2e
4 changed files with 317 additions and 2 deletions

View File

@@ -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'

View File

@@ -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));
}
}
}

View File

@@ -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<Book> {
return BookSource.findBooksByAuthor(criteria.author).stream()
.map { BookSource.getBookWithoutAuthor(it.id) }
.toList()
}
}
@Controller
class CoroutineBatchController : BookController() {
@BatchMapping
suspend fun author(books: List<Book>): Map<Book, Author> {
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<Book>): Flow<Author> {
return flow {
delay(100)
for (book in books) {
emit(BookSource.getAuthor(book.getAuthorId()))
}
}
}
}
}

View File

@@ -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<Book> {
return BookSource.findBooksByAuthor(criteria.author)
}
@SubscriptionMapping
suspend fun bookSearch(@Argument author : String): Flow<Book> {
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)
}
}
}