ElmでGoogle Spread Sheetの値を読み込んだメモ

## できたもの

以下のページの下部「変異器官」をクリック。スプレッドシートから取得した値を表示する
[ルールブック](https://garden-2a6de.firebaseapp.com/rulebook/#character)

グーグルドキュメントは以下。
https://docs.google.com/spreadsheets/d/1cyGpEw4GPI2k5snngBPKz7rfETklKdSaIBqQKnTta1w/view#gid=0

## やったことのメモ

[この時点のソース](https://github.com/hibohiboo/garden/tree/c2c05eb2f897c7c7c858a7339446dd518ac58c67)  


### apiキーの環境変数化

jsに含めるのでどうせ見えることにはなるが、ソース管理から外したかった。

#### docker-composeの設定
env_fileで.envファイルを読み込むように修正

```diff
  garden:
    build: ./garden-webpack
+   env_file: .env
    environment:
      - NODE_ENV=develop
      - SHELL=/bin/bash
    command: [yarn, webpack-dev-server, --hot, --colors, --port, '3000', --host, '0.0.0.0', ]
```

.envファイルにgoogleapiキーを保存

#### webpackの設定

webpack.DefinePluginを使用して環境変数を読み込めるようにする。

```diff
if (MODE === 'development') {
  console.log('Building for dev...');
  module.exports = merge(common, {
    plugins: [
      // Suggested for hot-loading
      new webpack.NamedModulesPlugin()
      // Prevents compilation errors causing the hot loader to lose state
      , new webpack.NoEmitOnErrorsPlugin()
+      , new webpack.DefinePlugin({
+        GOOGLE_SHEET_API_KEY: JSON.stringify(process.env.GOOGLE_SHEET_API_KEY)
+      })
    ],
    module: {
```

#### typescriptの設定

typescritptでグローバル変数を読み込むために以下のファイルを作成する。[*][*6]

```ts:contents.ts
// グローバル変数。webpack.DefinePluginで定義。環境変数から読み出し
declare var GOOGLE_SHEET_API_KEY: string;
const _GOOGLE_SHEET_API_KEY = GOOGLE_SHEET_API_KEY;
export { _GOOGLE_SHEET_API_KEY as GOOGLE_SHEET_API_KEY };
```

flagsにJSONを詰めてELMに渡す

```ts:index.ts
import { Elm } from './Main'; //  eslint-disable-line import/no-unresolved
import { GOOGLE_SHEET_API_KEY } from "./constants";

const flags = JSON.stringify({
  googleSheetApiKey: GOOGLE_SHEET_API_KEY
});
const mountNode: HTMLElement = document.getElementById('main')!;
const app = Elm.Main.init({ node: mountNode, flags });
```

### elmの設定

flagsに詰められて渡された値を取り出す

```elm:Main.elm
type alias Model =
    { key : Nav.Key
    , page : Page
    , googleSheetApiKey : String
    }

init : String -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
init flags url key =
    let
        apiKey =
            case D.decodeString (D.field "googleSheetApiKey" D.string) flags of
                Ok decodedKey ->
                    decodedKey

                Err _ ->
                    ""
    in
    Model key (TopPage Page.Top.initModel) apiKey
        |> goTo (Route.parse url)
        |> initialized

initialized : ( Model, Cmd Msg ) -> ( Model, Cmd Msg )
initialized ( model, msg ) =
    ( model, Cmd.batch [ msg, initializedToJs () ] )

goTo : Maybe Route -> Model -> ( Model, Cmd Msg )
goTo maybeRoute model =
    case maybeRoute of
        Just (Route.RuleBook id) ->
            let
                ( m, cmd ) =
                    RuleBook.init model.googleSheetApiKey id
            in
            ( { model | page = RuleBookPage m }
            , Cmd.map RuleBookMsg cmd
            )
```

GSAPI.getOrgansで呼び出している。

```elm:RuleBook.elm
port module Page.RuleBook exposing (Model, Msg(..), init, update, view)

import Browser.Dom as Dom
import Browser.Navigation as Navigation
import GoogleSpreadSheetApi as GSAPI exposing (Organ)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick)
import Http
import Page.Rules.Base exposing (..)
import Skeleton exposing (viewLink, viewMain)
import Task exposing (..)
import Url
import Url.Builder

port openModal : () -> Cmd msg

type alias Model =
    { naviState : NaviState
    , googleSheetApiKey : String
    , id : String
    , modalTitle : String
    , modalContents : Html Msg
    }

init : String -> Maybe String -> ( Model, Cmd Msg )
init apiKey s =
    case s of
        Just id ->
            ( Model Close apiKey id "" (text ""), jumpToBottom id )
        Nothing ->
            ( initModel apiKey
            , Cmd.none
            )

initModel : String -> Model
initModel apiKey =
    Model Close apiKey "" "異形器官一覧" (text "")


type Msg
    = ModalOrgan String
    | GotOrgans (Result Http.Error (List Organ))


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ModalOrgan title ->
            ( { model | modalTitle = title }, GSAPI.getOrgans GotOrgans model.googleSheetApiKey "1cyGpEw4GPI2k5snngBPKz7rfETklKdSaIBqQKnTta1w" "organList!A1:B11" )

        GotOrgans (Ok organs) ->
            let
                organTable =
                    organList organs
            in
            ( { model | modalContents = organTable }, openModal () )

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


view : Model -> Skeleton.Details Msg
view model =
    let
        naviClass =
            getNavigationPageClass
                model.naviState
    in
    { title = "基本ルール"
    , attrs = [ class naviClass ]
    , kids =
        [ viewMain viewRulebook
        , viewNavi (List.map (\( value, text ) -> NavigationMenu value text) tableOfContents)
        , openNavigationButton ToggleNavigation
        , closeNavigationButton ToggleNavigation
        , modalWindow model.modalTitle model.modalContents
        ]
    }


modalWindow : String -> Html msg -> Html msg
modalWindow title content =
    div [ id "mainModal", class "modal" ]
        [ div [ class "modal-content" ]
            [ h4 [] [ text title ]
            , p [] [ content ]
            ]
        , div [ class "modal-footer" ]
            [ a [ href "#!", class "modal-close waves-effect waves-green btn-flat" ] [ text "閉じる" ]
            ]
        ]

viewRulebook : Html Msg
viewRulebook =
    div []
        [ div [ class "rulebook-title" ] [ div [] [ text Terms.trpgGenre ], h1 [] [ text "Garden 基本ルールブック" ] ]
        , div [ class "content" ]
            [ first
            , world
            , character
            , a [ onClick (ModalOrgan "変異器官一覧"), class "waves-effect waves-light btn", href "#" ] [ text "変異器官一覧" ]
            ]
        ]


organList : List Organ -> Html Msg
organList organs =
    table []
        [ thead []
            [ tr []
                [ th [] [ text "器官" ]
                , th [] [ text "説明" ]
                ]
            ]
        , tbody []
            (List.map (\organ -> tr [] [ td [] [ text organ.name ], td [] [ text organ.description ] ]) organs)
        ]
```

```elm:GoogleSpreadSheetApi.elm
module GoogleSpreadSheetApi exposing (Organ, getOrgans, organDecodeFromString, organDecoder, organsDecodeFromString, organsDecoder, organsInObjectDecodeFromString, organsInObjectDecoder, sheetUrl)

import Http
import Json.Decode as D exposing (..)
import Url
import Url.Builder


type alias Organ =
    { name : String
    , description : String
    }


getOrgans : (Result Http.Error (List Organ) -> msg) -> String -> String -> String -> Cmd msg
getOrgans toMsg apiKey documentId range =
    Http.get
        { url = sheetUrl apiKey documentId range
        , expect = Http.expectJson toMsg organsInObjectDecoder
        }


sheetUrl : String -> String -> String -> String
sheetUrl apiKey documentId range =
    Url.Builder.crossOrigin "https://sheets.googleapis.com" [ "v4", "spreadsheets", documentId, "values", range ] [ Url.Builder.string "key" apiKey ]


organsInObjectDecodeFromString : String -> Result Error (List Organ)
organsInObjectDecodeFromString s =
    decodeString organsInObjectDecoder s


organsInObjectDecoder : Decoder (List Organ)
organsInObjectDecoder =
    field "values" organsDecoder


organsDecodeFromString : String -> Result Error (List Organ)
organsDecodeFromString s =
    decodeString organsDecoder s


organsDecoder : Decoder (List Organ)
organsDecoder =
    D.list organDecoder


organDecodeFromString : String -> Result Error Organ
organDecodeFromString s =
    decodeString organDecoder s


organDecoder : Decoder Organ
organDecoder =
    D.map2 Organ
        (index 0 string)
        (index 1 string)
```

`module Tests exposing (sheet, suite, unitTest)`で、exposingしないとテストしてもらえないのにしばらく気づかずにハマった。

```elm:Tests.elm
module Tests exposing (sheet, suite, unitTest)

import Expect
import GoogleSpreadSheetApi as GSApi
import Route exposing (..)
import Test exposing (..)
import Url



----


unitTest : Test
unitTest =
    describe "simple unit test"
        [ test "Inc adds one" <|
            \() ->
                1
                    |> Expect.equal 1
        ]


{-| テストケースを簡単に書くためのヘルパー関数
-}
testParse : String -> String -> Maybe Route -> Test
testParse name path expectedRoute =
    test name <|
        \_ ->
            Url.fromString ("http://example.com" ++ path)
                |> Maybe.andThen Route.parse
                |> Expect.equal expectedRoute


suite : Test
suite =
    describe "Route"
        [ testParse "shold parse Top" "/" (Just Route.Top)
        , testParse "shold parse Top with quesies" "/?dummy=value" (Just Route.Top)
        , testParse "shold parse Top with hash" "/#dumy" (Just Route.Top)
        , testParse "shold parse RuleBook" "/rulebook" (Just (Route.RuleBook Nothing))
        , testParse "shold parse RuleBook with hash" "/rulebook#first" (Just (Route.RuleBook (Just "first")))
        , testParse "shold parse PrivacyPolicy" "/privacy-policy" (Just Route.PrivacyPolicy)
        , testParse "shold parse Agreement" "/agreement" (Just Route.Agreement)
        , testParse "shold parse LoginUser" "/mypage" (Just Route.LoginUser)
        , testParse "shold parse CreateCharacter" "/mypage/character/create/aaa" (Just (Route.CharacterCreate "aaa"))
        , testParse "shold parse UpdateCharacter" "/mypage/character/edit/aaa/bbb" (Just (Route.CharacterUpdate "aaa" "bbb"))
        , testParse "shold parse Inavalid path" "/foo/bar/baz" Nothing
        ]


sheet : Test
sheet =
    describe "GoogleSpreadSheet"
        [ test "配列をレコードに入れるテスト" <|
            \_ ->
                let
                    actual =
                        case GSApi.organDecodeFromString "[\"a\", \"b\"]" of
                            Ok a ->
                                a

                            Err _ ->
                                GSApi.Organ "" ""

                    expect =
                        { name = "a", description = "b" }
                in
                Expect.equal actual expect
        , test "配列の配列を処理するテスト" <|
            \_ ->
                let
                    actual =
                        case GSApi.organsDecodeFromString "[[\"a\", \"b\"],[\"c\", \"d\"]]" of
                            Ok a ->
                                a

                            Err _ ->
                                [ GSApi.Organ "a" "b" ]

                    expect =
                        [ GSApi.Organ "a" "b", GSApi.Organ "c" "d" ]
                in
                Expect.equal actual expect
        , test "オブジェクトの中の配列を処理するテスト" <|
            \_ ->
                let
                    actual =
                        case GSApi.organsInObjectDecodeFromString "{\"values\":[[\"a\", \"b\"],[\"c\", \"d\"]]}" of
                            Ok a ->
                                a

                            Err _ ->
                                []

                    expect =
                        [ GSApi.Organ "a" "b", GSApi.Organ "c" "d" ]
                in
                Expect.equal actual expect
        ]

```
## 参考
[expect][*1]
[json-decode][*2]
[url builder][*3]
[google spread][*4]
[Webpackを使ってJSでも.envしたい][*5]
[Web Pack define plugin variables are recoginized by ts-loader][*6]

[*1]:https://package.elm-lang.org/packages/elm-community/elm-test/latest/Expect#equal
[*2]:https://package.elm-lang.org/packages/elm/json/latest/Json-Decode#decodeValue
[*3]:https://package.elm-lang.org/packages/elm/url/latest/Url-Builder
[*4]:https://developers.google.com/sheets/guides/concepts?hl=ja
[*5]:https://aloerina01.github.io/blog/2017-02-22-1
[*6]:https://github.com/TypeStrong/ts-loader/issues/37