Skip to content

add custom java mapping support to FunctionDataFetcher #1520

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed

Conversation

cryptoki
Copy link

📝 Description

@dariuszkuc @samuelAndalon
it's only a draft to evaluate if this way is an option as well. I added a function for java object mapping. The default implementation results null but it can overwritten in custom FunctionDataFetcher. I updated my little playground project as well.

I'm going to add tests as well. actually it is only a draft.

🔗 Related Issues

#1515

@cryptoki cryptoki force-pushed the cryptoki_add_custom_support branch from 01b1da1 to c53f8a0 Compare August 16, 2022 18:11
@dariuszkuc
Copy link
Collaborator

Since you would still have to do custom FunctionDataFetcher to implement the mapping function -> why not simply extend the existing one with the old logic (https://github.com/ExpediaGroup/graphql-kotlin/blob/5.x.x/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/execution/FunctionDataFetcher.kt)?

class JacksonFunctionDataFetcher(
    private val target: Any?,
    private val fn: KFunction<*>,
    private val objectMapper: ObjectMapper = jacksonObjectMapper()
) : FunctionDataFetcher(target, fn) {

    override fun mapParameterToValue(param: KParameter, environment: DataFetchingEnvironment): Pair<KParameter, Any?>? =
        when {
            param.isGraphQLContext() -> param to environment.getContext()
            param.isDataFetchingEnvironment() -> param to environment
            else -> {
                val name = param.getName()
                if (environment.containsArgument(name) || param.type.isOptionalInputType()) {
                    val value: Any? = environment.arguments[name]
                    param to convertArgumentToObject(param, environment, name, value)
                } else {
                    null
                }
            }
        }

    /**
     * Convert the generic arument value from JSON input to the paramter class.
     * This is currently achieved by using a Jackson ObjectMapper.
     */
    private fun convertArgumentToObject(
        param: KParameter,
        environment: DataFetchingEnvironment,
        argumentName: String,
        argumentValue: Any?
    ): Any? = when {
        param.type.isOptionalInputType() -> {
            when {
                !environment.containsArgument(argumentName) -> OptionalInput.Undefined
                argumentValue == null -> OptionalInput.Defined(null)
                else -> {
                    val paramType = param.type.getTypeOfFirstArgument()
                    val value = convertValue(paramType, argumentValue)
                    OptionalInput.Defined(value)
                }
            }
        }
        else -> convertValue(param.type, argumentValue)
    }

    private fun convertValue(
        paramType: KType,
        argumentValue: Any?
    ): Any? = when {
        paramType.isList() -> {
            val argumentClass = paramType.getTypeOfFirstArgument().getJavaClass()
            val jacksonCollectionType = objectMapper.typeFactory.constructCollectionType(List::class.java, argumentClass)
            objectMapper.convertValue(argumentValue, jacksonCollectionType)
        }
        paramType.isArray() -> {
            val argumentClass = paramType.getWrappedType().getJavaClass()
            val jacksonCollectionType = objectMapper.typeFactory.constructArrayType(argumentClass)
            objectMapper.convertValue(argumentValue, jacksonCollectionType)
        }
        else -> {
            val javaClass = paramType.getJavaClass()
            objectMapper.convertValue(argumentValue, javaClass)
        }
    }
}

NOTE: code above calls internal graphql-kotlin APIs so you would have to copy those over as well

@cryptoki
Copy link
Author

@dariuszkuc yes. that's possible as well. My intension is not to separate the code base and if somebody needs to deserialise an old java pojo it needs only to overwrite one method instead of copy the old 5.x code.

  val objectMapper: ObjectMapper = ObjectMapper()
  override fun mapToJavaObject(arguments: Map<String, *>, clazz: Class<*>): Any? =
    objectMapper.convertValue(arguments, clazz)
}

it is easier to understand.

with your solution some of our project use the JacksonFunctionDataFetcher copied from the 5.x branch and other the internal v6.x stuff. I'm not sure if this would be the best solution for the next years.

@samuelAndalon
Copy link
Contributor

I would encourage doing what @dariuszkuc mentioned, you can extend the FunctionDataFetcher with your specific use case and create your custom KotlinDataFetcherFactoryProvider, thats the reason some of the core classes are explicitly marked as open.

It is actually then pattern that is used with the SpringDataFetcher and others

@cryptoki
Copy link
Author

cryptoki commented Aug 17, 2022

thank you. Why do you drop the support of java classes? graphql-kotlin 6.x have a huge breaking change and the workaround with the first constructor is not very workable. The workaround to copy the old stuff is not very workable as well. Jackson works well for java classes.

sure it works with a copied FunctionDataFetcher from the 5.5.x branch. not our preferred solution.

JacksonFunctionDataFetcher

import com.expediagroup.graphql.generator.annotations.GraphQLName
import com.expediagroup.graphql.generator.exceptions.CouldNotGetNameOfKParameterException
import com.expediagroup.graphql.generator.exceptions.InvalidWrappedTypeException
import com.expediagroup.graphql.generator.execution.FunctionDataFetcher
import com.expediagroup.graphql.generator.execution.GraphQLContext
import com.expediagroup.graphql.generator.execution.OptionalInput
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import graphql.schema.DataFetchingEnvironment
import kotlin.reflect.KAnnotatedElement
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
import kotlin.reflect.KParameter
import kotlin.reflect.KType
import kotlin.reflect.full.createType
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.jvm.jvmErasure

class JacksonFunctionDataFetcher(
    private val target: Any?,
    private val fn: KFunction<*>,
    private val objectMapper: ObjectMapper = jacksonObjectMapper()
) : FunctionDataFetcher(target, fn) {

  override fun mapParameterToValue(param: KParameter, environment: DataFetchingEnvironment): Pair<KParameter, Any?>? =
    when {
      param.isGraphQLContext() -> param to environment.getContext()
      param.isDataFetchingEnvironment() -> param to environment
      else -> {
        val name = param.getName()
        if (environment.containsArgument(name) || param.type.isOptionalInputType()) {
          val value: Any? = environment.arguments[name]
          param to convertArgumentToObject(param, environment, name, value)
        }
        else {
          null
        }
      }
    }

  private fun convertArgumentToObject(
      param: KParameter,
      environment: DataFetchingEnvironment,
      argumentName: String,
      argumentValue: Any?
  ): Any? = when {
    param.type.isOptionalInputType() -> {
      when {
        !environment.containsArgument(argumentName) -> OptionalInput.Undefined
        argumentValue == null -> OptionalInput.Defined(null)
        else -> {
          val paramType = param.type.getTypeOfFirstArgument()
          val value = convertValue(paramType, argumentValue)
          OptionalInput.Defined(value)
        }
      }
    }

    else -> convertValue(param.type, argumentValue)
  }

  private fun convertValue(
      paramType: KType,
      argumentValue: Any?
  ): Any? = when {
    paramType.isList() -> {
      val argumentClass = paramType.getTypeOfFirstArgument().getJavaClass()
      val jacksonCollectionType = objectMapper.typeFactory.constructCollectionType(List::class.java, argumentClass)
      objectMapper.convertValue(argumentValue, jacksonCollectionType)
    }

    paramType.isArray() -> {
      val argumentClass = paramType.getWrappedType().getJavaClass()
      val jacksonCollectionType = objectMapper.typeFactory.constructArrayType(argumentClass)
      objectMapper.convertValue(argumentValue, jacksonCollectionType)
    }

    else -> {
      val javaClass = paramType.getJavaClass()
      objectMapper.convertValue(argumentValue, javaClass)
    }
  }
}

internal fun KParameter.isGraphQLContext() = this.type.getKClass().isSubclassOf(GraphQLContext::class)

internal fun KParameter.isDataFetchingEnvironment() = this.type.classifier == DataFetchingEnvironment::class

@Throws(CouldNotGetNameOfKParameterException::class)
internal fun KParameter.getName(): String =
  this.getGraphQLName() ?: this.name ?: throw CouldNotGetNameOfKParameterException(this)

internal fun KType.getKClass() = this.jvmErasure

internal fun KType.getJavaClass(): Class<*> = this.getKClass().java

internal fun KType.isSubclassOf(kClass: KClass<*>) = this.getKClass().isSubclassOf(kClass)

internal fun KType.isList() = this.isSubclassOf(List::class)

internal fun KType.isArray() = this.getJavaClass().isArray

internal fun KType.isOptionalInputType() = this.isSubclassOf(OptionalInput::class)

@Throws(InvalidWrappedTypeException::class)
internal fun KType.getTypeOfFirstArgument(): KType =
  this.arguments.firstOrNull()?.type ?: throw InvalidWrappedTypeException(this)

internal fun KType.getWrappedType(): KType {
  val primitiveClass = primitiveArrayTypes[this.getKClass()]
  return when {
    primitiveClass != null -> primitiveClass.createType()
    else -> this.getTypeOfFirstArgument()
  }
}

private val primitiveArrayTypes = mapOf(
    IntArray::class to Int::class,
    LongArray::class to Long::class,
    ShortArray::class to Short::class,
    FloatArray::class to Float::class,
    DoubleArray::class to Double::class,
    CharArray::class to Char::class,
    BooleanArray::class to Boolean::class
)

internal fun KAnnotatedElement.getGraphQLName(): String? = this.findAnnotation<GraphQLName>()?.value

JacksonKotlinDataFetcherFactoryProvider

import com.expediagroup.graphql.generator.execution.SimpleKotlinDataFetcherFactoryProvider
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import graphql.schema.DataFetcherFactory
import kotlin.reflect.KFunction

class JacksonKotlinDataFetcherFactoryProvider(private val objectMapper: ObjectMapper)
  : SimpleKotlinDataFetcherFactoryProvider() {

  override fun functionDataFetcherFactory(target: Any?, kFunction: KFunction<*>) = DataFetcherFactory {
    JacksonFunctionDataFetcher(
        target = target,
        fn = kFunction,
        objectMapper = objectMapper
    )
  }
}

GraphQlConfig

        config = SchemaGeneratorConfig(
            dataFetcherFactoryProvider = JacksonKotlinDataFetcherFactoryProvider(jsonObjectMapper),
        ),

@cryptoki
Copy link
Author

I upgraded one of our project with the copied (dirty) workaround from the 5.x branch. We are not happy with the solution.

please let me know if I can contribute something. There are a lot of options

1
See the draft. Maybe the default implementation for the java mapping should use the first constructor

2
convertArgumentValue open class and protected fun .. add method mapToJavaObject and give us the option to override the method. FunctionDataFetcher must have the option to set the convertArgumentValue implementation

3
add mapToJavaObject to convertArgumentValue and add Jackson support :)

4
...

if none of them is an option please close the draft pr.

@dariuszkuc
Copy link
Collaborator

While it is unfortunate that dropping Jackson broke the functionality for you, we never targeted full Java compatibility (due to the lack of nullability information from Java POJOs as they are treated as system types). Creating custom FunctionDataFetcher (in your case with Jackson logic) is the solution we suggest. Custom logic for handling Jackson types has to reside somewhere and we don't think schema-generator repo is the right place for it.

We dropped Jackson dependency from schema-generator to fix the duplicate deserialization issue of custom scalars as well as to allow end users to select their serialization technology. Arguably Jackson is the most popular framework in the Java ecosystem (and we rely on it in our spring-server module) but there are other frameworks (like kotlinx-serialization or moshi) that folks might prefer to use (and which might integrate better with their server technology, e.g. ktor).

workaround with the first constructor is not very workable

Since those POJOs are system types, all properties on those objects are treated as required so we need an all-arg constructor. If there are multiple constructors we would need additional logic to figure out the all-arg constructor. What is the order of the constructors? If we match constructors by number of input fields but input is missing some required fields -> how do we determine that required fields were missing? What if there are multiple constructors that take same number of arguments? Enforcing single all-arg constructor rule is the simplest workaround to enable usage of Java POJOs without implementing tons of custom logic to support that use case (we never targeted full Java interop).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

3 participants