はじめに
昨年にGitHubがGraphQLのAPIを公開して大きな話題になりました。GraphQLに関する詳細はこちらの記事をご覧になっていただければと思います。最近ではBaaS(Backend as a Service)として手軽に利用できるGraphQLサーバー(Scaphold、Hasura、AWS Appsync等)も増えてきています。
JavaScriptで使われているメジャーなクライアントライブラリはRelayとApolloでしょうか、大きなプロジェクトでの採用実績も多くあるそうです。ElmからPorts経由でこれらのライブラリの利用することも可能とは思いますが、キャッシングやオフラインのサポート、サブスクリプションで同期だとか高度な事を望まなければ、所詮はJSONデータのやり取りなので、ElmからHttpで直接GraphQLサーバーを叩いて利用する事も可能です。
Elmのコードから、変数を文字列で書かれたGraphQLのクエリに埋め込み、さらにJSON形式にエンコードしてGraphQLサーバーに送ります。
{"operationName":null,"variables":{},"query":"{\n country(code: \"JP\") {\n code\n name\n }\n}\n"}
結果として返される以下の様なJSON形式の文字列をデコードしてElmの型に戻します。
{"data":{"country":{"code":"JP","name":"Japan"}}}
残念なことに、この方法ではElmとGraphQLがそれぞれ持つ型チェックの機能が活用されていません。また、規模が大きくなって複雑になってくると、この方法では破綻するのが容易に想像できます。
GraphQLサーバーとスキーマ
今回はオープンソースで公開されているシンプルなスキーマのGraphQL APIサーバー(Graphiqlコンソールとリポジトリ)を例にします。そこで定義されているスキーマの内容は下記の通りです。
type Continent {
code: String
name: String
}
type Country {
code: String
name: String
native: String
phone: String
continent: Continent
currency: String
languages: [Language]
emoji: String
emojiU: String
}
type Language {
code: String
name: String
native: String
rtl: Int
}
type Query {
continents: [Continent]
continent(code: String): Continent
countries: [Country]
country(code: String): Country
languages: [Language]
language(code: String): Language
}
typeで定義されている部分はGraphQLではオブジェクト型と呼ばれ、複数のフィールドを持つことができます。各フィールドには戻り値としての型が合わせて定義されています。詳しくはこちらを見て下さい。なんとなくElmの型に似ていますね。実際に全て以下の様なレコード型の別名で書くことができます。
type alias Continent =
{ code : Maybe String
, name : Maybe String
}
type alias Country =
{ code : Maybe String
, name : Maybe String
, native : Maybe String
, phone : Maybe String
, continent : Maybe Continent
, currency : Maybe String
, languages : Maybe (List (Maybe Language))
, emoji : Maybe String
, emojiU : Maybe String
}
type alias Language =
{ code : Maybe String
, name : Maybe String
, native : Maybe String
, rtl : Maybe Int
}
{-- 実際にはこの部分はElmのレコード型としては使いません
type alias Query =
{ continents: Maybe (List (Maybe Continent))
, continent: Maybe String -> Maybe Continent
, countries: Maybe (List (Maybe Country))
, country: Maybe String -> Maybe Country
, languages: Maybe (List (Maybe Language))
, language: String -> Maybe Language
}
--}
うーん
Maybe
だらけ…これは先のGraphQLのスキーマで、全てのフィールドがnullを許すとして定義されているためです。本来はスキーマ側でもっと制約をかけるべきなのでしょうが、スキーマを変更できない場合には受け入れる他ありません。
この2つの世界の間を出来るだけ楽をしながらかつ型安全に橋渡しする方法が欲しいのです。graphqlをキーワードとしてElm 0.18のパッケージを検索すると11個、Elm 0.19のパッケージでは淘汰されたのか4個が結果として得られます。
現時点で、この4個の中で最も活発に使われていると思われるのが、一番上のdillonkearns/elm-graphql
になります。全てを試したわけではありませんが、ほとんどはJson.Decode
とJson.Encode
をGraphQL用に合わせるアプローチで、フィールド名を文字列として書かなければならず、その点において型安全が失われてしまっています。
一方、@dillonkearnsのアプローチでは、外部ツールを導入することにより、GraphQLサーバーのイントロスペクション機能を利用してGraphQLのスキーマを解析し、Elmのコードを自動生成してくれます。生成されたコードは、主にGraphQLオブジェクトとそのフィールドをデコードするための関数で構成されます。これにより型安全なコードを書けることを保証してくれます。
環境の準備
以前に書いたparcel-bundlerとElmを組み合わせる内容をボイラープレートにした物を用意したのでそちらを利用します。最初に、GraphQLサーバーからElmコードを生成してくれるツールをnpmでインストールし、必要なElmのパッケージもインストールしておきます。
git clone git@github.com:kyasu1/elm-parcel-boilerplate.git
cd elm-parcel-boilerplate
npm install --save-dev @dillonkearns/elm-graphql
elm install dillonkearns/elm-graphql
elm install elm/json
スキーマからElm
のコードを生成
npx elm-graphql https://countries.trevorblades.com/ --base Country --output src/elm
今回はシンプルなスキーマなので使用するのは下記のファイルのみとなります:
-
Country/Query.elm
はスキーマ中のtype Query
で定義されたフィールドをデコードする関数の集まり -
Country/Object.elm
はスキーマ中で定義されたオブジェクトをElm上での表現するためのカスタム型の集まり -
Country/Object/Continent.elm
はスキーマ中のtype Continent
で定義されたフィールドをデコードする関数の集まり -
Country/Object/Country.elm
はスキーマ中のtype Coutnry
で定義されたフィールドをデコードする関数の集まり -
Country/Object/Language.elm
はスキーマ中のtype Language
で定義されたフィールドをデコードする関数の集まり
Elmアプリを書いてみる
まずは必要なモジュールをインポートします。
import Country.Object
import Country.Object.Continent as Continent
import Country.Object.Country as Country
import Country.Object.Language as Language
import Country.Query as Query
import Graphql.Http
import Graphql.Operation exposing (RootQuery)
import Graphql.OptionalArgument exposing (OptionalArgument(..))
import Graphql.SelectionSet exposing (SelectionSet, succeed, with)
Graphql
で始まるモジュールには実際にクエリを構築したり送信するためのヘルパー関数が含まれています。
シンプルなクエリ
先のスキーマを見るとcode
を引数にとり、返り値としてCountry
を返すクエリcountry
が定義されています。国コードを指定してその国の情報を取得することができます。
query {
....
country(code: String): Country
....
}
結果として得られるCountry
オブジェクトには多くのフィールドが定義されていますが、この中からcode
とname
のみを取得するシンプルなクエリ(SelectionSet
)をElmで構築してみます。
GraphQLのクエリで書くと次のようになります。
query {
country(code: "JP") {
code
name
}
}
自分の理解ではSelectionSet
はGraphQLクエリにおけるフィールドの集まりだと思っています。query
オブジェクトはcountry
フィールド一つで構成されるSelectionSet
を含みます。country
オブジェクトはcode
とname
の二つののフィールドで構成されるSelectionSet
を含むといった感じです。この階層に沿って分解してSelectionSet
を構成していきます。
まずは最終的に欲しいElmの型を定義します。先のスキーマではnull値を返す可能性があるため、全てのフィールドにMaybe
を付ける必要があります。
type alias Response =
{ countryRowMaybe : Maybe CountryRow
}
type alias CountryRow =
{ code : Maybe String
, name : Maybe String
}
query : String -> SelectionSet Response RootQuery
query code =
succeed Response
|> with (Query.country (\arg -> { arg | code = Present code }) countrySelection)
countrySelection : SelectionSet CountryRow Country.Object.Country
countrySelection =
succeed CountryRow
|> with Country.code
|> with Country.name
どっかで見たような。そうです、NoRedInk/elm-json-decode-pipeline
の記法と同じです。1
RootQuery
はGraphQLのスキーマ定義にあったtype query {}
オブジェクトに相当し、with
以下では必要なフィールドを取得してデコードし、最終的にResponse
型にして返すよう指示します。同様に、Country.Object.Country
はtype Country {}
オブジェクトに相当し、with
以下で必要なフィールドだけを取得・デコードし、最終的にCountryRow
型にして返すようするよう指示しています。
Query.country
は国コードを指定するための引数を取りますが、GraphQLのスキーマではnull
でも良いと定義されてしまっているため、(\arg -> { arg | code = Present code })
という形式で引数を渡す必要があります。もしidentity
を渡すと引数にnull
を指定したことになります。2
実際にこのクエリをサーバーに投げる場合にはGraphQL.Http
を使用します。
execQuery : Cmd Msg
execQuery =
query "JP"
|> Graphql.Http.queryRequest "https://countries.trevorblades.com/"
|> Graphql.Http.send GotRespponse
### ちょっと複雑なクエリ
GraphQLでは複数のオブジェクトや関連するオブジェクトを一度に取得できます。
query {
continents {
code
name
}
countries {
country {
code
name
native
phone
continent {
code
name
}
currency
languages {
code
name
native
rtl
}
emoji
emojiU
}
}
このクエリでは大陸の一覧と国の一覧を同時に取得しています。このクエリを発行するSelectionSet
を構築すると以下のようになります。
type alias Response =
{ continents : List Continent
, countries : List Country
}
query : SelectionSet Response RootQuery
query =
succeed Response
|> with
(Query.continents continentSelection
|> SelectionSet.nonNullOrFail
|> SelectionSet.nonNullElementsOrFail
)
|> with
(Query.countries countrySelection
|> SelectionSet.nonNullOrFail
|> SelectionSet.nonNullElementsOrFail
)
continentSelection : SelectionSet Continent Country.Object.Continent
continentSelection =
succeed Continent
|> with (Continent.code |> SelectionSet.nonNullOrFail)
|> with (Continent.name |> SelectionSet.nonNullOrFail)
countrySelection : SelectionSet Country Country.Object.Country
countrySelection =
succeed Country
|> with
(Country.code
|> SelectionSet.nonNullOrFail
)
|> with
(Country.name
|> SelectionSet.nonNullOrFail
)
|> with Country.native
|> with Country.phone
|> with
(Country.continent continentSelection
|> SelectionSet.nonNullOrFail
)
|> with Country.currency
|> with
(Country.languages languageSelection
|> SelectionSet.nonNullOrFail
|> SelectionSet.nonNullElementsOrFail
)
|> with Country.emoji
|> with (Country.emoji |> SelectionSet.withDefault "")
languageSelection : SelectionSet Language Country.Object.Language
languageSelection =
succeed Language
|> with
(Language.code
|> SelectionSet.nonNullOrFail
)
|> with Language.name
|> with Language.native
|> with Language.rtl
ここで、SelectionSet.nonNullOrFail
やSelectionSet.nonNullElementsOrFail
がパイプに追加されていますが、スキーマ上ではnullが許されているフィールドを、nullにならないはずと仮定して、Elmに渡す際に強制的にMaybeを外した値にしてくれます。万が一nullが返された場合にはデコードエラーとなります。
本来は、nullになることがないフィールドであれば、スキーマの設計の際にnon-nullなフィールドと定義するべきなのですが、パブリックなスキーマなどで、変更ができない場合にリスクを覚悟で使用します。また、nullになる可能性がありデフォルト値が存在する場合には、SelectionSet.withDefault
をパイプに追加して処理することも可能です。この場合にはエラーとなりません。
このスキーマ使った簡単な例を作りましたので参考にしてみて下さい。
動くデモ http://kyasu1.github.io/
リポジトリ https://github.com/kyasu1/elm-graphql-example
おわりに
- JSONデコーダを書いていく作業と
SelectionSet
の構築は似ています(というか同じ記法になるように合わせている)。前者でなんとなくデコーダを書いてコンパイルが通っても、フィールド名にタイポがあったり、自分の想定外のJSONが返されると実行時にデコードに失敗ます。こういったエラーの原因の追求には結構時間が取られたりします。一方、後者ではElmの型チェックによりコンパイル時にエラーが発生します。逆にコンパイルが通ればGraphQLスキーマに対しては実行時エラーが発生しないことを保証してくれます。 - 冒頭にも書いたように、当初はGraphQLクエリを文字列として書いてました。レコードに含まれるフィールドが微妙に異なれば、JSONデコーダも別々に用意する必要がありました。スキーマに変更があればその都度手書きで変更…とても苦痛でした。この辺の作業がほとんど自動化されてしまうのでとても楽ができて素晴らしいです。
- 紹介したパッケージの使い方を理解するには、
Json.Decode
の使い方が理解できているのが前提です。Json Decoderが不要になるわけではありません - 偶然にもちょっとだけ貢献しました
-
この記事を書いている間にアップデートされてこの記法に変わりました( https://github.com/dillonkearns/elm-graphql/blob/master/CHANGELOG-ELM-PACKAGE.md#200---2018-12-02 )。
Json.Decode
同じ様にmapN
を用いた記法も用意されています。 ↩ -
もっとわかりやすい方法が検討されているようです( https://discourse.elm-lang.org/t/optional-key-records-syntax-proposal/2634 )。 ↩