Elm と RapidAPI を使って冬休みの航空券検索アプリを作ってみた

この記事は [VALU Advent Calendar 2019](https://qiita.com/advent-calendar/2019/valu) の 14 日目の記事です。

こんにちは。VALU で Web エンジニアをしている濱崎です。
普段は React でフロントエンドを書いたり、ときどき バックエンドの API をいじったりしています。
近ごろ Elm での関数型なフロントエンド開発に興味をもったので、この機会にちょっと触ってみました。

# TL; DR
- [Elm](https://guide.elm-lang.jp/) を使って、かんたんな航空券検索アプリを作ってみました
- API には、[RapidAPI](https://rapidapi.com/) の [Skyscanner Flight Search API](https://rapidapi.com/skyscanner/api/skyscanner-flight-search) を使用
- VALU の冬休み (12月28日〜1月5日) に、東京 ⇔ ニューヨークを往復する航空券を検索
  - ニューヨークに行きたい

# Elm とは
React や Vue.js のことは知っていても、Elm のことは知らない方も多いのではないでしょうか。
Elm は JavaScript にコンパイル可能な関数型プログラミング言語で、主に Web フロントエンドの開発に用いられます。 TypeScript にも似た堅牢な型システムをもつと同時に、Haskell にも通じる関数型言語の性質も備えているのが特徴です。
Elm による [SPA の実装例](https://github.com/rtfeldman/elm-spa-example) も公開されており、開発実務での採用事例もあるようです。

また、言語とともに [The Elm Architecture](https://guide.elm-lang.jp/architecture/) という Web アプリ向けのアーキテクチャも提唱されています。Redux もここから影響を受けたといわれており、実際に [Redux の公式サイトでも Elm が紹介](https://redux.js.org/introduction/prior-art#elm) されていたりします。
[公式ドキュメント](https://guide.elm-lang.jp/) の冒頭でも React に触れられており、個人的にはコードの見た目の印象も React x Redux に近い気がします。


# Rapid API とは
今回作ったアプリでは、航空券情報の検索用 API として [RapidAPI](https://rapidapi.com/) の [Skyscanner Flight Search API](https://rapidapi.com/skyscanner/api/skyscanner-flight-search) を採用しました。
RapidAPI は API のマーケットプレイス（API のまとめサイトのようなもの）で、無料・有料のさまざまな API が公開されています。
近年は楽天と提携し、 [Rakuten RapidAPI](https://api.rakuten.co.jp/) として日本語での事業展開も進んでいるようです。
旅行関連でも多くの API が公開されていますが、今回は無料で利用できる Skyscanner の API を選択しました。


# create-elm-app で雛形を作る
React でいう create-react-app と同様、 Elm でも [create-elm-app](https://github.com/halfzebra/create-elm-app) を使って簡単にアプリの雛形を作ることができます。 
README に従ってコマンドを流すだけで `localhost:3000` に↓こんな画面を表示することができました。

<img width="648" alt="Your Elm App is working!" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213209/26537b27-5d97-fa28-d69a-a346fd19e0e6.png">

このとき自動生成された Elm のコードは下記のような感じです。
Model, Update, View が Elm Architecture に沿って分離されているのがわかりますね。

```Elm:src/Main.elm
module Main exposing (..)

import Browser
import Html exposing (Html, text, div, h1, img)
import Html.Attributes exposing (src)


---- MODEL ----


type alias Model =
    {}


init : ( Model, Cmd Msg )
init =
    ( {}, Cmd.none )



---- UPDATE ----


type Msg
    = NoOp


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    ( model, Cmd.none )



---- VIEW ----


view : Model -> Html Msg
view model =
    div []
        [ img [ src "/logo.svg" ] []
        , h1 [] [ text "Your Elm App is working!" ]
        ]



---- PROGRAM ----


main : Program () Model Msg
main =
    Browser.element
        { view = view
        , init = \_ -> init
        , update = update
        , subscriptions = always Sub.none
        }

```

# JSON のサンプルコードをマージする
ここからいきなり RapidAPI に接続するのは大変そうだったので、まずは[公式ガイドの JSON サンプル](https://guide.elm-lang.jp/effects/json.html) として紹介されている [猫GIF](https://elm-lang.org/examples/cat-gifs) をアプリ内に移植することにしました。
`elm install` コマンドで `elm/http` や `elm/json` といった通信系の公式ライブラリを足していくんですが、このあたりの雰囲気はちょっと Go 言語にも近い感じがしました。
（ `elm-format` を使えばコマンドでコード自動整形できるあたりも Go っぽい）

MODEL や UPDATE の処理は、だんだん React の Action や reducer っぽい雰囲気が出てきます。VIEW もどことなく JSX っぽい見た目です。

```Elm:src/Main.elm
module Main exposing (..)

import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Http
import Json.Decode exposing (Decoder, field, string)



---- MODEL ----


type Model
    = Failure
    | Loading
    | Success String


init : ( Model, Cmd Msg )
init =
    ( Loading, getRandomCatGif )



---- UPDATE ----


type Msg
    = MorePlease
    | GotGif (Result Http.Error String)


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        MorePlease ->
            ( Loading, getRandomCatGif )

        GotGif result ->
            case result of
                Ok url ->
                    ( Success url, Cmd.none )

                Err _ ->
                    ( Failure, Cmd.none )

-- (略)

---- VIEW ----


view : Model -> Html Msg
view model =
    div []
        [ img [ src "/logo.svg" ] []
        , h1 [] [ text "旅行アプリを作りたい" ]
        , viewGif model
        ]


viewGif : Model -> Html Msg
viewGif model =
    case model of
        Failure ->
            div []
                [ text "猫の取得に失敗しました。"
                , button [ class "catButton", onClick MorePlease ] [ text "Try Again!" ]
                ]

        Loading ->
            div [ class "loading" ]
                [ text "Loading..." ]

        Success url ->
            div [ class "catWrapper" ]
                [ button [ onClick MorePlease, class "catButton" ] [ text "まず猫より始めよ" ]
                , img [ src url ] []
                ]
```

猫 GIF の API 処理はこちら。まだだいぶシンプルです。

```Elm:src/Main.elm
-- HTTP


getRandomCatGif : Cmd Msg
getRandomCatGif =
    Http.get
        { url = "https://api.giphy.com/v1/gifs/random?api_key=dc6zaTOxFJmzC&tag=cat"
        , expect = Http.expectJson GotGif gifDecoder
        }


gifDecoder : Decoder String
gifDecoder =
    field "data" (field "image_url" string)
```

コンパイルした結果がこちら↓

<img width="450" alt="まず猫より始めよ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213209/1dc4b3ec-3918-0075-5f86-2327365d1562.png">


# Skyscanner の API に繋ぐ
次はいよいよ、サンプルの猫を Skyscanner の航空券 API に差し替えていきます！
実はこの工程がなかなか大変でした。理由はいろいろあるのですが、大きかったのは

- Skyscanner API では 検索クエリ送信 API と検索結果受信 API が別々になっていた
  - セッション情報を受け渡しつつ 2 本の API を連携させる必要があった
- セッション情報が Response Body ではなく Response Header 側に書かれていた
  - 通常の `Http.expectJson` ではだめで、 [Stack Overflow の記事](https://stackoverflow.com/questions/56403905/how-to-get-response-headers-upon-http-request) を参考に自作の必要があった
- 検索結果の JSON が複雑で、階層をキレイにしつつ Msg の型に合わせて結果を整形するのが大変だった
  - 「[ElmでJSON APIを叩く最低限](https://qiita.com/dyoshikawa/items/a9ee32b84211f381f2c2)」の記事が参考になりました

といったあたりです。
API まわりの実装 (MODEL, UPDATE, HTTP) はこんなふうになりました。↓

```Elm:src/Main.elm
---- MODEL ----


type Session
    = FailureSession
    | LoadingSession
    | SuccessSession String


type Fetch
    = FailureFetch
    | LoadingFetch
    | WaitingFetch
    | SuccessFetch


type alias Itinerary =
    { price : Float
    , deeplinkUrl : String
    }


type alias Model =
    { session : Session
    , fetch : Fetch
    , itineraries : List Itinerary
    }


init : ( Model, Cmd Msg )
init =
    ( { session = LoadingSession
      , fetch = WaitingFetch
      , itineraries = []
      }
    , getFlightSearchSession
    )



---- UPDATE ----


type Msg
    = GotSkyscannerSession (Result Http.Error String)
    | GotSkyscannerFetch (Result Http.Error (List Itinerary))


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotSkyscannerSession result ->
            case result of
                Ok url ->
                    ( { model | session = SuccessSession url }
                    , getFlightSearchFetch url
                    )

                Err _ ->
                    ( { model | session = FailureSession }
                    , Cmd.none
                    )

        GotSkyscannerFetch result ->
            case result of
                Ok itineraries ->
                    ( { model | fetch = SuccessFetch, itineraries = itineraries }
                    , Cmd.none
                    )

                Err _ ->
                    ( { model | fetch = FailureFetch }
                    , Cmd.none
                    )


-- (VIEW は飛ばして) HTTP


getFlightSearchSession : Cmd Msg
getFlightSearchSession =
    Http.request
        { method = "POST"
        , headers =
            [ Http.header "x-rapidapi-host" "skyscanner-skyscanner-flight-search-v1.p.rapidapi.com"
            , Http.header "x-rapidapi-key" "MY SUPER SECRET KEY"
            ]
        , url = "https://skyscanner-skyscanner-flight-search-v1.p.rapidapi.com/apiservices/pricing/v1.0"
        , body =
            Http.stringBody "application/x-www-form-urlencoded"
                -- 既存のライブラリで form-urlencoded の body をうまく作れなかったので、
                -- クエリストリングス形式で生成してから冒頭の `?` をカット
                (String.dropLeft 1
                    (Url.Builder.toQuery
                        -- 今回パラメータは決め打ち。フォームで入力できるとよさそう
                        [ Url.Builder.string "inboundDate" "2020-01-05"
                        , Url.Builder.string "children" "0"
                        , Url.Builder.string "infants" "0"
                        , Url.Builder.string "country" "JP"
                        , Url.Builder.string "currency" "JPY"
                        , Url.Builder.string "locale" "ja-JP"
                        , Url.Builder.string "originPlace" "TYOA-sky"
                        , Url.Builder.string "destinationPlace" "NYCA-sky" -- ニューヨークにいきたい
                        , Url.Builder.string "outboundDate" "2019-12-28"
                        , Url.Builder.string "adults" "1"
                        ]
                    )
                )
        -- ↓ Response Header の値を取得するため、 expectResponseHeader を自作
        , expect = expectResponseHeader GotSkyscannerSession sessionDecoder
        , timeout = Nothing
        , tracker = Nothing
        }


expectResponseHeader : (Result Http.Error String -> msg) -> Decoder a -> Http.Expect msg
expectResponseHeader toMsg decoder =
    Http.expectStringResponse toMsg <|
        \response ->
            case response of
                Http.BadUrl_ url ->
                    Err (Http.BadUrl url)

                Http.Timeout_ ->
                    Err Http.Timeout

                Http.NetworkError_ ->
                    Err Http.NetworkError

                Http.BadStatus_ metadata body ->
                    Err (Http.BadStatus metadata.statusCode)

                Http.GoodStatus_ metadata body ->
                    metadata.headers
                        |> Dict.get "location" -- ここで Response Header の location を取得
                        |> Result.fromMaybe (Http.BadStatus 403)


sessionDecoder : Decoder String
sessionDecoder =
    field "location" Json.Decode.string


getFlightSearchFetch : String -> Cmd Msg
getFlightSearchFetch url =
    Http.request
        { method = "GET"
        , headers =
            [ Http.header "x-rapidapi-host" "skyscanner-skyscanner-flight-search-v1.p.rapidapi.com"
            , Http.header "x-rapidapi-key" "MY SUPER SECRET KEY"
            ]
        , url =
            Url.Builder.crossOrigin
                "https://skyscanner-skyscanner-flight-search-v1.p.rapidapi.com/apiservices/pricing/uk2/v1.0"
                -- ↓ Response Header で取得した url から末尾のセッションキーを取り出し
                [ Maybe.withDefault "" (String.split "/" url |> List.reverse |> List.head) ]
                [ Url.Builder.int "pageIndex" 0, Url.Builder.int "pageSize" 20 ]
        , body = Http.emptyBody
        , expect = Http.expectJson GotSkyscannerFetch itinerariesDecoder
        , timeout = Nothing
        , tracker = Nothing
        }


itinerariesDecoder : Decoder (List Itinerary)
itinerariesDecoder =
    field "Itineraries" (Json.Decode.list itineraryDecoder)


itineraryDecoder : Decoder Itinerary
itineraryDecoder =
    Json.Decode.map2 Itinerary
        -- ↓ PricingOptions 直下の単要素配列を List として扱うと大変なので、 key = "0" の Dict として扱う
        (at [ "PricingOptions", "0", "Price" ] Json.Decode.float)
        (at [ "PricingOptions", "0", "DeeplinkUrl" ] Json.Decode.string)
```
長いですね。VIEW はこんな感じです。↓

```Elm:src/Main.elm
---- VIEW ----


view : Model -> Html Msg
view model =
    div []
        [ img [ src "/logo.svg" ] []
        , h1 [] [ text "旅行アプリを作りたい" ]
        , viewSession model.session
        , viewFetch model.fetch
        , ul [ class "itineraryWrapper" ] (List.map viewItinerary model.itineraries)
        ]


viewSession : Session -> Html Msg
viewSession session =
    case session of
        FailureSession ->
            div []
                [ text "セッションの取得に失敗しました。" ]

        LoadingSession ->
            div [ class "label" ]
                [ text "セッションを取得中..." ]

        SuccessSession url ->
            div [] []


viewFetch session =
    case session of
        FailureFetch ->
            div [ class "label" ]
                [ text "結果の取得に失敗しました。" ]

        LoadingFetch ->
            div [ class "label" ]
                [ text "結果を取得中..." ]

        WaitingFetch ->
            div [ class "label" ]
                [ text "セッション取得後に結果取得が開始されます。" ]

        SuccessFetch ->
            div [ class "label" ]
                [ text "冬休みの東京 ⇔ ニューヨーク往復航空券のお値段" ]


viewItinerary : Itinerary -> Html Msg
viewItinerary itinerary =
    li [ class "itineraryLink" ]
        [ a
            [ href itinerary.deeplinkUrl
            , target "_blank"
            , rel "noopener noreferrer"
            ]
            [ text <| String.fromFloat itinerary.price ++ " JPY" ]
        ]
```

# 完成形
冬休みの航空券のお値段がわかるようになりました :clap: 

<img width="350" alt="完成形" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213209/a056b1a3-dd30-84cf-d503-166841481dd5.png">

リンクは Skyscanner のページに飛び、そこから航空券を予約することができます。
[GitHub リポジトリはこちらから](https://github.com/lfcd85/elm-flight-search-app)。

# 感想
「関数型言語 x React 風アーキテクチャ」という印象を持っていた Elm ですが、書いてみると意外と Go 言語の書き味にも近い感じがしました。標準ライブラリなどのエコシステムが似ているのも一因かもしれません。

REST API との連携については、エンティティ（型）の構造が元の API とデコード後の Elm コードで違う場合はけっこう整形が大変かもしれない、、！　という印象でした。
JavaScript のオブジェクトをゴリゴリ変形させること、そのときに言語仕様や型の柔らかさを利用することにこれまで慣れすぎていた面もあるかもしれません。
どちらかというと、GraphQL のようにクエリを柔軟に整形して必要なデータ構造を生成できる API のほうが Elm とは相性がよいのではないかと思いました。

今回はほんとうに簡単なアプリを作っただけだったので、今後はフォームで検索機能を拡充してみたり、Elm で SPA などにも挑戦できたらよいなと思っています！