graphql-java-extended-scalars 에서 제공해주는 UUID
type 을 field 로 가지고 있는 input 을 통해 query 를 날릴 경우,
실제 graphql 요청 시 잘 조회가 되지만 code-gen
플러그인을 통한 client 클래스를 이용하여 JUnit 테스트를 할 경우 발생하는 에러에 대한 해결 방법을 소개하는 글입니다.
plugins {
...
id("com.netflix.dgs.codegen") version "5.4.0"
}
dependencies {
...
implementation(platform("com.netflix.graphql.dgs:graphql-dgs-platform-dependencies:5.3.0"))
implementation("com.netflix.graphql.dgs:graphql-dgs-spring-boot-starter")
implementation("com.netflix.graphql.dgs:graphql-dgs-extended-scalars") // !!! 중요
...
}
tasks.withType<GenerateJavaTask>{
typeMapping = mutableMapOf(
"UUID" to "java.util.UUID",
)
generateClient = true
}
scalar UUID
type Query {
user(
input: GetUserInput!
): User
}
type Mutation {
createUser(
input: CreateUserInput!
): User
}
type User {
id: UUID!
name: String
age: Int
}
input CreateUserInput {
name: String
age: Int
}
input GetUserInput {
id: UUID!
}
@DgsComponent
class UserFetcher {
private val userList = mutableListOf<User>()
@DgsMutation
fun createUser(@InputArgument input: CreateUserInput): User {
val user = User(
id = UUID.randomUUID(),
name = input.name,
age = input.age
)
userList.add(
user
)
return user
}
@DgsQuery
fun user(@InputArgument input: GetUserInput): User? {
return userList.find { it.id == input.id }
}
}
...
input GetUserInput {
id: UUID!
}
에서 볼 수 있듯이, id 타입은 UUID
이며, 요청에 성공하는 것을 확인할 수 있습니다.
하지만 JUnit 에서 다음과 같이 codegen plugin 에 의해 생성된 client class 들로 테스트를 하면 다음과같은 에러가 발생합니다.
package com.vitamax.gqldgsdemo
import com.jayway.jsonpath.TypeRef
import com.netflix.dgs.codegen.generated.client.CreateUserGraphQLQuery
import com.netflix.dgs.codegen.generated.client.CreateUserProjectionRoot
import com.netflix.dgs.codegen.generated.client.UserGraphQLQuery
import com.netflix.dgs.codegen.generated.client.UserProjectionRoot
import com.netflix.dgs.codegen.generated.types.CreateUserInput
import com.netflix.dgs.codegen.generated.types.GetUserInput
import com.netflix.dgs.codegen.generated.types.User
import com.netflix.graphql.dgs.DgsQueryExecutor
import com.netflix.graphql.dgs.client.codegen.GraphQLQueryRequest
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
internal class UserTest {
@Autowired
private lateinit var dgsQueryExecutor: DgsQueryExecutor
@Test
fun `generated client classes fail with uuid type field`() {
val name = "Max"
val age = 15
val createRequest =
GraphQLQueryRequest(
query = CreateUserGraphQLQuery.Builder().input(
CreateUserInput(
name = name,
age = age
)
).build(),
projection = CreateUserProjectionRoot().id().name().age()
)
val createdUser = dgsQueryExecutor.executeAndExtractJsonPathAsObject(
createRequest.serialize(),
"data.createUser",
object : TypeRef<User>() {}
)
createdUser.id shouldNotBe null
createdUser.name shouldBe name
createdUser.age shouldBe age
val getRequest =
GraphQLQueryRequest(
query = UserGraphQLQuery.Builder().input(
GetUserInput(
id = createdUser.id
)
).build(),
projection = UserProjectionRoot().id().name().age()
)
val fetchedUser = dgsQueryExecutor.executeAndExtractJsonPathAsObject(
getRequest.serialize(),
"data.user",
object : TypeRef<User>() {}
)
fetchedUser.id shouldBe createdUser.id
fetchedUser.name shouldBe createdUser.name
fetchedUser.age shouldBe createdUser.age
}
}
2022-12-13 02:31:14.367 WARN 41832 --- [ Test worker] notprivacysafe.graphql.GraphQL : Query did not validate : 'query {
user(input: {id : {mostSigBits : -2605378752402207655, leastSigBits : -8031803200137982341}}) {
id
name
age
}
}'
Validation error of type WrongType: argument 'input.id' with value 'ObjectValue{objectFields=[ObjectField{name='mostSigBits', value=IntValue{value=-2605378752402207655}}, ObjectField{name='leastSigBits', value=IntValue{value=-8031803200137982341}}]}' is not a valid 'UUID' - Expected a 'java.util.UUID' AST type object but was 'ObjectValue'. @ 'user'
com.netflix.graphql.dgs.exceptions.QueryException: Validation error of type WrongType: argument 'input.id' with value 'ObjectValue{objectFields=[ObjectField{name='mostSigBits', value=IntValue{value=-2605378752402207655}}, ObjectField{name='leastSigBits', value=IntValue{value=-8031803200137982341}}]}' is not a valid 'UUID' - Expected a 'java.util.UUID' AST type object but was 'ObjectValue'. @ 'user'
at app//com.netflix.graphql.dgs.internal.DefaultDgsQueryExecutor.getJsonResult(DefaultDgsQueryExecutor.kt:167)
at app//com.netflix.graphql.dgs.internal.DefaultDgsQueryExecutor.getJsonResult$default(DefaultDgsQueryExecutor.kt:163)
얼핏 봐도 input.id
에 이상한 값이 들어간게 보입니다.
문제는 request.serialize() 하는 부분에서 발생했습니다.
fun serialize(): String {
...
if (query.input.isNotEmpty()) {
selection.arguments(
query.input.map { (name, value) ->
// input 값을 toString 해주는 부분
Argument(name, inputValueSerializer.toValue(value))
}
)
}
inputValueSerializer.toValue(value) 이 어떻게 toValue 해주고 있는지 파고 들어가보니,
fun toValue(input: Any?): Value<*> {
if (input == null) {
return NullValue.newNullValue().build()
}
if (input is Value<*>) {
return input
}
if (input::class.java in scalars) {
return scalars.getValue(input::class.java).valueToLiteral(input)
}
...
val classes = sequenceOf(input::class) + input::class.allSuperclasses.asSequence() - Any::class
val propertyValues = mutableMapOf<String, Any?>()
for (klass in classes) {
for (property in klass.memberProperties) {
if (property.name in propertyValues || property.isAbstract || property.hasAnnotation<Transient>()) {
continue
}
property.isAccessible = true
propertyValues[property.name] = property.call(input)
}
}
val objectFields = propertyValues.asSequence()
.filter { (_, value) -> value != null }
.map { (name, value) -> ObjectField(name, toValue(value)) }
.toList()
InputValueSerializer
클래스에서 미리 정의되지 않은 Scalar Type 들은 멤버변수들이 나올때까지 루프를 돌면서 해주고 있네요.
public final class UUID implements java.io.Serializable, Comparable<UUID> {
/**
* Explicit serialVersionUID for interoperability.
*/
private static final long serialVersionUID = -4856846361193249489L;
/*
* The most significant 64 bits of this UUID.
*
* @serial
*/
private final long mostSigBits;
/*
* The least significant 64 bits of this UUID.
*
* @serial
*/
private final long leastSigBits;
위와 같이 UUID 클래스는 mostSigBits, leastSigBits 라는 필드들을 가지고 있어서 아까 봤던 에러와 같이 이상한 모양으로 toValue 화 된것을 확인할 수 있었습니다.
해결 방법은 간단합니다.
GraphQLQueryRequest 생성 시 마지막 인자인 scalar 를 추가해주면 됩니다.
class GraphQLQueryRequest(
val query: GraphQLQuery,
val projection: BaseProjectionNode?,
scalars: Map<Class<*>, Coercing<*, *>>?
) {
constructor(query: GraphQLQuery) : this(query, null, null)
constructor(query: GraphQLQuery, projection: BaseProjectionNode?) : this(query, projection, null)
val inputValueSerializer = InputValueSerializer(scalars ?: emptyMap())
val projectionSerializer = ProjectionSerializer(inputValueSerializer)
scalars 를 추가해줌으로써 inputValueSerializer
가 인식하여 UUID 클래스의 toValue 를 사전에 등록되어 있는 ExtendedScalars.UUID
의 valueToLiteral() 로 대체할 수 있습니다.
...
val getRequest =
GraphQLQueryRequest(
query = UserGraphQLQuery.Builder().input(
GetUserInput(
id = createdUser.id
)
).build(),
projection = UserProjectionRoot().id().name().age(),
scalars = mapOf(UUID::class.java to ExtendedScalars.UUID.coercing)
)
val fetchedUser = dgsQueryExecutor.executeAndExtractJsonPathAsObject(
getRequest.serialize(),
"data.user",
object : TypeRef<User>() {}
)
fetchedUser.id shouldBe createdUser.id
fetchedUser.name shouldBe createdUser.name
fetchedUser.age shouldBe createdUser.age
}
}
위와같이 scalars = mapOf(UUID::class.java to ExtendedScalars.UUID.coercing)
만 추가해줌으로써 간단히 해결하였습니다. 다른 Extended Scalar Type 들도 마찬가지로, 종종 Junit 테스트를 할 때 실패하는 경우가 종종 있는데, scalars 에 추가가 안된것이 아닌지 확인해보시면 좋을 것 같습니다.
위 코드들은 github 에 올려놓았으니 참고하시면 좋을 것 같습니다.