Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,29 @@ import kotlin.reflect.KParameter
import kotlin.reflect.KType
import kotlin.reflect.full.primaryConstructor

/**
* Convert a raw GraphQL argument map into a typed Kotlin object using Kotlin reflection.
*
* Uses [KClass.primaryConstructor] (Kotlin-side reflection) to construct the target object.
* Field names are resolved using [@GraphQLName][com.expediagroup.graphql.generator.annotations.GraphQLName]
* or the Kotlin parameter name β€” the same logic used to build the schema. Already-coerced values
* (e.g. custom scalars that graphql-java has already parsed) are passed through as-is.
*
* This is the same coercion path used internally by [FunctionDataFetcher] for resolver parameters.
*
* Use this when you have already extracted a specific argument value from
* [graphql.schema.DataFetchingEnvironment.getArguments] and need to coerce it to a typed object.
* When coercing the full arguments map (e.g. in a custom [graphql.schema.DataFetcher]),
* prefer the `getArgumentsAs` extension on `DataFetchingEnvironment` in
* `com.expediagroup.graphql.generator.extensions`.
*/
fun <T : Any> convertInputMap(input: Map<String, *>, targetClass: KClass<T>): T =
mapToKotlinObject(input, targetClass)

/**
* Convert the argument from the argument map to a class we can pass to the Kotlin function.
*/
internal fun convertArgumentValue(
fun convertArgumentValue(
argumentName: String,
param: KParameter,
argumentMap: Map<String, Any?>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright 2024 Expedia, Inc
*
* 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 com.expediagroup.graphql.generator.extensions

import com.expediagroup.graphql.generator.execution.convertInputMap
import graphql.schema.DataFetchingEnvironment
import kotlin.reflect.KClass

/**
* Coerces [DataFetchingEnvironment.getArguments] into a typed Kotlin object using Kotlin reflection.
*
* The target class must match the top-level shape of the arguments map: its constructor parameters
* must correspond to GraphQL argument names.
*
* Field names are resolved using [@GraphQLName][com.expediagroup.graphql.generator.annotations.GraphQLName]
* or the Kotlin parameter name β€” the same logic used to build the schema. Already-coerced values
* (e.g. custom scalars that graphql-java has already parsed) are passed through as-is.
*
* This is the same coercion path that [com.expediagroup.graphql.generator.execution.FunctionDataFetcher]
* uses internally for resolver parameters, and is the correct alternative to `ObjectMapper.convertValue`
* for use in instrumentation or custom data fetcher code.
*/
fun <T : Any> DataFetchingEnvironment.getArgumentsAs(targetClass: KClass<T>): T =
convertInputMap(arguments, targetClass)

/**
* Coerces [DataFetchingEnvironment.getArguments] into a typed Kotlin object using Kotlin reflection.
Comment thread
JordanJLopez marked this conversation as resolved.
*
* @see getArgumentsAs
*/
inline fun <reified T : Any> DataFetchingEnvironment.getArgumentsAs(): T =
getArgumentsAs(T::class)
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,46 @@ import kotlin.test.assertIs
import kotlin.test.assertNotNull

class ConvertArgumentValueTest {
@Test
fun `convertInputMap converts flat map to data class`() {
val result = convertInputMap(mapOf("foo" to "hello", "bar" to "world"), TestInput::class)
assertEquals("hello", result.foo)
assertEquals("world", result.bar)
}

@Test
fun `convertInputMap uses default values for missing fields`() {
val result = convertInputMap(mapOf("foo" to "hello"), TestInput::class)
assertEquals("hello", result.foo)
assertEquals(null, result.bar)
assertEquals(null, result.baz)
}

@Test
fun `convertInputMap handles nested objects`() {
val result = convertInputMap(
mapOf("nested" to mapOf("value" to "custom")),
TestInputNested::class
)
assertEquals("foo", result.foo)
assertEquals("custom", result.nested?.value)
}

@Test
fun `convertInputMap passes through already-coerced field values`() {
// Simulates what environment.arguments looks like in instrumentation code after
// graphql-java has run custom scalar coercers: the scalar field is already the
// target type object, not a raw string. convertInputMap passes it through as-is
// via the "value is already parsed" branch.
val preCoercedId = ID("already-coerced")
val result = convertInputMap(
mapOf("foo" to "hello", "id" to preCoercedId),
TestInputNullableScalar::class
)
assertEquals("hello", result.foo)
assertEquals(preCoercedId, result.id)
}

@Test
fun `string input is parsed`() {
val kParam = assertNotNull(TestFunctions::stringInput.findParameterByName("input"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright 2024 Expedia, Inc
*
* 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 com.expediagroup.graphql.generator.extensions

import com.expediagroup.graphql.generator.annotations.GraphQLName
import graphql.schema.DataFetchingEnvironment
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals

class DataFetchingEnvironmentExtensionsTest {

data class SimpleInput(val foo: String, val bar: String? = null)
data class RenamedInput(@GraphQLName("baz") val foo: String)
data class NestedInput(val inner: SimpleInput, val tag: String)

@Test
fun `getArgumentsAs coerces arguments to typed Kotlin object`() {
val environment = mockk<DataFetchingEnvironment> {
every { arguments } returns mapOf("foo" to "hello", "bar" to "world")
}

val result = environment.getArgumentsAs<SimpleInput>()

assertEquals("hello", result.foo)
assertEquals("world", result.bar)
}

@Test
fun `getArgumentsAs respects default parameter values for absent fields`() {
val environment = mockk<DataFetchingEnvironment> {
every { arguments } returns mapOf("foo" to "hello")
}

val result = environment.getArgumentsAs(SimpleInput::class)

assertEquals("hello", result.foo)
assertEquals(null, result.bar)
}

@Test
fun `getArgumentsAs resolves field names via GraphQLName`() {
val environment = mockk<DataFetchingEnvironment> {
every { arguments } returns mapOf("baz" to "renamed")
}

val result = environment.getArgumentsAs<RenamedInput>()

assertEquals("renamed", result.foo)
}

@Test
fun `getArgumentsAs passes through already-coerced field values`() {
// Simulates what environment.arguments looks like after graphql-java has run
// custom scalar coercers β€” the field value is already the target type, not a raw string.
val preCoerced = SimpleInput("already", "coerced")
val environment = mockk<DataFetchingEnvironment> {
every { arguments } returns mapOf("inner" to preCoerced, "tag" to "test")
}

val result = environment.getArgumentsAs<NestedInput>()

assertEquals(preCoerced, result.inner)
assertEquals("test", result.tag)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* Copyright 2024 Expedia, Inc
*
* 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 com.expediagroup.graphql.generator.test.integration

import com.expediagroup.graphql.generator.TopLevelObject
import com.expediagroup.graphql.generator.execution.convertInputMap
import com.expediagroup.graphql.generator.getTestSchemaConfigWithHooks
import com.expediagroup.graphql.generator.hooks.SchemaGeneratorHooks
import com.expediagroup.graphql.generator.test.utils.graphqlUUIDType
import com.expediagroup.graphql.generator.toSchema
import graphql.GraphQL
import graphql.schema.GraphQLType
import org.junit.jupiter.api.Test
import java.util.UUID
import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlin.test.assertEquals
import kotlin.test.assertNull

/**
* Verifies that plain Kotlin data classes whose fields include custom scalars are correctly
* coerced in both of the scenarios covered by this test class:
*
* 1. the resolver parameter path exercised end-to-end via [graphql.GraphQL.execute], where
* [com.expediagroup.graphql.generator.execution.FunctionDataFetcher] maps GraphQL input
* objects to Kotlin constructor parameters, and
*
* 2. the direct input-map conversion path exercised through
* [com.expediagroup.graphql.generator.execution.convertInputMap], which uses the same
* Kotlin reflection-based coercion logic for nested input objects and custom scalars.
*
* UUID is used as a stand-in for any custom scalar that graphql-java coerces before these
* coercion paths consume the input values.
*/
class PlainKotlinInputWithCustomScalarTest {

// No @GraphQLName β€” field names resolve to Kotlin parameter names.
data class RequestContext(
val requestId: UUID,
val userId: String,
val depth: Int = 0
)

data class NestedContext(
val outer: RequestContext,
val tag: String
)

class ContextQuery {
fun processContext(context: RequestContext): String =
"id=${context.requestId},user=${context.userId},depth=${context.depth}"

fun processNestedContext(context: NestedContext): String =
"tag=${context.tag},id=${context.outer.requestId},user=${context.outer.userId}"
}

private val schema = toSchema(
queries = listOf(TopLevelObject(ContextQuery())),
config = getTestSchemaConfigWithHooks(object : SchemaGeneratorHooks {
override fun willGenerateGraphQLType(type: KType): GraphQLType? =
when (type.classifier as? KClass<*>) {
UUID::class -> graphqlUUIDType
else -> null
}
})
)

private val graphQL = GraphQL.newGraphQL(schema).build()

@Test
fun `plain data class with custom scalar field resolves correctly end-to-end`() {
val result = graphQL.execute(
"""{ processContext(context: { requestId: "550e8400-e29b-41d4-a716-446655440000", userId: "alice", depth: 0 }) }"""
)
assertNull(result.errors.firstOrNull(), "Expected no errors but got: ${result.errors}")
val data: Map<String, String> = result.getData()
assertEquals(
"id=550e8400-e29b-41d4-a716-446655440000,user=alice,depth=0",
data["processContext"]
)
}

@Test
fun `plain data class with nested custom scalar resolves correctly end-to-end`() {
val result = graphQL.execute(
"""{ processNestedContext(context: { outer: { requestId: "550e8400-e29b-41d4-a716-446655440000", userId: "bob", depth: 0 }, tag: "test" }) }"""
)
assertNull(result.errors.firstOrNull(), "Expected no errors but got: ${result.errors}")
val data: Map<String, String> = result.getData()
assertEquals(
"tag=test,id=550e8400-e29b-41d4-a716-446655440000,user=bob",
data["processNestedContext"]
)
}

@Test
fun `coercion correctly handles pre-coerced custom scalar field`() {
// This is the instrumentation scenario: by the time beginFieldFetch is called,
// graphql-java has already run the scalar coercer so the UUID field in
// environment.arguments is already a UUID object, not a string.
val preCoercedId = UUID.fromString("550e8400-e29b-41d4-a716-446655440000")
val result = convertInputMap(
mapOf("requestId" to preCoercedId, "userId" to "alice"),
RequestContext::class
)

assertEquals(preCoercedId, result.requestId)
assertEquals("alice", result.userId)
assertEquals(0, result.depth)
}

@Test
fun `coercion correctly handles nested pre-coerced custom scalar field`() {
val preCoercedId = UUID.fromString("550e8400-e29b-41d4-a716-446655440000")
val result = convertInputMap(
mapOf("outer" to mapOf("requestId" to preCoercedId, "userId" to "bob"), "tag" to "instrumentation"),
NestedContext::class
)

assertEquals(preCoercedId, result.outer.requestId)
assertEquals("bob", result.outer.userId)
assertEquals("instrumentation", result.tag)
}
}
Loading
Loading