🐫

Scala, Akka HTTP, CalibanでGraphQL APIを実装する

2021/03/15に公開

この記事は

ScalaのGraphQLライブラリといえばSangriaがより有名だが

  • ZIOとの連携の容易さ
  • Schema周りのboilerplate codeの少なさ
    からCalibanを試すことにしたので導入のメモを残しておきます

公式のドキュメント
https://ghostdogpr.github.io/caliban/
Authorのblog
https://medium.com/@ghostdogpr/graphql-in-scala-with-caliban-part-1-8ceb6099c3c2
を参考にした

今回作成したものは
https://github.com/kowaremonoid/caliban-akka-http-sample

Calibanについて

純粋な関数型ライブラリでデータ型からGraphQLスキーマを自動的に導き出すMagnolia、クエリを解析するFastparse、様々な効果を処理するZIOに依存している。

依存の追加

Calibanを使うためにbuild.sbtに依存を追加

libraryDependencies += "com.github.ghostdogpr" %% "caliban" % "0.9.5"

HTTP ServerはAkka Http, JsonはCirceを使うため"caliban-akka-http"を選択した

libraryDependencies += "com.github.ghostdogpr" %% "caliban-akka-http"  % "0.9.5"
libraryDependencies += "de.heikoseeberger" %% "akka-http-circe" % "1.35.3"

あとAkka HttpのServerを立ち上げるのにAkkaのActorを使いますが, 今からやるならTypedが良さそうなので依存に追加してます

libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % "2.6.12"

Schema定義

以下のGraphQLを実装するとします

type Query {
  users: [User!]!
  user(id: Int): User
}

type User {
  id: Int!
  name: String!
}

Schemaを用意して

case class UserSchema(id: Int, name: String)

(今回は手動で用意しましたが, 既にSchemaがある場合に自動生成もできるようで良さそうです)
https://ghostdogpr.github.io/caliban/docs/schema.html#code-generation

Query定義

Queriesを作成します

API(GraphQLのQuery)を表すQueriesという名前のケースクラスを作成し、公開したい関数にちなんだ名前とモデルを持つ2つのフィールドを作成します。次に、実際の関数を呼び出すこのクラスの値を作成します。

object ExampleApi extends GenericSchema[GetUserService] {

  case class Queries(
    users: URIO[GetUserService, Seq[UserSchema]],
    user: UserIdArgs => URIO[GetUserService, Option[UserSchema]]
  )

GraphQL APIの定義を作成します。まず、RootResolverの中にQueryResolverを入れます。RootResolverは、query, mustation, subscribeを含むルートオブジェクトです。Queryだけは必須です。

  val api: GraphQL[Console with Clock with GetUserService] =
    graphQL(
      RootResolver(
        Queries(
          GetUserService.findUsers,
          args => GetUserService.findUser(args.id)
        )
      )
    )

}

Queriesで定義しといたGetUserServiceを用意する

object GetUserService {

  type GetUserService = Has[Service]

  trait Service {
    def findUsers: UIO[Seq[UserSchema]]

    def findByUserId(id: Int): UIO[Option[UserSchema]]
  }

  def findUsers: URIO[GetUserService, Seq[UserSchema]] = URIO.accessM(_.get.findUsers)

  def findUser(id: Int): URIO[GetUserService, Option[UserSchema]] = URIO.accessM(_.get.findByUserId(id))

  def make(initial: Seq[UserSchema] = sample): ZLayer[Any, Nothing, GetUserService] = ZLayer.fromEffect {
    for {
      users <- Ref.make(initial)
    } yield new Service {

      def findUsers: UIO[Seq[UserSchema]] = users.get

      def findByUserId(id: Int): UIO[Option[UserSchema]] = users.get.map(_.find(c => c.id == id))
    }
  }

}

Http Server

Akka Http Serverに

  • ZIOのRuntime
  • Route
    を設定すれば完成です
object ExampleApp extends App with AkkaHttpCirceAdapter {

  implicit val system: ActorSystem[String] = ActorSystem(Behaviors.empty[String], "example-app")

  implicit val executionContext: ExecutionContextExecutor = system.executionContext

  implicit val runtime: Runtime[GetUserService with Console with Clock] =
    Runtime.unsafeFromLayer(GetUserService.make() ++ Console.live ++ Clock.live, Platform.default)

  val interpreter = runtime.unsafeRun(ExampleApi.api.interpreter)

  val route = path("graphql") {
    adapter.makeHttpService(interpreter)
  }

  val bindingFuture = Http().newServerAt("localhost", 8088).bind(route)

  sys.addShutdownHook {
    bindingFuture
      .flatMap(_.unbind())
      .onComplete(_ => system.terminate())
  }
}

これでlocalからqueryを実行できるようになりました

❯ curl -X POST \
http://localhost:8088/graphql \
-H 'Host: localhost:8088' \
-H 'Content-Type: application/json' \
-d '{
"query": "query { users { id name }}"
}'


{
  "data" : {
    "users" : [
      {
        "id" : 1,
        "name" : "name1"
      },
      {
        "id" : 2,
        "name" : "name2"
      }
    ]
  }
}

Discussion