Sangria + Akka HTTPでGraphQL 入門編

スタートプラン

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

最近では、GitHubのAPIがGraphQLに対応してしばらくたち、また、先日からAWSのAppSyncもパブリックプレビューとして提供されており、世の中のGraphQLへの熱がだんだんと高まってきているのを感じます。

そこで今回から数回に渡って、ScalaのGraphQL実装であるSangriaというライブラリを使用してGraphQLのAPIを実装する方法を紹介していきます。

GraphQL

GraphQLはFacebookによって開発されたAPIに対するクエリ言語です。

GraphQLを使用することで、クライアントは必要とするデータの情報をGraphQLのクエリとして送信し、サーバはそれに応じてレスポンスを返します。 これにより、クライアントが一つの画面で必要な情報を得るために何度もAPIに対してリクエストしたり、それを避けるために特定のクライアント専用のBFFを用意することなく、クライアントが必要とする情報を1度のリクエストでまとめて取得できるようになります。

実装してみよう

GraphQLにはJavascriptやJava,Rustなど様々な言語での実装が存在しているのですが、今回はScalaの実装であるSangriaを使用して実装していきます。

今回の記事ではブログ記事APIの簡単な例を実装してみます。 具体的には、以下のようなクエリを送信すると、

1
2
3
4
5
6
7
8
9
10
11
12
query GetArticle {
  getAllArticles {
    id
    title
    tags
  }
  getArticle(id: "1") {
    id
    title
    tags
  }
}

以下のようなレスポンスが取得できることを目指します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
  "data": {
    "getAllArticles": [
      {
        "id": "1",
        "title": "AWSの話",
        "tags": [
          "aws",
          "インフラ"
        ]
      },
      {
        "id": "2",
        "title": "Scalaの話",
        "tags": [
          "null",
          "許さない"
        ]
      }
    ],
    "getArticle": {
      "id": "1",
      "title": "AWSの話",
      "tags": [
        "aws",
        "インフラ"
      ]
    }
  }
}

実装は以下の順番で行っていきます。

  1. 依存ライブラリの定義
  2. 必要なデータを取得するリポジトリの実装
  3. GraphQLのスキーマの実装
  4. HTTPでクエリを受け取り実行する部分の実装

依存ライブラリ

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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",
 
  "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",
)

必要なデータを取得するリポジトリ

まずはGraphQLへのQueryの実体となる部分データを取得できるようにしてみましょう。

まずは、取得したいデータの型 Article とそれを取得するためのリポジトリ ArticleRepository を定義します。

1
2
3
4
5
6
7
8
9
10
11
case class Article(id: String, title: String, author: Option[String], tags: List[String])
 
class ArticleRepository {
  def getArticle(id: String): Option[Article] = ArticleRepository.articles.find(_.id == id)
}
 
object ArticleRepository {
  val articles = List(
    Article("1", "AWSの話", Some("yamasaki"), List("aws", "インフラ")),
    Article("2", "Scalaの話", None, List("null", "許さない")))
}

今回は、サンプルであるため、ArticleRepository.getArticle の実装はメモリ内のリストを使用したものになっていますが、実際のアプリケーションではDBにアクセスを行うなどしてデータを取得するように実装されます。

スキーマの定義

次にGraphQLのスキーマを定義していきます。

先程定義したリポジトリのようなContextや、クエリの引数の値などからどのように結果の値を得るかを記述しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import sangria.schema._
 
object SchemaDefinition {
 
  // GraphQL上のArticle型を定義しています。
  val Article =
    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
        ),
        Field("tags", ListType(StringType),
          Some("記事についているタグ"),
          resolve = _.value.tags)
      ))
  // このように記述することもできます
  // import sangria.macros.derive._
  // val Article = deriveObjectType[ArticleRepository, Article]
 
  // Queryに使用する引数です これはString型のidという名前を持つ引数
  val ID = Argument("id", StringType, description = "id")
 
  // Queryです。ここにクエリ操作を定義していきます。
  val Query = ObjectType(
    "Query", fields[ArticleRepository, Unit](
      Field("getArticle", OptionType(Article),
        arguments = ID :: Nil,
        // ArticleRepositoryからどのように記事を取得するかを記述しています(ctx.ctxはArticleRepository型)
        resolve = (ctx) ⇒ ctx.ctx.getArticle(ctx.arg(ID))),
      Field("getAllArticles", ListType(Article),
        resolve = (ctx) ⇒ ctx.ctx.getAllArticles
    ))
  )
 
  // 最後にSchemaを定義します。
  val ArticleSchema = Schema(Query)
}

クエリをHTTPで受け取る

最後にAkka-HTTPでリクエストを受け付け、Sangriaでクエリを実行するところを実装します。

この部分はsangria-akka-http-exampleのコードをほぼそのまま使用しています。 src/main/scala/Server.scalaについては、コードの一部をここまで実装したスキーマとリポジトリを使用するように修正しています。 resource/assets/graphiql.htmlおよびsrc/main/scala/GraphQLUnmarshaller.scalasangria-akka-http-exampleのリポジトリの実装をそのままコピーして使用します。

sangria-akka-http-exampleのコードをほぼそのまま使用していることや、実装したいGraphQLのスキーマが変わっても変化する部分ではないことから、 処理の流れ上重要なところと変更を行った部分を抜粋して説明します。

今回のGraphQLを実行する際には、applicaiton/graphqlapplicaiton/jsonのコンテンツタイプで以下のような形式のJsonをボディに含めることでクエリをサーバに送信するようにします。

1
2
3
4
5
{
  "query": "query SomeQuery ...",
  "operationName": "operation name",
  "variables": { "myVariable": "someValue"}
}

上記の形式のjsonをパースしgraphQLを実行するメソッドに引数をわたしている部分の実装がこちらです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
post {
    parameters('query.?, 'operationName.?, 'variables.?) { (queryParam, operationNameParam, variablesParam) ⇒
      // `application/json`
      entity(as[Json]) { body ⇒
        val query = queryParam orElse root.query.string.getOption(body)
        val operationName = operationNameParam orElse root.operationName.string.getOption(body)
        val variablesStr = variablesParam orElse root.variables.string.getOption(body)
 
        query.map(QueryParser.parse(_)) match {
          case Some(Success(ast)) ⇒
            variablesStr.map(parse) match {
              case Some(Left(error)) ⇒ complete(BadRequest, formatError(error))
              case Some(Right(json)) ⇒ executeGraphQL(ast, operationName, json)
              // Parseが成功したらクエリを実行
              case None ⇒ executeGraphQL(ast, operationName, root.variables.json.getOption(body) getOrElse Json.obj())
            }
          case Some(Failure(error)) ⇒ complete(BadRequest, formatError(error))
          case None ⇒ complete(BadRequest, formatError("No query to execute"))
        }
      } ~
        // `application/graphql`
        entity(as[Document]) { document ⇒
          variablesParam.map(parse) match {
            case Some(Left(error)) ⇒ complete(BadRequest, formatError(error))
            case Some(Right(json)) ⇒ executeGraphQL(document, operationNameParam, json)
            case None ⇒ executeGraphQL(document, operationNameParam, Json.obj())
          }
        }
    }
  }
} ~

そして実際にGraphQLの実行を行う部分ではsangria-akka-http-exampleのコードから少し修正し、この記事の中で作成したArticleSchemaとArticleRepositoryを使用するように修正しました。

1
2
3
4
5
6
7
8
9
def executeGraphQL(query: Document, operationName: Option[String], variables: Json) =
  complete(Executor.execute(SchemaDefinition.ArticleSchema, query, new ArticleRepository,
    variables = if (variables.isNull) Json.obj() else variables,
    operationName = operationName,
    .map(OK → _)
    .recover {
      case error: QueryAnalysisError ⇒ BadRequest → error.resolveError
      case error: ErrorWithResolver ⇒ InternalServerError → error.resolveError
    })

また、テスト実行ができるようgraphqlにGETでアクセスするとgraphiqlを返すようになっています。

1
2
3
4
5
6
7
val route: Route =
    path("graphql") {
      get {
        explicitlyAccepts(`text/html`) {
          getFromResource("assets/graphiql.html")
        }
      } ~

上記のように定義したルートを元に8080番ポートでリクエストを受け付けるように実行します。

1
Http().bindAndHandle(route, "0.0.0.0", sys.props.get("http.port").fold(8080)(_.toInt))

実際に実行してみる

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

プロジェクトルートで以下のコマンドを実行し、APIサーバをローカルで起動します。

$ sbt run

この状態でlocalhost:8080/graphqlにブラウザでアクセスすると次のようなGraphiQLのページが表示されます。

ここにクエリを入力して実行してみましょう。

すばらしい!GraphQLでデータを取得することができました!

まとめ

GraphQLの実装であるSangriaを動かす方法を簡単な例を実装しながら解説しました。 GraphQLのAPIサーバを実装するのはそれほど難しくないことがお分かりいただけたと思います。

今回は単純なデータを取得しただけですが、次回以降はデータの更新処理を実行するMutation、またオブジェクト間の関連をもとにした取得やページング、さらには属性ごとの認可など、実際に使うにあたって必要になる機能について解説していきます。

今回実装したコード

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

参考資料

使用したライブラリ

スタートプラン