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:
@@ -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'
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user