Edited at

SangriaでGraphQLのinterfaceを扱うには

More than 1 year has passed since last update.

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が判断できずにエラーになってしまう。