ghostdogpr / caliban

Functional GraphQL library for Scala
https://ghostdogpr.github.io/caliban/
Apache License 2.0
947 stars 248 forks source link

Use of custom types in GQL input types? #50

Closed jmpicnic closed 5 years ago

jmpicnic commented 5 years ago

Hello,

First, kudos to an amazingly clean GQL service. Impossible to make it any better.

I am stuck with some code that users Enumerations instead of sealed traits to express Enums. It was easy enough to create a schema for Enumeration types (see below).

My question is what do I need to provide the schema to be able to use the Enum type also as an "input type" in GQL.

Simply putting it in as a field in the arguments case class is not enough (not surprising). It complains that it misses the Query Case class Schema, probably because it does not know how to marshall the input into the internal type.

Any insights on how to provide the compiler with the right info/implcits will be more than welcome. If I come up with a general approach, I'll be glad to make it available.

Thanks

The barebones example code (that does not compile) looks like:

import caliban.RootResolver
import caliban.GraphQL._

import scala.collection.mutable

object QueryWithEnum {
    type ThingType = ThingType.Value
    object ThingType extends Enumeration {
        val BIG = Value(1)
        val SMALLISH = Value(2)
    }
    import caliban.schema
    def enumSchema[E <: Enumeration](name: String) = schema.Schema.scalarSchema[E#Value]("name", None, e => caliban.ResponseValue.StringValue(e.toString))
    implicit val thingTypeSchema = enumSchema[ThingType.type]("ThingType")

    case class Thing(k: String, name: Option[String], thingType: ThingType, value: Double)

    private val thingStore: mutable.Map[String, Thing] = mutable.Map(
        "thingOne" -> Thing("thingOne", None, ThingType.BIG, 33.2),
        "thingTwo" -> Thing("thingTwo", Some("Dearest Thingy"), ThingType.SMALLISH, 324.34)
    )
    case class ByKey(k: String)
    def allThingsEnum: List[Thing] = thingStore.values.toList
    def oneThingEnum(tk: ByKey): Option[Thing] = thingStore.get(tk.k)

    case class ByType(t: ThingType)
    def someThingsEnum(tk: ByType): List[Thing] = allThingsEnum.filter(_.thingType == tk.t)

    case class Query(things: List[Thing], thing: ByKey => Option[Thing], someThings: ByType => List[Thing])

    val queryService = Query(allThingsEnum, oneThingEnum, someThingsEnum)
    val interpreter = graphQL(RootResolver(queryService))

}

And the compiler error:

[error] MessageQuery.scala:34:27: could not find implicit value for parameter querySchema: caliban.schema.Schema[R,com.cruxsystems.ais.service.inspection.QueryWithEnum.Query] [error] val interpreter = graphQL(RootResolver(queryService))

ghostdogpr commented 5 years ago

Hi @jmpicnic Thanks for the kind words 😄

As I mentioned (briefly) here, any type you pass as an argument needs an instance of the ArgBuilder typeclass, which defines how to turn an input GraphQL Value into an actual value of that type.

In your case let's say you pass the enum as a String:

  implicit val thingTypeArgBuilder: ArgBuilder[ThingType] = {
    case Value.StringValue(value) =>
      Task(ThingType.withName(value)).mapError(ex => ExecutionError(s"Invalid input received for ThingType", Some(ex)))
    case other => IO.fail(ExecutionError(s"Can't build a ThingType from input $other"))
  }

You could also support passing the enum as a number by adding a case Value.IntValue.

PS: you have "name" instead of just name in enumSchema.

PS2: you decided to use a Scalar for your enum, which might be okay for your needs. In case you would like to have a GraphQL Enum type, you could do something like this:

  implicit val thingTypeSchema: Schema[Any, ThingType] = new Schema[Any, ThingType] {
    override def toType(isInput: Boolean): __Type =
      Types.makeEnum(
        Some("ThingType"),
        None,
        ThingType.values.toList.map(v => __EnumValue(v.toString, None, isDeprecated = false, None))
      )
    override def resolve(value: ThingType, arguments: Map[String, Value]): ZIO[Any, ExecutionError, ResolvedValue] =
      UIO(EnumValue(value.toString))
  }

Might even be possible to make it generic for any Enumeration.

jmpicnic commented 5 years ago

Thanks a lot, and thanks for the patience. I did not see that part of the doc. I focused on the CustomTypes and missed that completely.

Cheers.

jmpicnic commented 5 years ago

With two little generic functions, it is a breeze to get them working:

    import caliban.schema
    def enumSchema[E <: Enumeration](name: String) = schema.Schema.scalarSchema[E#Value]("name", None, e => caliban.ResponseValue.StringValue(e.toString))
    def enumArgBuilder[E <: Enumeration](e: E) : ArgBuilder[E#Value] = {
        case Value.StringValue(value) =>
            Task(e.withName(value)).mapError(ex => ExecutionError(s"Invalid input received for Enumeration Value", Some(ex)))
        case other => IO.fail(ExecutionError(s"Can't build The enumeration ${e.getClass} value from input $other"))
    }
       // simply used
    implicit val thingTypeSchema = enumSchema[ThingType.type]("ThingType")
    implicit val thingTypeArgBuilder = enumArgBuilder(ThingType)

Thanks

Miguel