はじめての Elm × GraphQL 〜 Elm でポケモンをレンダリングするまで 〜
この記事は Elm アドベントカレンダー22 日目の記事です。
Elm のお試しとして GraphQL Pokemon のデータをただ描画するだけのアプリを作りました。Elm × GraphQL の入門に良いと思ったのでチュートリアル形式でまとめます。
作るもの
GraphQL Pokemon へクエリを投げて、レスポンスをレンダリングする Elm アプリを作ります。
技術スタックは以下の通りです。
- Elm
0.19.1
- elm-graphql
5.0.3
- create-elm-app
5.22.0
今回説明するコードは全てこちらのリポジトリにあります。もし動かない等あればご確認ください。
1. プロジェクトの作成
create-react-app の Elm 版的な create-elm-app でプロジェクトを作成します。
主要ファイルとビルド環境を一度に作ってくれるので便利です。
$ npx create-elm-app my-app
my-app に移動すると以下のようにディレクトリが作られているはずです。
my-app/
├── .gitignore
├── README.md
├── elm.json
├── elm-stuff
├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── logo.svg
│ └── manifest.json
├── src
│ ├── Main.elm
│ ├── index.js
│ ├── main.css
│ └── serviceWorker.js
└── tests
└── Tests.elm
これでアプリを起動します。
npx elm-app start
しばらく待つと localhost でアプリが起動します。
Elm の環境構築は完了です。
画面を作る段階でスタイリングを楽にするため、先に CSS フレームワークの Bulma と Bulma のクラスを Elm 上で型安全に使える ahstro/elm-bulma-classes
を追加します。
$ yarn add bulma
$ elm install ahstro/elm-bulma-classes
src/index.js に以下を追記してください。これでビルド時に Bulma の CSS が読み込まれます
import 'bulma/css/bulma.min.css';
2. elm-graphqlの追加 & Code Generate
Elm の GraphQL クライアントは色々あるみたいなのですが、今回は 型安全 に GraphQL クエリを書きたいので、GraphQL のスキーマから型や関連関数を自動生成してくれる dillonkearns/elm-graphql
を使います。
まずインストール。
elm-graphql は elm のパッケージだけでなく、npm パッケージも追加するのがポイントです。
$ elm install dillonkearns/elm-graphql
$ elm install elm/json
$ elm install krisajenkins/remotedata
$ yarn add -D @dillonkearns/elm-graphql
続いて elm-graphql の Code Generator を起動するスクリプトを package.json に追記します。
ここで、GraphQL Pokemonの URL を指定します。
"scripts": {
"code:generate": "elm-graphql https://graphql-pokemon2.vercel.app/ --base Pokemon"
}
この状態でyarn code:generate
を実行すると、以下のようにsrc/Pokemons
配下にファイルが自動生成されます。
src/Pokemon/
├── Enum
├── InputObject
├── InputObject.elm
├── Interface
├── Interface.elm
├── Object
│ ├── Attack.elm
│ ├── Pokemon.elm
│ ├── PokemonAttack.elm
│ ├── PokemonDimension.elm
│ └── PokemonEvolutionRequirement.elm
├── Object.elm
├── Query.elm
├── Scalar.elm
├── ScalarCodecs.elm
├── Union
├── Union.elm
├── VerifyScalarCodecs.elm
└── elm-graphql-metadata.json
これが Elm 上で GraphQL を型安全に使うための肝となります。
3. 実装
続いて本体を実装していきます。
コードが長いのでポイントのみ抜粋します。
全体のコードは以下をご覧ください。
GraphQLClient.elm
module GraphQLClient exposing (makeGraphQLQuery)
import Graphql.Http
import Graphql.Operation exposing (RootQuery)
import Graphql.SelectionSet as SelectionSet exposing (SelectionSet)
graphql_url : String
graphql_url =
"https://graphql-pokemon2.vercel.app/"
makeGraphQLQuery : SelectionSet decodesTo RootQuery -> (Result (Graphql.Http.Error decodesTo) decodesTo -> msg) -> Cmd msg
makeGraphQLQuery query decodesTo =
query
|> Graphql.Http.queryRequest graphql_url
|> Graphql.Http.send decodesTo
Main.elm
module Main exposing (..)
import Browser
import Bulma.Classes as Bulma
import GraphQLClient exposing (makeGraphQLQuery)
import Graphql.Http
import Graphql.Operation exposing (RootQuery)
import Graphql.SelectionSet as SelectionSet exposing (SelectionSet)
import Html exposing (Html, div, figure, h1, img, p, text)
import Html.Attributes exposing (class, src)
import List
import Pokemon.Object
import Pokemon.Object.Pokemon as Pokemon
import Pokemon.Query as Query exposing (PokemonsRequiredArguments)
import Pokemon.ScalarCodecs
import RemoteData exposing (RemoteData)
---- MODEL ----
type alias Pokemon =
{ id : Pokemon.ScalarCodecs.Id
, name : Maybe String
, image : Maybe String
}
type alias Pokemons = Maybe (List (Maybe Pokemon))
type alias PokemonData =
RemoteData (Graphql.Http.Error Pokemons) Pokemons
type alias Model =
{ pokemons : PokemonData }
init : ( Model, Cmd Msg )
init =
( { pokemons = RemoteData.Loading
}
, fetchPokemons 151
)
---- UPDATE ----
type Msg
= FetchDataSuccess PokemonData
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
FetchDataSuccess response ->
updatePokemonsData response model Cmd.none
updatePokemonsData : PokemonData -> Model -> Cmd Msg -> ( Model, Cmd Msg )
updatePokemonsData data model cmd =
( { model | pokemons = data }, cmd )
---- VIEW ----
view : Model -> Html Msg
view model =
div [ class Bulma.container ]
[ img [ src "https://i.gyazo.com/480551bded5134ddacf08616b2595717.png" ] []
, h1 [ class Bulma.title, class Bulma.is4, class Bulma.mb6 ] [ text "Pokemons with elm-graphql" ]
, renderPokemonList model.pokemons
]
renderPokemonList : PokemonData -> Html Msg
renderPokemonList pokemonData =
let
renderPokemon pokemon =
div [ class Bulma.card ]
[ div [ class Bulma.cardImage ]
[ figure [ class Bulma.image, class Bulma.is16by9, class Bulma.mx5, class Bulma.mt5 ]
[ img [ src (pokemon.image |> Maybe.withDefault "") ] []
]
]
, div [ class Bulma.cardContent ]
[ p [ class Bulma.isSize4 ] [ text (pokemon.name |> Maybe.withDefault "") ]
]
]
renderPokemons maybePokemons =
maybePokemons
|> Maybe.withDefault []
|> List.map
(\maybePokemon ->
div [ class Bulma.column, class Bulma.is3 ]
[ maybePokemon
|> Maybe.map renderPokemon
|> Maybe.withDefault (text "")
]
)
|> div [ class Bulma.columns, class Bulma.isMultiline ]
in
case pokemonData of
RemoteData.NotAsked ->
p [ class Bulma.isSize4, class Bulma.hasTextCentered ] [ text "not" ]
RemoteData.Success maybePokemons ->
renderPokemons maybePokemons
RemoteData.Loading ->
p [ class Bulma.isSize4, class Bulma.hasTextCentered ] [ text "loading..." ]
RemoteData.Failure err ->
p [ class Bulma.isSize4, class Bulma.hasTextCentered ] [ text "Error" ]
---- GraphQL API ----
pokemonsRequiredArguments : Int -> PokemonsRequiredArguments
pokemonsRequiredArguments num =
{ first = num }
pokemonListSelection : SelectionSet Pokemon Pokemon.Object.Pokemon
pokemonListSelection =
SelectionSet.map3 Pokemon
Pokemon.id
Pokemon.name
Pokemon.image
fetchPokemonsQuery : Int -> SelectionSet Pokemons RootQuery
fetchPokemonsQuery num =
Query.pokemons (pokemonsRequiredArguments num) pokemonListSelection
fetchPokemons : Int -> Cmd Msg
fetchPokemons num =
makeGraphQLQuery (fetchPokemonsQuery num) (RemoteData.fromResult >> FetchDataSuccess)
---- PROGRAM ----
main : Program () Model Msg
main =
Browser.element
{ view = view
, init = \_ -> init
, update = update
, subscriptions = always Sub.none
}
GraphQL Client
GraphQL のリクエストを送るためのクライアントです。
GraphQL Pokemon には認証が不要なので、elm-graphql で提供されている関数にエンドポイントの URL を渡すだけで OK です。
graphql_url : String
graphql_url =
"https://graphql-pokemon2.vercel.app/"
makeGraphQLQuery : SelectionSet decodesTo RootQuery -> (Result (Graphql.Http.Error decodesTo) decodesTo -> msg) -> Cmd msg
makeGraphQLQuery query decodesTo =
query
|> Graphql.Http.queryRequest graphql_url
|> Graphql.Http.send decodesTo
Type alias & Model
Main.elm の type alias 及び Model です。
GraphQL リクエストのレスポンスはkrisajenkins/remotedata
の RemoteData を使いハンドリングします。init のタイミングでポケモンを取得する GraphQL クエリ実行しています。
type alias Pokemon =
{ id : Pokemon.ScalarCodecs.Id
, name : Maybe String
, image : Maybe String
}
type alias Pokemons =
Maybe (List (Maybe Pokemon))
type alias PokemonData =
RemoteData (Graphql.Http.Error Pokemons) Pokemons
type alias Model =
{ pokemons : PokemonData }
init : ( Model, Cmd Msg )
init =
( { pokemons = RemoteData.Loading
}
, fetchPokemons 151
)
GraphQL Query
GraphQL のクエリ部分です。
pokemonsRequiredArguments
で GraphQL クエリ時の引数。pokemonListSelection
で取得するフィールドを組み立てています。
どのクエリを呼び出すかどうかはfetchPokemonsQuery
のQuery.pokemons
で指定します。これはelm-graphql
で自動的に生成される関数です。
最後にfetchPokemons
で、先ほど作ったmakeGraphQLQuery
を使った GraphQL リクエストを実行する関数を作っています。
elm-graphql から提供されいている型・関数を使うことで、完全に型で守られた状態で GraphQL クエリがかけます。すごい!
pokemonsRequiredArguments : Int -> PokemonsRequiredArguments
pokemonsRequiredArguments num =
{ first = num }
pokemonListSelection : SelectionSet Pokemon Pokemon.Object.Pokemon
pokemonListSelection =
SelectionSet.map3 Pokemon
Pokemon.id
Pokemon.name
Pokemon.image
fetchPokemonsQuery : Int -> SelectionSet Pokemons RootQuery
fetchPokemonsQuery num =
Query.pokemons (pokemonsRequiredArguments num) pokemonListSelection
fetchPokemons : Int -> Cmd Msg
fetchPokemons num =
makeGraphQLQuery (fetchPokemonsQuery num) (RemoteData.fromResult >> FetchDataSuccess)
Update
状態変更は Update で行います。
GraphQL クエリの実行後に呼ばれる FetchDataSuccess の Msg でレスポンスから Model の更新を行っています。
type Msg
= FetchDataSuccess PokemonData
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
FetchDataSuccess response ->
updatePokemonsData response model Cmd.none
updatePokemonsData : PokemonData -> Model -> Cmd Msg -> ( Model, Cmd Msg )
updatePokemonsData data model cmd =
( { model | pokemons = data }, cmd )
View
最後に View です。
renderPokemons
で pokemonData にある RemoteData の値によって処理を変更しています。
RemoteData が Success の場合のみ結果の HTML を表示するようになっています。
RemoteData を使うとリクエスト中は、ローディング文字列を出すなども簡単に出来るので良いですね。
view : Model -> Html Msg
view model =
div [ class Bulma.container ]
[ img [ src "https://i.gyazo.com/480551bded5134ddacf08616b2595717.png" ] []
, h1 [ class Bulma.title, class Bulma.is4, class Bulma.mb6 ] [ text "Pokemons with elm-graphql" ]
, renderPokemonList model.pokemons
]
renderPokemonList : PokemonData -> Html Msg
renderPokemonList pokemonData =
let
renderPokemon pokemon =
div [ class Bulma.card ]
[ div [ class Bulma.cardImage ]
[ figure [ class Bulma.image, class Bulma.is16by9, class Bulma.mx5, class Bulma.mt5 ]
[ img [ src (pokemon.image |> Maybe.withDefault "") ] []
]
]
, div [ class Bulma.cardContent ]
[ p [ class Bulma.isSize4 ] [ text (pokemon.name |> Maybe.withDefault "") ]
]
]
renderPokemons maybePokemons =
maybePokemons
|> Maybe.withDefault []
|> List.map
(\maybePokemon ->
div [ class Bulma.column, class Bulma.is3 ]
[ maybePokemon
|> Maybe.map renderPokemon
|> Maybe.withDefault (text "")
]
)
|> div [ class Bulma.columns, class Bulma.isMultiline ]
in
case pokemonData of
RemoteData.NotAsked ->
p [ class Bulma.isSize4, class Bulma.hasTextCentered ] [ text "not" ]
RemoteData.Success maybePokemons ->
renderPokemons maybePokemons
RemoteData.Loading ->
p [ class Bulma.isSize4, class Bulma.hasTextCentered ] [ text "loading..." ]
RemoteData.Failure err ->
p [ class Bulma.isSize4, class Bulma.hasTextCentered ] [ text "Error" ]
以上で終わりです。
以下コマンドを実行すればポケモンが表示されるはずです!
完成 🎉
$ npx elm-app start
おわりに
以上「はじめての Elm × GraphQL」でした。
以前 Elm の勉強会に参加したものの、結局それから Elm を触る機会を持てず Elm アドベントカレンダー期限の 4 日前まできてしまい急遽作ったのがこちらです😅
まだまだ Elm に慣れていないので、もしおかしいところあれば気軽にコメント頂けると嬉しいです。
正直最初はどうにもコンパイルエラーが直せず、めちゃくちゃ詰まったのですが、新しい言語を試行錯誤しながら学ぶ過程が面白かったです。
今回のアプリ作成で、少し Elm と仲良くなれた気がするので、機会見てちょこちょこ書いていきたいなと思っています。
Discussion