Skip to content

Commit 0413950

Browse files
committed
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
1 parent a280215 commit 0413950

File tree

4 files changed

+317
-2
lines changed

4 files changed

+317
-2
lines changed

spring-graphql/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ dependencies {
2828

2929
compileOnly 'com.google.code.findbugs:jsr305'
3030
compileOnly 'org.jetbrains.kotlin:kotlin-stdlib'
31+
compileOnly "org.jetbrains.kotlin:kotlin-reflect"
3132
compileOnly 'org.jetbrains.kotlinx:kotlinx-coroutines-core'
3233

3334
compileOnly 'com.fasterxml.jackson.core:jackson-databind'
@@ -43,6 +44,8 @@ dependencies {
4344
testImplementation 'org.mockito:mockito-core'
4445
testImplementation 'org.awaitility:awaitility'
4546
testImplementation 'io.projectreactor:reactor-test'
47+
testImplementation "org.jetbrains.kotlin:kotlin-reflect"
48+
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-reactor'
4649
testImplementation 'org.springframework:spring-core-test'
4750
testImplementation 'org.springframework:spring-messaging'
4851
testImplementation 'org.springframework:spring-test'

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@
4545
import graphql.schema.GraphQLCodeRegistry;
4646
import graphql.schema.idl.RuntimeWiring;
4747
import graphql.schema.idl.TypeDefinitionRegistry;
48+
import kotlin.jvm.JvmClassMappingKt;
49+
import kotlin.reflect.KFunction;
50+
import kotlin.reflect.KType;
51+
import kotlin.reflect.full.KClassifiers;
52+
import kotlin.reflect.full.KTypes;
53+
import kotlin.reflect.jvm.ReflectJvmMapping;
54+
import kotlinx.coroutines.flow.Flow;
4855
import org.dataloader.DataLoader;
4956
import org.reactivestreams.Publisher;
5057
import reactor.core.publisher.Flux;
@@ -306,7 +313,14 @@ protected DataFetcherMappingInfo getMappingInfo(Method method, Object handler, C
306313
}
307314
else {
308315
if (Collection.class.isAssignableFrom(parameter.getParameterType())) {
309-
typeName = parameter.nested().getNestedParameterType().getSimpleName();
316+
Class<?> type = parameter.nested().getNestedParameterType();
317+
if (Object.class.equals(type)) {
318+
// Maybe a Kotlin List
319+
type = ResolvableType.forMethodParameter(parameter).getNested(2).resolve(Object.class);
320+
}
321+
if (!Object.class.equals(type)) {
322+
typeName = type.getSimpleName();
323+
}
310324
break;
311325
}
312326
}
@@ -356,13 +370,16 @@ private DataFetcher<Object> registerBatchLoader(DataFetcherMappingInfo info) {
356370

357371
MethodParameter returnType = handlerMethod.getReturnType();
358372
Class<?> clazz = returnType.getParameterType();
373+
Method method = handlerMethod.getMethod();
359374

360375
if (clazz.equals(Callable.class)) {
361376
returnType = returnType.nested();
362377
clazz = returnType.getNestedParameterType();
363378
}
364379

365-
if (clazz.equals(Flux.class) || Collection.class.isAssignableFrom(clazz)) {
380+
if (clazz.equals(Flux.class) || Collection.class.isAssignableFrom(clazz) ||
381+
(KotlinDetector.isSuspendingFunction(method) && KotlinDelegate.isFlowReturnType(method))) {
382+
366383
registration.registerBatchLoader(invocable::invokeForIterable);
367384
ResolvableType valueType = ResolvableType.forMethodParameter(returnType.nested());
368385
return new BatchMappingDataFetcher(info, valueType, dataLoaderKey);
@@ -614,4 +631,20 @@ Set<DataFetcherMappingInfo> filterExistingMappings(
614631
}
615632
}
616633

634+
635+
/**
636+
* Inner class to avoid a hard dependency on Kotlin at runtime.
637+
*/
638+
private static final class KotlinDelegate {
639+
640+
private static final KType flowType =
641+
KClassifiers.getStarProjectedType(JvmClassMappingKt.getKotlinClass(Flow.class));
642+
643+
static boolean isFlowReturnType(Method method) {
644+
KFunction<?> function = ReflectJvmMapping.getKotlinFunction(method);
645+
return (function != null && KTypes.isSubtypeOf(function.getReturnType(), flowType));
646+
}
647+
648+
}
649+
617650
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
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 org.springframework.graphql.data.method.annotation.support
18+
19+
import kotlinx.coroutines.delay
20+
import kotlinx.coroutines.flow.Flow
21+
import kotlinx.coroutines.flow.flow
22+
import org.assertj.core.api.Assertions.assertThat
23+
import org.junit.jupiter.params.ParameterizedTest
24+
import org.junit.jupiter.params.provider.MethodSource
25+
import org.springframework.context.annotation.AnnotationConfigApplicationContext
26+
import org.springframework.core.task.SimpleAsyncTaskExecutor
27+
import org.springframework.graphql.*
28+
import org.springframework.graphql.data.method.annotation.Argument
29+
import org.springframework.graphql.data.method.annotation.BatchMapping
30+
import org.springframework.graphql.data.method.annotation.QueryMapping
31+
import org.springframework.graphql.execution.BatchLoaderRegistry
32+
import org.springframework.graphql.execution.DefaultBatchLoaderRegistry
33+
import org.springframework.stereotype.Controller
34+
import java.util.function.Function
35+
import java.util.function.Supplier
36+
import java.util.stream.Collectors
37+
38+
/**
39+
* Kotlin tests for GraphQL requests handled with {@code @BatchMapping} methods.
40+
*
41+
* @author Rossen Stoyanchev
42+
*/
43+
class BatchMappingInvocationKotlinTests {
44+
45+
companion object {
46+
47+
@JvmStatic
48+
fun argumentSource() = listOf(
49+
CoroutineBatchController::class.java,
50+
FlowBatchController::class.java
51+
)
52+
}
53+
54+
@ParameterizedTest
55+
@MethodSource("argumentSource")
56+
fun queryWithObjectArgument(controllerClass: Class<*>) {
57+
val document = """
58+
{ booksByCriteria(criteria: {author:"Orwell"}) {id, name, author {firstName, lastName}}}
59+
"""
60+
61+
val responseMono = graphQlService(controllerClass).execute(document)
62+
63+
val bookList = ResponseHelper.forResponse(responseMono).toList("booksByCriteria", Book::class.java)
64+
assertThat(bookList).hasSize(2)
65+
66+
assertThat(bookList[0].name).isEqualTo("Nineteen Eighty-Four")
67+
assertThat(bookList[0].author.firstName).isEqualTo("George")
68+
assertThat(bookList[0].author.lastName).isEqualTo("Orwell")
69+
70+
assertThat(bookList[1].name).isEqualTo("Animal Farm")
71+
assertThat(bookList[1].author.firstName).isEqualTo("George")
72+
assertThat(bookList[1].author.lastName).isEqualTo("Orwell")
73+
}
74+
75+
76+
private fun graphQlService(controllerClass: Class<*>): TestExecutionGraphQlService {
77+
val registry: BatchLoaderRegistry = DefaultBatchLoaderRegistry()
78+
79+
val context = AnnotationConfigApplicationContext()
80+
context.register(controllerClass)
81+
context.registerBean(BatchLoaderRegistry::class.java, Supplier { registry })
82+
context.refresh()
83+
84+
val configurer = AnnotatedControllerConfigurer()
85+
configurer.setExecutor(SimpleAsyncTaskExecutor())
86+
configurer.setApplicationContext(context)
87+
configurer.afterPropertiesSet()
88+
89+
val setup = GraphQlSetup.schemaResource(BookSource.schema).runtimeWiring(configurer)
90+
91+
return setup.dataLoaders(registry).toGraphQlService()
92+
}
93+
94+
95+
open class BookController {
96+
@QueryMapping
97+
fun booksByCriteria(@Argument criteria: BookCriteria): List<Book> {
98+
return BookSource.findBooksByAuthor(criteria.author).stream()
99+
.map { BookSource.getBookWithoutAuthor(it.id) }
100+
.toList()
101+
}
102+
}
103+
104+
@Controller
105+
class CoroutineBatchController : BookController() {
106+
107+
@BatchMapping
108+
suspend fun author(books: List<Book>): Map<Book, Author> {
109+
delay(100)
110+
return books.stream().collect(
111+
Collectors.toMap(Function.identity()) { b: Book -> BookSource.getAuthor(b.authorId) }
112+
)
113+
}
114+
}
115+
116+
117+
@Controller
118+
class FlowBatchController : BookController() {
119+
120+
@BatchMapping
121+
suspend fun author(books: List<Book>): Flow<Author> {
122+
return flow {
123+
delay(100)
124+
for (book in books) {
125+
emit(BookSource.getAuthor(book.getAuthorId()))
126+
}
127+
}
128+
}
129+
}
130+
131+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
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 org.springframework.graphql.data.method.annotation.support
18+
19+
import kotlinx.coroutines.delay
20+
import kotlinx.coroutines.flow.Flow
21+
import kotlinx.coroutines.flow.flow
22+
import org.assertj.core.api.Assertions
23+
import org.junit.jupiter.api.Test
24+
import org.springframework.context.annotation.AnnotationConfigApplicationContext
25+
import org.springframework.core.task.SimpleAsyncTaskExecutor
26+
import org.springframework.graphql.*
27+
import org.springframework.graphql.data.method.annotation.Argument
28+
import org.springframework.graphql.data.method.annotation.QueryMapping
29+
import org.springframework.graphql.data.method.annotation.SchemaMapping
30+
import org.springframework.graphql.data.method.annotation.SubscriptionMapping
31+
import org.springframework.graphql.execution.BatchLoaderRegistry
32+
import org.springframework.graphql.execution.DefaultBatchLoaderRegistry
33+
import org.springframework.stereotype.Controller
34+
import reactor.test.StepVerifier
35+
import java.util.function.Supplier
36+
37+
/**
38+
* Kotlin tests for GraphQL requests handled with {@code @SchemaMapping} methods.
39+
*
40+
* @author Rossen Stoyanchev
41+
*/
42+
class SchemaMappingInvocationKotlinTests {
43+
44+
@Test
45+
fun queryWithScalarArgument() {
46+
val document = """
47+
{ bookById(id:"1") {id, name, author {firstName, lastName}}}
48+
"""
49+
50+
val responseMono = graphQlService().execute(document)
51+
52+
val book = ResponseHelper.forResponse(responseMono).toEntity("bookById", Book::class.java)
53+
Assertions.assertThat(book.id).isEqualTo(1)
54+
Assertions.assertThat(book.name).isEqualTo("Nineteen Eighty-Four")
55+
56+
val author = book.author
57+
Assertions.assertThat(author.firstName).isEqualTo("George")
58+
Assertions.assertThat(author.lastName).isEqualTo("Orwell")
59+
}
60+
61+
@Test
62+
fun queryWithObjectArgument() {
63+
val document = """
64+
{ booksByCriteria(criteria: {author:"Orwell"}) {id, name}}
65+
"""
66+
67+
val responseMono = graphQlService().execute(document)
68+
69+
val bookList = ResponseHelper.forResponse(responseMono).toList("booksByCriteria", Book::class.java)
70+
Assertions.assertThat(bookList).hasSize(2)
71+
Assertions.assertThat(bookList[0].name).isEqualTo("Nineteen Eighty-Four")
72+
Assertions.assertThat(bookList[1].name).isEqualTo("Animal Farm")
73+
}
74+
75+
@Test
76+
fun subscription() {
77+
val document = """
78+
subscription {bookSearch(author:"Orwell") {id, name}}
79+
"""
80+
81+
val responseMono = graphQlService().execute(document)
82+
83+
val bookFlux = ResponseHelper.forSubscription(responseMono)
84+
.map { response: ResponseHelper -> response.toEntity("bookSearch", Book::class.java) }
85+
86+
StepVerifier.create(bookFlux)
87+
.consumeNextWith { book: Book ->
88+
Assertions.assertThat(book.id).isEqualTo(1)
89+
Assertions.assertThat(book.name).isEqualTo("Nineteen Eighty-Four")
90+
}
91+
.consumeNextWith { book: Book ->
92+
Assertions.assertThat(book.id).isEqualTo(5)
93+
Assertions.assertThat(book.name).isEqualTo("Animal Farm")
94+
}
95+
.verifyComplete()
96+
}
97+
98+
private fun graphQlService(): TestExecutionGraphQlService {
99+
val registry: BatchLoaderRegistry = DefaultBatchLoaderRegistry()
100+
101+
val context = AnnotationConfigApplicationContext()
102+
context.register(BookController::class.java)
103+
context.registerBean(BatchLoaderRegistry::class.java, Supplier { registry })
104+
context.refresh()
105+
106+
val configurer = AnnotatedControllerConfigurer()
107+
configurer.setExecutor(SimpleAsyncTaskExecutor())
108+
configurer.setApplicationContext(context)
109+
configurer.afterPropertiesSet()
110+
111+
val setup = GraphQlSetup.schemaResource(BookSource.schema).runtimeWiring(configurer)
112+
113+
return setup.dataLoaders(registry).toGraphQlService()
114+
}
115+
116+
117+
@Controller
118+
class BookController {
119+
120+
@QueryMapping
121+
suspend fun bookById(@Argument id: Long): Book {
122+
delay(50)
123+
return BookSource.getBookWithoutAuthor(id)
124+
}
125+
126+
@QueryMapping
127+
fun booksByCriteria(@Argument criteria: BookCriteria): List<Book> {
128+
return BookSource.findBooksByAuthor(criteria.author)
129+
}
130+
131+
@SubscriptionMapping
132+
suspend fun bookSearch(@Argument author : String): Flow<Book> {
133+
return flow {
134+
for (book in BookSource.findBooksByAuthor(author)) {
135+
delay(10)
136+
emit(BookSource.getBookWithoutAuthor(book.id))
137+
}
138+
}
139+
}
140+
141+
@SchemaMapping
142+
suspend fun author(book: Book): Author {
143+
delay(50)
144+
return BookSource.getAuthor(book.authorId)
145+
}
146+
}
147+
148+
}

0 commit comments

Comments
 (0)