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