Scala の GraphQL サーバー Caliban のランタイムに cats-effect を使う
この記事は Scala アドベントカレンダー2024 21日目の記事、ということにします...
TL;DR;
https://github.com/i10416/caliban-cats-http4s-interop に Caliban, cats-effect, http4s を利用した例がある. スキーマファーストなコード生成、再帰的なスキーマのクエリ、型クラス導出のカスタマイズや fiber local な値の伝播など、よくあるユースケースを実装してあるので参考にしてほしい.
Scala の GraphQL 実装: Caliban
Scala の GraphQL 実装の一つに Caliban というライブラリがあります.
Caliban には以下の特長があります.
- スキーマファースト・コードファースト両方のサポート
- ZIO ・ cats-effect 両方の非同期ランタイムのサポート
- サーバー・クラアント実装両方のサポート
- relay や apollo などの規格の実装
- Play、Pekko、http4s、Tapir などのライブラリとのインテグレーション
- Data Loader のようなバッチ化やキャッシュ
Caliban は ZIO をデフォルトの非同期ランタイムとして利用していますが、interop モジュールを利用すれば cats-effect を使うこともできます.
以下がサンプルのレポジトリです.
git clone
し、sbt からコンパイル・実行できます.
このレポジトリは、次のようなスキーマで定義される記事・企業・人物を扱う仮想的なメディアサイトのバックエンドを想定しています.
schema {
query: Query
}
type Query {
"""
Find person by ID
"""
person(id: ID!): Person
"""
Find company by ID
"""
company(id: ID!): Company
"""
Find article by ID
"""
article(id: ID!): Article
"""
returns current user(if any)
"""
viewer: Viewer
me: Account
}
典型的なパターンをカバーするように
- 抽象エフェクト
F[_]
を利用するコード生成 - Caliban の型クラス
caliban.schema.Schema
やcaliban.schema.ArgBuilder
のカスタマイズ - cats.data.Klisli と ZIO environment の Context の引き継ぎ
- 例: ログや認証などの横断的な関心ごとを取り回すパターン
を実装しています. また、依存を整理するため、protocol、core、server に分割したマルチモジュール構成をとっています.
なお、執筆時の最新のバージョン 2.9.0 では抽象エフェクト F[_]
のコード生成がバグっているため、修正が入ったスナップショットバージョンを利用しています.
ここからは、上記のレポジトリのコードを実装する際にいくつかハマりポイントがあったので順に解説していきます.
F[_]
を利用するコード生成
抽象エフェクト caliban のコード生成ツールはデフォルトで ZIO の非同期モナドを利用するコードを生成します.
Caliban のコード生成には非同期モナドの型(名前)を変えるオプションもありますが、cats 系のエコシステムを使うなら将来の変更のしやすさや、いわゆる Tagless-Final パターン(F[_]: Network: Console
のように副作用を "Context Bounds" で表現するパターン)で副作用を表現する方針を考慮して IO
を直接使わず F[_]
を利用したいです. F[_]
を利用するには abstractEffectType(true)
を指定します.
lazy val protocol = project
.enablePlugins(CalibanPlugin)
.in(file("protocol"))
.settings(
scalaVersion := scala3,
libraryDependencies ++= Seq(
"com.github.ghostdogpr" %% "caliban" % calibanVersion
),
Compile / caliban / calibanSettings ++= Seq(
calibanSetting(file("protocol/src/main/graphql/schema.graphql"))(
_.genType(Codegen.GenType.Schema)
.clientName("Generated")
+ .abstractEffectType(true)
.scalarMapping("ID" -> "String")
.packageName("dev.i10416.example.protocol")
)
)
)
.abstractEffectType(true)
を指定すると、@lazy
ディレクティブがついた型や、@lazy
ディレクティブがついた型を子孫のフィールドに持つ型に推移的に F[_]
が付与されます.
再帰的なスキーマのための型クラスの導出
Caliban で利用する主要な型クラスは ArgBuilder
と ``Schemaです. Json の AST と
case class の変換と比較して考えると、
ArgBuilderは、外部から Caliban に与えられた型からユーザーが定義した型への
Decoder、
Schemaはユーザーが定義した型から Caliban が外部に返す型への
Encoder` とみなせます.
Caliban の caliban.schema.Schema
型は次のようなシグニチャです.
Schema[R,T]
R
が環境、例えば認証情報やロギングなどの依存、T
が Schema
を与えたい型です.
例えば、Query[F]
に環境を持たない Schema
を与えるには Schema[Any, Query[F]]
を導出します.
Schema
の導出は
-
import caliban.schema.Schema.auto
で自動生成する方法 -
Schema.stringSchema.contramap(...)
のように、既存のSchema
を利用する方法 -
Schema.gen
を利用して半自動で導出する方法 -
Schema.obj
やnew Schema[R, T] { ... }
を使って手動で定義する方法
があります.
基本的な Schema
は、import caliban.schema.Schema.auto
でよしなに生成されますが、R
が Any
でない場合は、import caliban.schema.Schema.auto
はうまく動きません(&残念ながらコンパイラエラーに なりません ). また、再帰的なスキーマや巨大なスキーマの導出がうまくいかなかったりコンパイル時間が長時間になったりする問題があります.
F[_]
を持たない型の R
をカスタマイズした Schema
は Schema.gen
で定義できます.
implicit def HogeSchema[R]: Schema[R, Hoge] =
Schema.gen
F[_]
に包まれた型の R
をカスタマイズした Schema
は catsEffectSchema
で定義できます.
import caliban.interop.cats.implicits.*
implicit def FugaSchema[F[_]: Dispatcher, R]: Schema[R, F[Hoge]] =
catsEffectSchema
再帰的なデータ型を扱う場合はデータの取得の都合上、以下のように GraphQL スキーマを定義する必要があります.
type Person {
id: ID!,
books: [Book!]! @lazy
}
type Book {
id: ID!,
authors: [Person!]! @lazy
}
@lazy
は Caliban のコード生成用のディレクティブです. これを指定すると生成されるコードの friends
フィールドが UIO
や F[_]
で包まれるようになります.
※ UIO
や F[_]
で包まないと query が無限ループになります.
上記の GraphQL スキーマからは以下のようなコードが生成されます.
case class Person[F](id: String, books: F[List[Book]])
case class Book[F](id: String, authors: F[List[Person]])
再帰的な型に対しても次のように Schema を定義できます.
import cats.effect.std.Dispatcher
import caliban.schema.Schema.auto.field
import caliban.interop.cats.*
implicit def person[
F[_]: Dispatcher, R
]: Schema[R, Person[F]] =
Schema.obj("Person", Some("person"), Nil) { implicit fields =>
List(
field("id")(_.id),
field(
"books",
Some("books"),
Nil
)(_.books)
)
}
implicit def personF[
F[_]: Dispatcher, R
]: Schema[R, F[Person[F]]] =
catsEffectSchema
implicit def book[
F[_]: Dispatcher, R
]: Schema[R, Book[F]] =
Schema.obj("Book", Some("book"), Nil) { implicit fields =>
List(
field("id")(_.id),
field(
"authors"
)(_.authors)
)
}
implicit def bookF[
F[_]: Dispatcher, R
]: Schema[R, F[Book[F]]] =
catsEffectSchema
cats-effect と ZIO 間で文脈依存の値の伝播
文脈依存の値、いわゆる TheadLocal のようなデータの取り回しは cats-effect と ZIO で異なります.
cats-effect は cats.data.Kleisli
や IOLocal
を、ZIO は ZEnvironment
を使って文脈依存の値を取り回します.
例えば、文脈 R
を使うとき
- cats-effect は
-
Kleisli[IO, R, A]
で依存を定義します -
Kleisli#run(...)
で依存を与えます
-
- ZIO(Caliban) は
- (
GraphQL
型の)型引数R
で依存を定義します -
ZEnvironment(...)
で依存を与えます
- (
Caliban は内部では ZIO を使っているので、cats-effect をメインで利用するなら、文脈依存の値(cats.data.Kleisli
や IOLocal
)を ZIO の ZEnvironment とうまくやり取りする必要があります.
cats-effect と ZIO の相互運用に利用するデフォルトの CatsInterop
は文脈依存の値を非同期ランタイムを跨いで伝播させませんが、明示的に文脈依存の値の伝播をサポートする CatsInterop.Contextual[F, R]
が存在します.
cats.data.Kleisli[IO, R, A]
や cats.effect.IOLocal
には CatsInterop.Contextual
が要求する型クラスのデフォルトのインスタンスがいるので複雑な設定をすることなく以下のように使えます.
given interop: CatsInterop.Contextual[F, Aspect] =
CatsInterop.contextual(dispatcher)
ZIO 側から文脈依存の値を受け取ることもできます.
これは、例えば http4s の認証・認可ミドルウェアで付与した権限情報 R
と、スキーマの directives を使ってクエリの結果のフィールドをフィルターする用途に活用できます.
以下は一部省略した疑似コードです.
Kleisli { req =>
req.headers.get[AuthHeader] match
case Some(tkn) =>
val r: R = parse(tkn)
routes.run(req).scope(r)
}
implicit def[R] s: Schema[R, T] =
Schema.obj(name, ...) {implicit fields =>
List(
field(fName, directives = List(rolesDirective))(_.fName),
...
)
}
graphql(...)
@@ {
new FieldWrapper[R] {
def wrap[R1 <: R](query: ZQuery[...], fieldInfo: FieldInfo): ZQuery[...] =
ZQuery.environment[R].flatMap: env =>
val roles = env.get.roles
val fieldVisibility =
fieldInfo.directives.find(rolesDirectivePredicate).map(...).get
if fieldVisibility.visibleBy(roles) then
query
else
ZQuery.succeed(REDACTED)
}
}
まとめ
Scala のエコシステムは、Play Framework のような All in One のフレームワークだけでなく、コンポーザブルに入れ替えられるライブラリが揃っています. このおかげで Caliban のような ZIO のエコシステムをベースにするライブラリでも別のエコシステムのライブラリと組み合わせて使えます.
JavaScript のエコシステムの不安定さや Golang のちょっとした物足りなさを感じているなら、 GraphQL のスキーマファースト・コードファースト両方のサーバー実装の選択肢として Scala の Caliban を試してみるのはいかがでしょうか?☺️
参考
Discussion