Elm

ElmからPhoenix v1.3のRest APIを叩いてみる

 前に書いた記事「ElmからPhoenixのRest APIを叩いてみる - Qiita」が少し古いバージョンのものだったので、同じ内容をあらためてPhoenix v1.3で試してみました。Phoenix v1.3で変わったところは大きく2つあります。ひとつはmix phx.newで作られるプロジェクトフォルダのディレクトリ構成がかわったことです。2つ目はmix phx.gen.json でcontextを指定する必要があることです。その点に留意してみていきたいと思います。

1.PhoenixでRest APIを実装する

 まずプロジェクトを作成します。

mix phx.new phoenixv13_elm_test
cd phoenixv13_elm_test

 データベースを初期化しておきます。

mix ecto.create

 次にRest APIを素早く作るためにphx.gen.jsonを利用します。phx.gen.jsonはcontrollerやviewsなどのRest APIリソースを生成してくれます。これでコードを書くことなくRest APIを実装できてしまうから驚きものです。Phoenixの威力を実感できるところです。Phoenix v1.3で変わったところは、phx.gen.jsonの引数です。第一引数が増えていてcontextを指定する必要があります。contextをどのように利用するかは私自身十分に咀嚼できていません。とりあえず関連ファイルがcontextのディレクトリにまとめて生成されますが、ここではあまり意識しません。少なくとも今までと同じように使えるようです。

mix phx.gen.json Articles Article articles title:string story:text

 第1引数のArticlesがcontextで、第2引数のArticleがリソース名(単体)、第3引数のarticlesがリソース名(複数)です。以下のようなメッセージが表示されますので、従います。

Add the resource to your :api scope in lib/phoenixv13_elm_test_web/router.ex:

    resources "/articles", ArticleController, except: [:new, :edit]


Remember to update your repository by running migrations:

    $ mix ecto.migrate

 Phoenix v1.3ではrouterのディレクトリが、lib/phoenixv13_elm_test_web/router.exのようになっています。デフォルトでapi scopeはコメントアウトされていますので、以下のように書き換えます。

lib/phoenixv13_elm_test_web/router.ex
  #
  # Other scopes may use custom stacks.
  scope "/api", Phoenixv13ElmTestWeb do
    pipe_through :api

    resources "/articles", ArticleController, except: [:new, :edit]
  end
  #

 次にmix ecto.migrateのコマンドを促していますので実行します。migrationとは、migrationファイルというテーブル定義書から、tableを作成したりfieldやindexを追加したり、ロールバックさせたりする機能です。

$ mix ecto.migrate

 migrationの結果、以下のようにファイルができます。

$ ls -l priv/repo/migrations/
合計 4
-rw-r--r-- 1 root root 220  2月 12 11:00 20180212020045_create_articles.exs

 最終的にサーバーを起動し、アクセスしてみましょう

$ mix phx.server

 別ターミナルにて、以下のcurlコマンドでデータを入力します

curl "http://www.mypress.jp:4000/api/articles" -X POST -d "article[title]=12月29日 晴れ" -d "article[story]=今日は晴れでした。"
curl "http://www.mypress.jp:4000/api/articles" -X POST -d "article[title]=12月30日 曇り" -d "article[story]=今日は大掃除をしました。エアコンの掃除は大変でした。"
curl "http://www.mypress.jp:4000/api/articles" -X POST -d "article[title]=12月31日 晴れのち雨" -d "article[story]=今日は大晦日です。夜更かしします。"

 article[title]やarticle[story]のarticleはphx.gen.jsonの第2引数で指定したschema名です。これは自動生成されたlib/phoenixv13_elm_test_web/controllers/article_controller.exで、以下のようにcreateの引数がarticleで受けているのに対応します。curlコマンドでarticles[title]などとするとエラーになります。

lib/phoenixv13_elm_test_web/controllers/article_controller.ex
#
def create(conn, %{"article" => article_params}) do
#

 以下のcurlコマンドで入力データを取得してみます。

$ curl -X GET "http://www.mypress.jp:4000/api/articles"
{"data":[{"title":"12月29日 晴れ","story":"今日は晴れでした。","id":1},
{"title":"12月30日 曇り","story":"今日は大掃除をしました。エアコンの掃除は大変でした。","id":2},
{"title":"12月31日 晴れのち雨","story":"今日は大晦日です。夜更かしします 。","id":3}]}

 以上でArtcles contextに関する設定が終わりました。
 Articlesと同じようにしてTags contextの設定を行っていきます。

mix phx.gen.json Tags Tag tags name:string

 以下のメッセージに従います。

Add the resource to your :api scope in lib/phoenixv13_elm_test_web/router.ex:

    resources "/tags", TagController, except: [:new, :edit]


Remember to update your repository by running migrations:

    $ mix ecto.migrate

 まずrouter.exに追加します。

lib/phoenixv13_elm_test_web/router.ex
  #
  # Other scopes may use custom stacks.
  scope "/api", Phoenixv13ElmTestWeb do
    pipe_through :api

    resources "/articles", ArticleController, except: [:new, :edit]
    resources "/tags", TagController, except: [:new, :edit]
  end
  #

 次にmix ecto.migrateを行います。

 $ mix ecto.migrate

 migrateの結果を確認します。

$ ls -l priv/repo/migrations/
合計 8
-rw-r--r-- 1 root root 220  2月 12 11:00 20180212020045_create_articles.exs
-rw-r--r-- 1 root root 187  2月 12 11:23 20180212022310_create_tags.exs

 phoenixを起動します。

mix phx.server

 別ターミナルで、curlコマンドでデータを投入し、curlコマンドで投入データを確認します。

curl "http://www.mypress.jp:4000/api/tags" -X POST -d "tag[name]=猫"
curl "http://www.mypress.jp:4000/api/tags" -X POST -d "tag[name]=犬"
curl "http://www.mypress.jp:4000/api/tags" -X POST -d "tag[name]=クジラ"
curl "http://www.mypress.jp:4000/api/tags" -X POST -d "tag[name]=ヤギ"
curl "http://www.mypress.jp:4000/api/tags" -X POST -d "tag[name]=タカ"

curl -X GET "http://www.mypress.jp:4000/api/tags"
{"data":[{"name":"猫","id":1},
{"name":"犬","id":2},
{"name":"クジラ","id":3},
{"name":"ヤギ","id":4},
{"name":"タカ","id":5}]}

2.Elmコードの設定

 ここでは「ElmのバックエンドとしてPhoenixを使う - Qiita」記事に従って設定を行います。但しPhoenix v1.3ではディレクトリ構成が変更されているので、JavaScript/Elmのアセットの設定も変える必要があります。

 Phoenixでは、Brunchを使って、静的なアセットをビルドしています。Elmコードはbrunchに従ってコンパイルされ、ロードされます。幸いなことに、ElmコードをコンパイルするためのBrunch pluginであるelm-brunchが利用できます。

 それではelm-brunchをインストールします。assetsディレクトリ下でnpmコマンドを発行してください。

cd assets/
npm install --save-dev elm-brunch

 次にElmプログラムの置き場所(ディレクトリ)を作成しておきます。

cd ..
mkdir lib/phoenixv13_elm_test_web/elm

 brunch-config.jsを編集します。2か所の追加が必要です(追加1と追加2)。

assets/brunch-config.js
exports.config = {
  // See http://brunch.io/#documentation for docs.
  files: {
    javascripts: {
      joinTo: "js/app.js"

      // To use a separate vendor.js bundle, specify two files path
      // http://brunch.io/docs/config#-files-
      // joinTo: {
      //   "js/app.js": /^js/,
      //   "js/vendor.js": /^(?!js)/
      // }
      //
      // To change the order of concatenation of files, explicitly mention here
      // order: {
      //   before: [
      //     "vendor/js/jquery-2.1.1.js",
      //     "vendor/js/bootstrap.min.js"
      //   ]
      // }
    },
    stylesheets: {
      joinTo: "css/app.css"
    },
    templates: {
      joinTo: "js/app.js"
    }
  },

  conventions: {
    // This option sets where we should place non-css and non-js assets in.
    // By default, we set this to "/assets/static". Files in this directory
    // will be copied to `paths.public`, which is "priv/static" by default.
    assets: /^(static)/
  },

  // Phoenix paths configuration
  paths: {
    // Dependencies and current project directories to watch
    watched: ["static", "css", "js", "vendor"
//-------------追加1
              ,"../lib/phoenixv13_elm_test_web/elm"],
//-------------
    // Where to compile files to
    public: "../priv/static"
  },

  // Configure your plugins
  plugins: {
//-------------追加2
    elmBrunch: {
      elmFolder: "../lib/phoenixv13_elm_test_web/elm",
      mainModules: ["Main.elm"],
      outputFolder: "vendor"
    },
//-------------
    babel: {
      // Do not use ES6 compiler in vendor code
      ignore: [/vendor/]
    }
  },

  modules: {
    autoRequire: {
      "js/app.js": ["js/app"]
    }
  },

  npm: {
    enabled: true
  }
};

 追加2でコンパイルすべきElmプログラムのソースファイルの場所を指定し、コンパイル結果のファイルの出力先を指定しています。出力先にvendorを指定しています。今、
elmFolder = "lib/phoenixv13_elm_test_web/elm"
なので
outputFolder = lib/phoenixv13_elm_test_web/elm/vendor
となります。

 一般的にvendorフォルダはBrunchによってこれ以上変換(コンパイル)されないファイルの置き場所で、そこのファイルは全て、appプログラムの前にロードされ、連結されるという決まりのようです。以下で見るようにassets/js/app.jsからlib/phoenixv13_elm_test_web/elm/vendor/main.js(Elmのコンパイル結果)を参照していますが、特に明示的にimport等の指定をしていないにもかかわらず、NotFoundエラーも出さずに、Elm.Mainモジュールを利用できています。
http://brunch.io/docs/config
http://brunch.io/docs/concepts

 さて次にElmのパッケージをインストールします。

cd lib/phoenixv13_elm_test_web/elm

elm-package install elm-lang/html
elm-package install elm-lang/http
elm-package install elm-lang/navigation
elm-package install evancz/url-parser
elm-package install debois/elm-mdl

 次にweb/elm/Main.elmを作成します。Main.elmは「Elmでマテリアルデザインをためしてみた! - Qiita」のものとほぼ同じものですが、Rest APIの返してくるJSONが少し異なるので、その分Decoderの指定も変えてあります。それ以外は全く同じで、非同期のHTTP通信でデータを取得し、マテリアルデザインでページを描画するコードになっています。

lib/phoenixv13_elm_test_web/elm/Main.elm
import Html exposing (..)
import Html.Attributes exposing (href,style)
import Html.Events exposing (onClick)
import Http
import Navigation
import UrlParser as Url exposing ((</>), (<?>), s, int, stringParam, top)
import Json.Decode as Decode
import Task exposing (Task)

-- ***MDL-1
import Material
import Material.Scheme
import Material.Button as Button
import Material.Options as Options exposing (css)
import Material.Card as Card 
import Material.Color as Color
import Material.Elevation as Elevation
import Material.List as Lists

main =
  Navigation.program MsgUrlChange
    { init = init
    , view = view
    , update = update
    , subscriptions = subscriptions
    }


init : Navigation.Location -> ( Model, Cmd Msg )
init location =  -- ***MDL-2: Boilerplate: Always use this initial Mdl model store.
  ( Model Nothing (Url.parsePath route location) [] [] Material.model
  , Cmd.none
  )


-- MODEL
type Tag
    = Tag String

type alias Article =
    { story : String
    , title : String
    }

type alias Model =
  { loading : Maybe Route
  , page : Maybe Route
  , tags : List Tag
  , articles : List Article
  , mdl : Material.Model -- ***MDL-3 : Boilerplate: model store for any and all Mdl components you use.
  }



-- URL PARSING
type Route
  = RouteHome
  | RouteTags
  | RouteArticles
  | RouteArticlePost Int
  | RouteTagsArticles
  | RouteMain



route : Url.Parser (Route -> a) a
route =
  Url.oneOf
    [ Url.map RouteHome top
    , Url.map RouteTags (Url.s "tags")
    , Url.map RouteArticles (Url.s "articles")
    , Url.map RouteArticlePost (Url.s "articles" </> int)
    , Url.map RouteTagsArticles (Url.s "tags-articles")
    , Url.map RouteMain (Url.s "Main.elm")
    ]


-- UPDATE
type Msg
  = MsgNewUrl String
  | MsgUrlChange Navigation.Location
  | MsgNewTags (Result Http.Error (List Tag))
  | MsgNewArticles (Result Http.Error (List Article))
  | MsgNewTagsArticles (Result Http.Error (List Tag, List Article) )
  | MsgNewArticlePost (Result Http.Error Article)
  | Mdl (Material.Msg Msg) -- ***MDL-4 : Boilerplate: Msg clause for internal Mdl messages.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    MsgNewUrl url ->
      ( model
       ,Navigation.newUrl url
      )

    MsgUrlChange location ->
      let
        _ = Debug.log "location=" location
        newpage = Url.parsePath route location
      in
        case newpage of
            Nothing ->
                ( {model | page=newpage}, Cmd.none)
            Just RouteHome ->
                ( {model | page=newpage}, Cmd.none)
            Just RouteTags ->
                ( { model | loading = newpage }, getTags )
            Just RouteArticles ->
                ( { model | loading = newpage }, getArticles )
            Just (RouteArticlePost n) ->
                ( { model | loading = newpage }, getArticlePost n )
            Just RouteTagsArticles ->
                ( { model | loading = newpage }, getTagsArticles )
            Just RouteMain ->
                ( {model | page=newpage}, Cmd.none)

    MsgNewTags (Ok newtags) ->
      ( { model | page=model.loading, tags=newtags, articles=[] }, Cmd.none)

    MsgNewTags (Err _) ->
      (model, Cmd.none)

    MsgNewArticles (Ok newarticles) ->
      ( { model | page=model.loading, tags=[], articles=newarticles } , Cmd.none)

    MsgNewArticles (Err _) ->
      (model, Cmd.none)

    MsgNewTagsArticles (Ok ( t, a ) ) ->
      ( { model | page=model.loading, tags=t, articles=a } , Cmd.none)

    MsgNewTagsArticles (Err _) ->
      (model, Cmd.none)

    MsgNewArticlePost (Ok newarticle) ->
      ( { model | page=model.loading, tags=[], articles=[newarticle] } , Cmd.none)

    MsgNewArticlePost (Err _) ->
      (model, Cmd.none)

    -- ***MDL-5 : Boilerplate: Mdl action handler.
    Mdl msg_ ->
        Material.update Mdl msg_ model


-- HTTP
url_tags =
    "http://www.mypress.jp:4000/api/tags"

url_articles =
    "http://www.mypress.jp:4000/api/articles"

requestTags : Http.Request (List Tag)
requestTags =
    -- Http.get url_tags ( Decode.field "animals" ( Decode.list ( Decode.map Tag Decode.string ) ) )
    Http.get url_tags ( Decode.field "data" ( Decode.list ( Decode.map Tag (Decode.field "name" Decode.string) ) ) )


requestArticles : Http.Request (List Article)
requestArticles =
    -- Http.get url_articles ( Decode.list article )
    Http.get url_articles ( Decode.field "data" ( Decode.list article ) )


article : Decode.Decoder Article
article =
    Decode.map2 toArticle (Decode.field "title" Decode.string) (Decode.field "story" Decode.string)

toArticle : String -> String -> Article
toArticle t s =
    { story=s, title=t }


getTags : Cmd Msg
getTags =
    Http.send MsgNewTags requestTags

getArticles : Cmd Msg
getArticles =
    Http.send MsgNewArticles requestArticles

getTagsArticles : Cmd Msg
getTagsArticles =
    Task.attempt MsgNewTagsArticles ( Task.map2 toPair ( Http.toTask (requestTags) )  ( Http.toTask (requestArticles) ) )

toPair : List Tag -> List Article -> (List Tag, List Article)
toPair t a =
    ( t, a )


getArticlePost : Int -> Cmd Msg
getArticlePost n =
    let
        url_post = url_articles ++ "/" ++ toString n
    in
    Http.send MsgNewArticlePost ( Http.get url_post ( Decode.field "data"  article ) )



-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
  Sub.none


type alias Mdl = -- ***MDL-6
    Material.Model


-- VIEW
view : Model -> Html Msg
view model =
  div []
    [ h1 [] [ text "Links" ]
    , ul [] (List.map (viewLink model) [ "/", "/tags/", "/articles/","/tags-articles/","/articles/1/", "/articles/2/", "/articles/3/" ])
    , div [] [ viewRoute model ]
    ]
    |> Material.Scheme.top -- ***MDL-7

viewLink : Model -> String -> Html Msg   -- ***MDL-8
viewLink model url =
  -- li [] [ button [ onClick (MsgNewUrl url) ] [ text url ] ]
  li [ style [ ("display", "inline-block") ] ] [ 
          Button.render Mdl
            [ 0 ]
            model.mdl
            [ Button.raised
            , Options.onClick  (MsgNewUrl url)
            , css "margin" "0 6px"
            ]
            [ text  url ]
        ]


viewRoute : Model -> Html msg
viewRoute model =
  let
    _ = Debug.log "maybeRoute=" model.page
  in
  case model.page of
    Nothing ->
      h2 [] [ text "404 Page Not Found!"]

    Just route ->
      viewPage route model


viewPage : Route -> Model -> Html msg
viewPage route model =
  case route of
    RouteHome ->
      div []
        [ h2 [] [text "Welcomw to My Page!"]
        , p [] [ text "これはテストページのトップです" ]
        ]

    RouteTags ->
      div []
        [ h2 [] [text "タグ一覧"]
        , p [] [ text "これはタグの一覧ページです" ]
        , Lists.ul [] (List.map viewTags model.tags)
        ]

    RouteArticles ->
      div []
        [ h2 [] [text "ブログ一覧"]
        , p [] [ text "これはブログの一覧ページです" ]
        , ul [] (List.map viewArticles model.articles)
        ]

    RouteTagsArticles ->
      div []
        [ h2 [] [text "タグ&ブログ一覧"]
        , p [] [ text "これはタグ&ブログの一覧ページです" ]
        , Lists.ul [] (List.map viewTags model.tags)
        , ul [] (List.map viewArticles model.articles)
        ]


    RouteArticlePost id ->
      div []
        [ h2 [] [text "ブログ記事表示"]
        , p [] [ text ("これはブログの記事("++ toString id ++")を表示します") ]
        , ul [] (List.map viewArticles model.articles)
        ]

    RouteMain ->
      div []
        [ h2 [] [text "初期画面"]
         ,p [] [ text "これはプログラムがロードされた初期画面です。" ]
        ]


viewTags (Tag t) =
    Lists.li []
      [ Lists.content []
          [ Lists.icon "inbox" []
          , text t
          ]
      ]


white : Options.Property c m 
white = 
  Color.text Color.white 

viewArticles a =
  div [ style [("padding","10px"), ("margin","10px")] ]
    [
      Options.div
        [ Elevation.e6
        , css "width"  "800px" 
        , Options.center
        ]
        [
          Card.view
            [ css "width" "800px"
            , Color.background (Color.color Color.Pink Color.S500)
            ]
            [ Card.title [] [ Card.head [ white ] [ text a.title ] ]
            , Card.text [ white ] [ text <| a.story ]
            ]
        ]
    ] 

 それではPhoenixのトップページのHTMLを修正します。index.html.eexを開いて、中身をすべて削除し、以下の一行に置き換えます。これでElmプログラムが動作する場所を確保しました。

lib/phoenixv13_elm_test_web/templates/page/index.html.eex
<div id="elm-container"></div>

 最後にassets/js/app.jsを編集して、末尾に以下の2行を追加します。Elm.Mainは lib/phoenixv13_elm_test_web/elm/vendor/main.jsの中のオブジェクトですが、前述の通り、特にmain.jsを明示的にimportする必要はないようです。

assets/js/app.js
const elmDiv = document.querySelector("#elm-container")
const elmApp = Elm.Main.embed(elmDiv)

 それではphoenixを起動してみましょう。次章に示すような画面を目にします。めでたしです。

mix phx.server

3.画面キャプチャ

■トップページです
http://www.mypress.jp:4000/

image.png

■/TAGS/ボタンをクリック
http://www.mypress.jp:4000/tags/

image.png

■/ARTICLES/ボタンをクリック
http://www.mypress.jp:4000/articles/

image.png

■/TAGS-ARTICLES/ボタンをクリック
http://www.mypress.jp:4000/tags-articles/

image.png

■/ARTICLES/2/ボタンをクリック
http://www.mypress.jp:4000/articles/3/
image.png

以上で終わりです。