blog.petitviolet.net

SangriaでGraphQLのinterfaceを扱うには

2018-07-01

QiitaScalaGraphQLsangria

sangria-graphql/sangriaを使って GraphQL な API を実装する時に interface をどうやって使うか、という話。
interface 自体は特に難しい話ではないが、地味に動かなくて困ったので残しておく。

まとめ

  • interface の実装自体はInterfaceTypeを使うだけ
  • Field.fieldTypeInterfaceTypeを与えるだけだと Scheme がエラーになる
  • 解決策として

    • Schema.additionalTypesあるいはInterfaceType.withPossibleTypesでその interface を実装したObjectTypeを与える
    • UnionTypeを使う

interface の実装としての InterfaceType

GraphQL の公式ページ: Schemas and Types | GraphQL いわゆる interface 的な型/機能が GraphQL にも定義されている。

Sangria で InterfaceType を使ったスキーマを実装する

まずは Scala で interface 的な trait を使ったコードを用意する。

trait Animal {
  def id: String
  def name: String
}
case class Dog(id: String, name: String, kind: String) extends Animal
case class Cat(id: String, name: String, color: Color) extends Animal

sealed abstract class Color(val rgb: String)
object Color {
  case object White extends Color("#FFFFFF")
  case object Black extends Color("#000000")
  case object Brown extends Color("#A52A2A")
}

Animalな interface をDogCatが implements していてそれぞれ独自のフィールドも持っている。 これらに対する GraphQL スキーマを実装すると以下のようになる。

import sangria.macros.derive
import sangria.schema._

lazy val animalInterface: InterfaceType[Unit, Animal] = InterfaceType[Unit, Animal](
  "Animal",
  "animal interface",
  fields[Unit, Animal](
    Field("id", StringType, resolve = ctx => ctx.value.id),
    Field("name", StringType, resolve = ctx => ctx.value.name)
  )
)

lazy val dogType = derive.deriveObjectType[Unit, Dog](
  derive.Interfaces[Unit, Dog](animalInterface)
)

implicit lazy val colorEnum = derive.deriveEnumType[Color]()

lazy val catType = derive.deriveObjectType[Unit, Cat](
  derive.Interfaces[Unit, Cat](animalInterface)
)

Scheme をschema.renderPrettyすると以下のようになる。

interface Animal {
  id: String!
  name: String!
}

type Cat implements Animal {
  id: String!
  name: String!
  color: Color!
}

enum Color {
  White
  Black
  Brown
}

type Dog implements Animal {
  id: String!
  name: String!
  kind: String!
}

interface を返却するディレクティブを定義する

先程定義したanimalInterfaceを返却するFieldを持つ Query を実装する。
CtxUnitで、allに対して固定でDogCatの List を返し、そのFieldの型はListType(animalInterface)にしてある。

lazy val animalQuery: ObjectType[Unit, Unit] = {
  ObjectType.apply(
    "AnimalQuery",
    fields[Unit, Unit](
      Field("all", ListType(animalInterface), resolve = { _ =>
        Dog("dog-1", "alice", "golden") ::
          Dog("dog-2", "bob", "Chihuahua") ::
          Cat("cat-1", "charlie", Color.Brown) :: Nil
      })
    )
  )
}

val schema = Schema(animalQuery)

DogCatdotType, catTypeanimalInterfaceを implements しているので動きそうに見える。

しかし動かして見るとエラーになってしまう。

sangria.schema.SchemaValidationException: Schema does not pass validation. Violations: Interface ‘Animal’ must be implemented by at least one object type.

エラーメッセージの通りで、animalInterface: InterfaceType[Unit, Animal]を implements したObjectTypeが無いということ。

これの解決策はいくつかある

  • Schema.additionalTypes を使う
  • InterfaceType.withPossibleTypes を使う
  • UnionType を使う

Schema.additionalTypes を使う

dogTypecatTypeは確実にInterfaceType[Unit, Animal]を implements しているが、それがSchemaに伝わっていないのが原因。

それを解決するためにSchema.additionalTypesというフィールドがある。 ソースコードはこのあたり: sangria/Schema.scala

つまり、以下のように書くだけで良い。

Schema(animalQuery, additionalTypes = dogType :: catType :: Nil)

これで以下のようなクエリを記述することが出来るようになる。

query {
  all {
    id
    name
    ... on Dog {
      kind
    }
    ... on Cat {
      color
    }
  }
}

InterfaceType.withPossibleTypes を使う

Schema に Interface を実装しているObjectTypeを伝えるもう一つの方法。 InterfaceType.withPossibleTypesを利用するとInterfaceTypeのインスタンス自体にそれを実装している型をもたせることが可能。

animalInterfaceの実装全体は以下のようになる。

lazy val animalInterface: InterfaceType[Unit, Animal] = InterfaceType[Unit, Animal](
  "Animal",
  "animal interface",
  fields[Unit, Animal](
    Field("id", StringType, resolve = ctx => ctx.value.id),
    Field("name", StringType, resolve = ctx => ctx.value.name)
  )
).withPossibleTypes(() => List(dogType, catType))

// SchemaにadditionalTypeを与える必要はない。
Schema(animalQuery)

なお、withPossibleTypesには 2 つ API があって可変長引数を与える方の API だと StackOverflowError が発生してしまった。

// StackOverflowError!
.withPossibleTypes(dogType, catType)

UnionType を使う

クエリの返り値として interface を返すのを諦めて UnionType を用いる方法。

今回だとdogTypecatTypeUnionTypeを実装すれば良い。

val animalUnionType = UnionType[Unit](
  "AnimalUnion",
  types = dogType :: catType :: Nil
)

一応クエリの方も載せておくと、Field.fieldTypeに UnionType を使うだけ。 それ以外は変えていない。

lazy val animalQuery: ObjectType[Unit, Unit] = {
  ObjectType.apply(
    "AnimalQuery",
    fields[Unit, Unit](
      Field("all", ListType(animalUnionType), resolve = { _ =>
        Dog("dog-1", "alice", "golden") ::
          Dog("dog-2", "bob", "Chihuahua") ::
          Cat("cat-1", "charlie", Color.Brown) :: Nil
      })
    )
  )
}

val schema = Schema(animalQuery)

しかし、こうするとクエリの幅が狭くなってしまうし、interface を使っている利点はなくなってしまう。

query {
  all {
    id # ←ここがエラーになる
    ... on Dog {
      kind
    }
    ... on Cat {
      color
    }
  }
}

番外編

バリデーションエラーになるならバリデーションをやめてしまえばいい、というのもある。
つまり、interface に対して実装が見つからないのを許容するということ。

SchemaにはvalidationRulesというフィールドがあるので、そこで今回邪魔になるルールを除去してみる。

 Schema(animalQuery,
        validationRules = List(
          DefaultValuesValidationRule,
          InterfaceImplementationValidationRule,
          // InterfaceMustHaveImplementationValidationRule,
          SubscriptionFieldsValidationRule,
          SchemaValidationRule.defaultFullSchemaTraversalValidationRule
        ))

これでスキーマのバリデーションはパスするが、実際に動かしてみるとエラーになる。

sangria.execution.UndefinedConcreteTypeError: Can’t find appropriate subtype of an interface type ‘Animal’ for value of class ‘net.petitviolet.prac.graphql.sample.UnionInterfaceSampleSchema$Cat’ at path ‘all[2]’. Possible types: none. Got value: Cat(cat-1,charlie,Brown).

animalInterfaceだけだとdogTypecatTypeの情報が入っていないので、レスポンスを返却する時に sangria が判断できずにエラーになってしまう。

from: https://qiita.com/petitviolet/items/6b8ac832360408a323a1