[DGS] JUnit 에서 codegen class 에 UUID type input field 를 사용할 때 발생하는 에러 처리

최대한·2022년 12월 12일
0

Overview

graphql-java-extended-scalars 에서 제공해주는 UUID type 을 field 로 가지고 있는 input 을 통해 query 를 날릴 경우,
실제 graphql 요청 시 잘 조회가 되지만 code-gen 플러그인을 통한 client 클래스를 이용하여 JUnit 테스트를 할 경우 발생하는 에러에 대한 해결 방법을 소개하는 글입니다.

build.gradle.kts

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
}

schema.graphqls

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!
}

UserFetcher.kt

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

}

schema.graphqls

...

input GetUserInput {
    id: UUID!
}

에서 볼 수 있듯이, id 타입은 UUID 이며, 요청에 성공하는 것을 확인할 수 있습니다.

문제상황

하지만 JUnit 에서 다음과 같이 codegen plugin 에 의해 생성된 client class 들로 테스트를 하면 다음과같은 에러가 발생합니다.

UserTest.kt

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() 하는 부분에서 발생했습니다.

GraphQLQueryRequest.kt

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 해주고 있는지 파고 들어가보니,

InputValueSerializer.kt

    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 들은 멤버변수들이 나올때까지 루프를 돌면서 해주고 있네요.

UUID.java

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 를 추가해주면 됩니다.

GraphQLQueryRequest.kt

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() 로 대체할 수 있습니다.

실제 사용

UserTest.kt

	...
    
		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 에 올려놓았으니 참고하시면 좋을 것 같습니다.

https://github.com/vitamaxDH/gql-dgs-demo

profile
Awesome Dev!

0개의 댓글