Skip to content

Commit

Permalink
Improved support and tests for Kotlin controllers
Browse files Browse the repository at this point in the history
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
  • Loading branch information
rstoyanchev committed Apr 30, 2024
1 parent a280215 commit 0413950
Show file tree
Hide file tree
Showing 4 changed files with 317 additions and 2 deletions.
3 changes: 3 additions & 0 deletions spring-graphql/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@
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;
Expand Down Expand Up @@ -306,7 +313,14 @@ protected DataFetcherMappingInfo getMappingInfo(Method method, Object handler, C
}
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;
}
}
Expand Down Expand Up @@ -356,13 +370,16 @@ private DataFetcher<Object> registerBatchLoader(DataFetcherMappingInfo info) {

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);
Expand Down Expand Up @@ -614,4 +631,20 @@ Set<DataFetcherMappingInfo> filterExistingMappings(
}
}


/**
* 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));
}

}

}
Original file line number Diff line number Diff line change
@@ -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()))
}
}
}
}

}
Original file line number Diff line number Diff line change
@@ -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)
}
}

}

0 comments on commit 0413950

Please sign in to comment.