nashcft's blog

時々何か書く。

Apollo Kotlin: エラーハンドリングのプラクティスと union type とコード生成方式

以前 Android で GraphQL あまり嬉しくないのでは的な tweet をいくつかしていて、その中でエラーの扱いに困るなというものがあった。

https://spec.graphql.org/draft/#sec-Errors.Error-Result-Format

大雑把にいうと top level の errors field は schema に載らない (= コード生成の対象にならない) ため結局エラー用の API document みたいなものを用意する必要が出てくる上に、アプリケーション由来の情報を入れるとなると errors.extensions field を使うことになるが、これは実質任意の構造の object なので Kotlin 上では Map<String, Any?>? の中を探るみたいな扱いになり面倒というものだった。

アプリケーション由来のエラーは schema で表現する

その後チームでエラーハンドリングどうしましょうを考える機会があって調べたり教えてもらったりで以下の記事を読んだ。

productionreadygraphql.com

sachee.medium.com

techblog.gaudiy.com

これらの記事を要約すると top level の errors field はシステム由来のエラーのみを扱うようにし、アプリケーション由来のエラー*1は schema 上で型として表現すべし、一箇所で複数種類のエラー状態の発生が起こりうる場合は union type を使おう、みたいなことが書いてある。この考え方ならそれぞれの operation に対してどのようなエラーが起こりうるのかやそれをクライアントでどのように扱うかも schema で管理共有できるようになるしクライアントサイドでもビジネスロジックに関わるエラーを安全に扱えるようになるので便利、じゃあこれでいきましょうとなり、この方針に沿って schema の構築が進められることになった。

Apollo Kotlin で union type を union type らしく使うにはコード生成方式を変える

デフォルトの生成モデルの問題点

ところで Apollo Kotlin で何も考えずに union type の値を取得しようとするとあまり嬉しくなくて、これがどういうことかはコードを見た方がわかりやすいと思うので以下で示していく。コード例としては上でリンクした GraphQL の union type のページに例として載っている query を使う (若干変更を加えている):

# schema
type Query {
  search(text: String!): [SearchResult!]!
}

union SearchResult = Human | Droid | Starship # それぞれの具体型の定義は省略

# query
query Search($text: String!){
  search(text: $text) {
    ... on Human {
      name
      height
    }
    ... on Droid {
      name
      primaryFunction
    }
    ... on Starship {
      name
      length
    }
  }
}

この query に対して生成されるコードは、モデル部分だけ取り出すと以下のような構造になる:

data class Data(
  val search: List<Search>,
) : Query.Data

data class Search(
  val __typename: String,
  val onHuman: OnHuman?,
  val onDroid: OnDroid?,
  val onStarship: OnStarship?
)

data class OnHuman(
  // ...
)

data class OnDroid(
  // ...
)

data class OnStarship(
  // ...
)

見てわかるように、 response として与えられるリストの要素を表すモデル Search は inline fragment で指定したそれぞれの型が個別の nullable な property として定義され、また型同士も独立した関係となっている。 Schema の定義上 SearchResult は non-null なので Human, Droid, Starship (Kotlin 上では OnHuman, OnDroid, OnStarship) のいずれかは存在するのだが、モデルとしては実際にインスタンスが作られるまでどれが入ってくるかわからないのでそれぞれの property は nullable で表さざるを得ないという感じ。これを扱うとなるとそれぞれの property の null check を行って non-null だったら取り出して云々、みたいになるが、 GraphQL の schema 上は大丈夫と言っても Kotlin 上ではそうとはわからないコードになるので見た目上安心度が低くなんか泥臭い雰囲気のものになってしまう。

val search = // query 実行して response.data.search 取ってくる

return search.map {
  when {
    it.onHuman != null -> // ...
    it.onDroid != null -> // ...
    it.onStarship != null -> // ...
  }
}

Response-based なコード生成方式

こういうのは Kotlin なら sealed class/interface として扱いたくなるものだし何かやり方あるでしょと調べてみるとあった。 Apollo Kotlin v3 から追加された機能で、コード生成方式を responseBased に指定することで上記のような inline fragment による field 指定を一つの property にまとめる形でモデル生成をしてくれるようになるというものだ。

www.apollographql.com

コード生成方式の指定は build.gradleapollo block 内で codegenModels に値をセットすることでできる。デフォルトの生成方式にも operationBased という名前がついていて明示的に指定することもできるが、実際には responseBased を指定する時しか codegenModels は使わないだろう。

apollo {
  service("service") {
    // ...
    codegenModels.set("responseBased")
  }
}

そして responseBased で生成した query のモデル部分は以下のような構造になる:

data class Data(
  val search: List<Search>,
) : Query.Data {
  sealed interface Search {
    val __typename: String

    companion object {
      fun Search.asHuman() = this as? HumanSearch
      fun Search.asDroid() = this as? DroidSearch
      fun Search.asStarship() = this as? StarshipSearch
    }
  }

  data class HumanSearch(
    // ...
  ) : Search

  data class DroidSearch(
    // ...
  ) : Search

  data class StarshipSearch(
    // ...
  ) : Search

  data class OtherSearch(
    // ...
  ) : Search
}

リストの要素 Search が data class から sealed interface に変わり、 inline fragment で指定した型はその具体型として表現されている。これで無駄な nullable に煩わされることなく when を使って具体型を特定して分岐させるという書き方ができるようになった。やったね。ところで OtherSearch という型が生成されているが、これは例えば interface で返す field になっていて、 interface で定義されてる field を取りつつ特定の具体型だったらこの field も、みたいな書き方をしている時に inline fragment などで指定した型にマッチしないものが取得された場合の受け皿として用意されているものと思われる。

responseBased なコード生成に関しては inline fragment だけでなく自分で定義した fragment に対しても同じように機能したり field を merge してくれたりと他にも色々トピックはあるが、詳しくは上で紹介した公式ドキュメントを読んでほしい。 Apollo Kotlin のコード生成まわりは公式ドキュメント以外にも以下の記事や design document を読むと理解が深まるかもしれない。

www.apollographql.com

github.com

おわりに

GraphQL とやりとりする際のエラーハンドリングに関するアプリケーション由来のエラーを schema で表現するプラクティスと、それで union type を使う際に Apollo Kotlin ではコード生成方式を responseBased を指定すると Kotlin 側でも sealed interface として扱えるようになるので便利という話をした。ハンドリングが必要なエラー状態を schema で表現するという schema 設計方針を採用できるなら GraphQL を Android/Kotlin で扱う上での嬉しくないポイントが一つ解消されるなあという所感なのだが、実際のところどのくらい普及しているプラクティスなのだろうか。

*1:エラーというか失敗シナリオと呼ぶべきか