sangria-graphql/sangriaを使ってGraphQLなAPIを実装する時にinterfaceをどうやって使うか、という話。
interface自体は特に難しい話ではないが、地味に動かなくて困ったので残しておく。
まとめ
- interfaceの実装自体はInterfaceTypeを使うだけ
- 
Field.fieldTypeにInterfaceTypeを与えるだけだと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をDogとCatが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を実装する。
CtxはUnitで、allに対して固定でDogとCatの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)
DogもCatもdotType, catTypeでanimalInterfaceを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を使う
dogTypeとcatTypeは確実に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を用いる方法。
今回だとdogTypeとcatTypeのUnionTypeを実装すれば良い。
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だけだとdogTypeとcatTypeの情報が入っていないので、レスポンスを返却する時にsangriaが判断できずにエラーになってしまう。