Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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,26 @@ 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.
* For use in instrumentation or custom data fetcher code, prefer the
* `getArgumentsAs` extension on `DataFetchingEnvironment` in
* `com.expediagroup.graphql.generator.extensions`.
*/
internal 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,53 @@ class ProductQueryService : Query {
You can also use `selectionSet` to access the selected fields of the current field. It can be useful to know which selections have been requested so the data fetcher can optimize the data access queries. For example, in an SQL-backed system, the data fetcher can access the database and use the field selection criteria to specifically retrieve only the columns that have been requested by the client.
what selection has been asked for so the data fetcher can optimise the data access queries.
For example an SQL backed system may be able to use the field selection to only retrieve the columns that have been asked for.

## Coercing Arguments to Typed Objects

`environment.arguments` returns a `Map<String, Any?>`. By the time your code sees this map, graphql-java has already run all custom scalar coercers, so scalar fields are already in their target JVM types β€” not raw strings.

If you need to coerce this map into a typed Kotlin object (for example, in instrumentation or a custom `DataFetcher`), use the `getArgumentsAs` extension function from `graphql-kotlin-schema-generator`:

```kotlin
import com.expediagroup.graphql.generator.extensions.getArgumentsAs
```

`getArgumentsAs` coerces the **full** `environment.arguments` map. The target class constructor parameters must correspond directly to the GraphQL argument names on the field.

For a field with multiple scalar arguments:

```graphql
type Query {
search(query: String!, limit: Int!): [Result!]!
}
```

```kotlin
data class SearchArgs(val query: String, val limit: Int)

val args = environment.getArgumentsAs<SearchArgs>()
```

For a field with a single complex input argument, wrap it:

```graphql
type Query {
processContext(context: RequestContext!): String!
}
```

```kotlin
data class ProcessContextArgs(val context: RequestContext)

val args = environment.getArgumentsAs<ProcessContextArgs>()
val context = args.context
```

`getArgumentsAs` uses `KClass.primaryConstructor` (Kotlin-side reflection), which means it:
- correctly passes through field values that graphql-java has already coerced (custom scalars, etc.)
- resolves field names using `@GraphQLName` or the Kotlin parameter name β€” the same logic used to build the schema
- respects Kotlin default parameter values for fields absent from the map

This is the same coercion path that `FunctionDataFetcher` uses internally for resolver parameters.

`ObjectMapper.convertValue` is not a suitable alternative here: it resolves field names via Jackson annotations or naming strategies rather than `@GraphQLName`, and it will attempt to re-deserialize values that graphql-java has already coerced.
Loading