🥰

Scala の GraphQL サーバー Caliban のランタイムに cats-effect を使う

2024/12/26に公開

この記事は 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 というライブラリがあります.

https://ghostdogpr.github.io/caliban/

Caliban には以下の特長があります.

  • スキーマファースト・コードファースト両方のサポート
  • ZIO ・ cats-effect 両方の非同期ランタイムのサポート
  • サーバー・クラアント実装両方のサポート
  • relay や apollo などの規格の実装
  • Play、Pekko、http4s、Tapir などのライブラリとのインテグレーション
  • Data Loader のようなバッチ化やキャッシュ

Caliban は ZIO をデフォルトの非同期ランタイムとして利用していますが、interop モジュールを利用すれば cats-effect を使うこともできます.

以下がサンプルのレポジトリです.

https://github.com/i10416/caliban-cats-http4s-interop

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.Schemacaliban.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 に与えられた型からユーザーが定義した型へのDecoderSchemaはユーザーが定義した型から Caliban が外部に返す型へのEncoder` とみなせます.

Caliban の caliban.schema.Schema 型は次のようなシグニチャです.

Schema[R,T]

R が環境、例えば認証情報やロギングなどの依存、TSchema を与えたい型です.

例えば、Query[F] に環境を持たない Schema を与えるには Schema[Any, Query[F]] を導出します.

Schema の導出は

  • import caliban.schema.Schema.auto で自動生成する方法
  • Schema.stringSchema.contramap(...) のように、既存の Schema を利用する方法
  • Schema.gen を利用して半自動で導出する方法
  • Schema.objnew Schema[R, T] { ... }を使って手動で定義する方法

があります.

基本的な Schema は、import caliban.schema.Schema.auto でよしなに生成されますが、RAny でない場合は、import caliban.schema.Schema.auto はうまく動きません(&残念ながらコンパイラエラーに なりません ). また、再帰的なスキーマや巨大なスキーマの導出がうまくいかなかったりコンパイル時間が長時間になったりする問題があります.

F[_] を持たない型の R をカスタマイズした SchemaSchema.gen で定義できます.

  implicit def HogeSchema[R]: Schema[R, Hoge] =
    Schema.gen

F[_] に包まれた型の R をカスタマイズした SchemacatsEffectSchema で定義できます.

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 フィールドが UIOF[_] で包まれるようになります.

UIOF[_] で包まないと 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.KleisliIOLocal を、ZIO は ZEnvironment を使って文脈依存の値を取り回します.

例えば、文脈 R を使うとき

  • cats-effect は
    • Kleisli[IO, R, A] で依存を定義します
    • Kleisli#run(...) で依存を与えます
  • ZIO(Caliban) は
    • (GraphQL 型の)型引数 R で依存を定義します
    • ZEnvironment(...) で依存を与えます

Caliban は内部では ZIO を使っているので、cats-effect をメインで利用するなら、文脈依存の値(cats.data.KleisliIOLocal)を 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 を使ってクエリの結果のフィールドをフィルターする用途に活用できます.

以下は一部省略した疑似コードです.

middleware
Kleisli { req =>
  req.headers.get[AuthHeader] match
    case Some(tkn) =>
      val r: R = parse(tkn)
      routes.run(req).scope(r)
}
schema
implicit def[R] s: Schema[R, T] =
  Schema.obj(name, ...) {implicit fields =>
     List(
       field(fName, directives = List(rolesDirective))(_.fName),
       ...
     )
  }
graphql
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 を試してみるのはいかがでしょうか?☺️

参考

https://qiita.com/aoyagi9936/items/1c76ec3dba15eb47de65
https://github.com/aoyagi9936/nextjs-graphql-caliban-example/

Discussion