Sangria + Akka HTTPでGraphQL Connection編

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、かたいなかです。

前回に引き続き、GraphQLのScalaでの実装であるSangriaについて解説していきます。

今回はページネーションを行うAPIを作成する方法について解説します。

Connection

今回はRelayのCursor Connectionという仕様に従ってページネーションを実装します。 まずは今回実装していくAPIを例にConnectionがどのようなものかを見ていきましょう。

クエリおよびレスポンスは以下のような形式です。

edgesnode,cursorといった見慣れない名前がありますね。

順番に解説します。

クエリの引数

articlesのようなConnection型を返すフィールドはページネーションに関連した引数を取れるようにします。

前方にページを読み進めるための属性

  • after この引数で指定されたカーソルより後ろの要素を返す。
  • first afterで指定されたカーソルから(指定されていない場合は最初から)この引数で指定された個数分の要素を返す。

後方にページを戻るための属性

  • before この引数で指定されたカーソルより前の要素を返す。
  • last beforeで指定されたカーソルから逆向きにこの引数で指定された個数分の要素を返す。

上の画像の例では、afterを指定せずにfirstという値が指定されているため、最初の要素から3つを取得しています。

Connection

articlesフィールドはArticleConnectionという型を持ちます。

属性

  • edges ページ内の要素をカーソルの情報とまとめたEdge型の配列。
  • pageInfo 今回のレスポンスのページ情報。PageInfo型。

Edge

articlesがもつedgesはEdge型の配列です。

属性

  • node 検索対象のオブジェクト
  • cursor ページネーションで使用するカーソル

PageInfo

PageInfoは、今回レスポンスとして返した要素のページの情報を返します。

属性

  • hasNextPage 次のページが存在するか
  • hasPreviousPage 前のページが存在するか
  • endCursor edgesの最後の要素がもつカーソル
  • startCursor edgesの最初の要素のカーソル

実装

今回もSangriaを使用して実装していきます。Akka HTTPとつなぎ込んだりする部分は前回と同様なので今回の記事中では触れません。

今回は以下のようにクエリが実行できるようにします。

また、以下のように記事に関連するコメントも検索できるようにします。

依存ライブラリ

build.sbtの内容は以下のとおりです。

前回のブログのものにsangria-relayというライブラリへの依存を追加しました。

name := "sangria-example"

version := "0.1"

scalaVersion := "2.12.5"

libraryDependencies ++= Seq(
  "org.sangria-graphql" %% "sangria" % "1.4.0",
  "org.sangria-graphql" %% "sangria-circe" % "1.2.1",
  "org.sangria-graphql" %% "sangria-relay" % "1.4.0",

  "com.typesafe.akka" %% "akka-http" % "10.1.0",
  "de.heikoseeberger" %% "akka-http-circe" % "1.20.0",

  "io.circe" %%   "circe-core" % "0.9.2",
  "io.circe" %% "circe-parser" % "0.9.2",
  "io.circe" %% "circe-optics" % "0.9.2",
)

実際にデータを取得する

Schemaから呼び出されるページネーションされたデータを取得するメソッドを実装します。

  //メモリ内のコレクションを使用した実装
  //実際にはafter,before,first,lastを使用してDB等にどのようなクエリでアクセスするかを決定しデータを取得
  //データが取得できたらページング情報とともにデータを返すようにする
  def articleConnection(connectionArgs: ConnectionArgs): Connection[Article] =
    Connection.connectionFromSeq(articles, connectionArgs)

  def commentConnection(articleId: String, connectionArgs: ConnectionArgs): Connection[Comment] =
    Connection.connectionFromSeq(comments.filter(_.articleId == articleId), connectionArgs)

今回はsangria-relay内で用意されているConnection.connectionFromSeqというメソッドを使用してメモリ内のコレクションを用いた簡単な実装を作っています。

実際の開発では、クエリの引数として受け取るafter,before,first,lastの4つの値を使用してDB等から要素を取得し、ページング用の情報と合わせて結果を返すように実装することになるでしょう。

この部分についてMongoDBを使用した例として以下のコードが参考になります。

https://github.com/chandu0101/sri-sangria-example/blob/master/server/src/main/scala/sri/sangria/mongoserver/services/BaseService.scala#L52-L99

スキーマの実装

import sangria.relay._
import sangria.schema._

object SchemaDefinition {

  // GraphQL上のComment型を定義しています。
  val CommentType =
    ObjectType(
      "Comment",
      "コメント",
      fields[ArticleRepository, Comment](
        Field("id", StringType, Some("コメントのId"), resolve = _.value.id),
        Field("articleId",
          StringType,
          Some("コメントがついている記事のId"),
          resolve = _.value.articleId),
        Field("body", StringType, Some("コメントの本文"), resolve = _.value.body)
      )
    )

  //GraphQL上でのCommentConnection型を定義します
  val ConnectionDefinition(_, commentConnection) =
    Connection.definition[ArticleRepository, Connection, Comment]("Comments",
      CommentType)

  // GraphQL上のArticle型を定義しています。
  val ArticleType =
    ObjectType(
      "Article",
      "記事",
      fields[ArticleRepository, Article](
        Field("id", StringType,
          Some("記事のId"),
          // Scalaコード中のArticle型とのマッピングを記述しています(_.valueはArticle型)
          resolve = _.value.id),
        Field("title", StringType,
          Some("記事のタイトル"),
          resolve = _.value.title),
        Field("author", OptionType(StringType),
          Some("記事の著者"),
          resolve = _.value.author
        ),
        // commentsという要素で関連するCommentを取得するようにしています。
        Field("comments", OptionType(commentConnection),
          Some("記事についているコメント"),
          arguments = Connection.Args.All,
          resolve = ctx => ctx.ctx.commentConnection(ctx.value.id, ConnectionArgs(ctx))),
        Field("tags", ListType(StringType),
          Some("記事についているタグ"),
          resolve = _.value.tags)
      ))
  // このように記述することもできます
  //  import sangria.macros.derive._
  //  val ArticleType = deriveObjectType[ArticleRepository, Article](
  //    AddFields(
  //      Field(
  //        "comments",
  //        OptionType(commentsConnection),
  //        Some("記事についているコメント"),
  //        arguments = Connection.Args.All,
  //        resolve =
  //          ctx => ctx.ctx.commentsConnection(ctx.value.id, ConnectionArgs(ctx))
  //      ),
  //    )
  //  )

  //GraphQL上でのArticleConnection型を定義します
  val ConnectionDefinition(_, articleConnection) =
    Connection.definition[ArticleRepository, Connection, Article]("Article",
      ArticleType)

  // Queryに使用する引数です これはString型のidという名前を持つ引数
  val idArgument = Argument("id", StringType, description = "id")

  // Queryです。ここにクエリ操作を定義していきます。
  val QueryType = ObjectType(
    "Query",
    fields[ArticleRepository, Unit](
      Field(
        "article",
        OptionType(ArticleType),
        arguments = idArgument :: Nil,
        // ArticleRepositoryからどのように記事を取得するかを記述しています(ctx.ctxはArticleRepository型)
        resolve = ctx => ctx.ctx.findArticleById(ctx.arg(idArgument))),
      Field("articles",
        OptionType(articleConnection),
        arguments = Connection.Args.All,
        resolve = ctx => ctx.ctx.articleConnection(ConnectionArgs(ctx)))
    )
  )

  // 最後にSchemaを定義します。
  val ArticleSchema = Schema(QueryType)
}

上が今回のSchema定義のコードです。多くが前回の記事での内容と同一のため、重要な部分について解説します。

上記のコード中で記事の一覧をページネーションに対応させるのに重要な部分は以下です。

まず、GraphQLのArticleConnection型を定義します。

  val ConnectionDefinition(_, articleConnection) =
    Connection.definition[ArticleRepository, Connection, Article]("Article",
      ArticleType)

クエリの値を取得する際には、ConnectionArgs(ctx)とすることで、クエリで受け取ったafter,before,first,lastの4つの属性をもつConnectionArgs型のオブジェクトを作成し、Connectionオブジェクトを取得するメソッドに渡しています。

      Field("articles",
        OptionType(articleConnection),
        arguments = Connection.Args.All,
        resolve = ctx => ctx.ctx.articleConnection(ConnectionArgs(ctx)))

また、関連するコメントを取得するために重要な部分は以下です。

まず、上のArticleConnectionと同様に、GraphQLのCommentsConnection型を定義します。

  val ConnectionDefinition(_, commentConnection) =
    Connection.definition[ArticleRepository, Connection, Comment]("Comments",
      CommentType)

ある記事に関連するコメントとして取得できるようにするため、ArticleType内のフィールドとして定義します。ctx.value.idにより記事のIDを取得し、取得したIDを用いて記事に関連するコメントを取得しています。

        // commentsという要素で関連するCommentを取得するようにしています。
        Field("comments", OptionType(commentConnection),
          Some("記事についているコメント"),
          arguments = Connection.Args.All,
          resolve = ctx => ctx.ctx.commentConnection(ctx.value.id, ConnectionArgs(ctx))),

実際に実行してみる

ここまでで実装は完了したので、実際に実行して動作を確認してみましょう。

まずは記事の一覧のページネーションを試してみます。

hasNextPagetrueとなっているので、次のページがあるようです。そこで、endCursorで返ってきた値をafterに指定して次のページを取得してみましょう。

期待通りにページネーションされたデータを取得することができました。

同様に、ある記事に関連するコメントの情報も取得します。

こちらも期待通りにデータを取得することができました。

まとめ

今回はSangriaでRelayのConnectionの仕様に基づいてページネーションに対応したGraphQLのAPIを実装する方法を紹介しました。

今回の実装

https://github.com/katainaka0503/hello-sangria

参考資料

使用したライブラリ