ElixirDesktop クラサバ構成 クライアントアプリ構築

# はじめに
この記事は Elixirアドベントカレンダーのシリーズ４の14日目の記事です

PhoenixのAPIサーバーとElixirDesktopをクライアントとしたクラサバ構成の構築と認証について解説します
今回はクライアントアプリの作成と認証情報の保存について解説します

# プロジェクトの作成
最初にサーバーと同じBlogでアプリを作りたいので、前回のサーバーのフォルダ名を変更します

```
mv blog blog_server
```

次にクライアントアプリを作成します。DBは必要ないのですが、色々使いまわしたいので `--database sqlite3`を指定します

```
mix phx.new blog --database sqlite3
cd blog
```

# ElixirDesktopアプリ化

https://qiita.com/the_haigo/items/f89ff221f49f2ce4dfb4#phoenix-project%E3%81%AE%E4%BD%9C%E6%88%90

こちらに沿って行っていきます

以下３つのライブラリを追加します

```mix.exs
  defp deps do
    [
      ...      
      {:desktop, "~> 1.5"},
      {:wx, "~> 1.1", hex: :bridge, targets: [:android, :ios]},
      {:req, "~> 0.4.0"}
    ]
  end
```

```
mix deps.get
```

configのRepoとEndpointを書き換えて、runtime.exsをruntime_disable.exsにリネームします

```diff_elixir:config/config.exs
- config :blog,
-  ecto_repos: [Blog.Repo],
-  generators: [timestamp_type: :utc_datetime]


config :blog, BlogWeb.Endpoint,
- url: [host: "localhost"],
+ http: [ip: {127, 0, 0, 1}, port: 10_000 + :rand.uniform(45_000)],
  adapter: Phoenix.Endpoint.Cowboy2Adapter,
  render_errors: [
    formats: [html: BlogWeb.ErrorHTML, json: BlogWeb.ErrorJSON],
    layout: false
  ],
  pubsub_server: Blog.PubSub,
- live_view: [signing_salt: "ryE8f7GX"]
+ live_view: [signing_salt: "ryE8f7GX"],
+ secret_key_base: :crypto.strong_rand_bytes(32),
+ server: true

```
Repoの設定を消し、デスクトップアプリモードで起動して`blog_server`とポートがかぶらないように`4001`に変更します

```diff_elixir:config/dev.exs
import Config

# Configure your database
- config :blog, Blog.Repo,
-  database: Path.expand("../blog_dev.db", Path.dirname(__ENV__.file)),
-  pool_size: 5,
-  stacktrace: true,
-  show_sensitive_data_on_connection_error: true

config :blog, BlogWeb.Endpoint,
- http: [ip: {127, 0, 0, 1}, port: 4000],
+ http: [ip: {127, 0, 0, 1}, port: 4001],
...
```

endpoint修正

```diff_elixir:lib/blog_web/endpoint.ex
defmodule BlogWeb.Endpoint do
- use Phoenix.Endpoint, otp_app: :blog
+ use Desktop.Endpoint, otp_app: :blog

  @session_options [
-   store: :cookie,
+   store: :ets,
    key: "_blog_key",
+   table: :session,
-   signing_salt: "aTwtJaw/",
-   same_site: "Lax"
  ]

```

起動時の設定を行います
前回との変更点としてDBは使わないのでRepoは起動しません

```lib/blog.ex
defmodule Blog do
  use Application

  def config_dir() do
    Path.join([Desktop.OS.home(), ".config", "blog"])
  end

  @app Mix.Project.config()[:app]
  def start(:normal, []) do
    # configフォルダを掘る
    File.mkdir_p!(config_dir())

    # session用のETSを起動
    :session = :ets.new(:session, [:named_table, :public, read_concurrency: true])

    children = [
      {Phoenix.PubSub, name: Blog.PubSub},
      BlogWeb.Endpoint
    ]

    opts = [strategy: :one_for_one, name: Blog.Supervisor]
    # メインのsuperviser起動
    {:ok, sup} = Supervisor.start_link(children, opts)


    # phoenixサーバーが起動中のポート番号を取得
    port = :ranch.get_port(BlogWeb.Endpoint.HTTP)
    
    # メインのsuperviserの配下にElixirDesktopのsuperviserを追加
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: BlogWindow,
          title: "Blog",
          size: {400, 800},
          url: "http://localhost:#{port}"
        ]
      })
  end

  def config_change(changed, _new, removed) do
    BlogWeb.Endpoint.config_change(changed, removed)
    :ok
  end
end
```

アプリケーションの設定ファイルを差し替えます

```diff_elixir:mix.exs
def application do
    [
-      mod: {Blog.Application, []},
+      mod: {Blog, []},
      extra_applications: [:logger, :runtime_tools]
    ]
  end
```

# 認証機能のベースコード生成

ベースとなるコードを以下のコマンドで生成します

```
mix phx.gen.auth Accounts User users
```

# Bcrypt関連を消す

bcrypt_elixirですがiOS、Androidでこれが入っていると起動しないので消します

```diff_elixir:mix.exs
  defp deps do
    [
-     {:bcrypt_elixir, "~> 3.0"},

```
テストからも消す

```diff_elixir:config/text.exs
import Config

- # Only in tests, remove the complexity from the password hashing algorithm
- config :bcrypt_elixir, :log_rounds, 1

```

Bcrypt周りのコードをごっそり消します

```diff_elixir:lib/blog/accounts/user.ex
defmodule Blog.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  ... 

  defp validate_email(changeset, opts) do
    changeset
    |> validate_required([:email])
    |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
    |> validate_length(:email, max: 160)
-   |> maybe_validate_unique_email(opts)
  end

  defp validate_password(changeset, opts) do
    changeset
    |> validate_required([:password])
    |> validate_length(:password, min: 12, max: 72)
    # Examples of additional password validation:
    # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
    # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
    # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
-   |> maybe_hash_password(opts)
  end

- defp maybe_hash_password(changeset, opts) do
-   hash_password? = Keyword.get(opts, :hash_password, true)
-   password = get_change(changeset, :password)
-
-   if hash_password? && password && changeset.valid? do
-     changeset
-     # If using Bcrypt, then further validate it is at most 72 bytes long
-     |> validate_length(:password, max: 72, count: :bytes)
-     # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
-     # would keep the database transaction open longer and hurt performance.
-     |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
-     |> delete_change(:password)
-   else
-     changeset
-   end
- end

- defp maybe_validate_unique_email(changeset, opts) do
-   if Keyword.get(opts, :validate_email, true) do
-     changeset
-     |> unsafe_validate_unique(:email, Blog.Repo)
-     |> unique_constraint(:email)
-   else
-     changeset
-   end
- end

...

- @doc """
- Verifies the password.
-
- If there is no user or the user doesn't have a password, we call
- `Bcrypt.no_user_verify/0` to avoid timing attacks.
- """
- def valid_password?(%Blog.Accounts.User{hashed_password: hashed_password}, password)
-     when is_binary(hashed_password) and byte_size(password) > 0 do
-   Bcrypt.verify_pass(password, hashed_password)
- end
-
- def valid_password?(_, _) do
-   Bcrypt.no_user_verify()
-   false
- end
-
- @doc """
- Validates the current password otherwise adds an error to the changeset.
- """
- def validate_current_password(changeset, password) do
-   if valid_password?(changeset.data, password) do
-     changeset
-   else
-     add_error(changeset, :current_password, "is not valid")
-   end
- end
end

```

# 認証トークンの保持
クライアントアプリですが、ユーザーの作成、ログイン時にbase64エンコードしたトークンをAPIサーバーから取得します
その際に config_dirにテキストファイルとしてトークンを生成します
こちらを通信時のヘッダーに付与したりします

トークンファイルのパスを取得するヘルパーを実装します

```diff_elixir:lib/blog.ex
defmodule Blog do
  use Application

  def config_dir() do
    Path.join([Desktop.OS.home(), ".config", "blog"])
  end

+ def token_path() do
+   Path.join([config_dir(), "token"])
+ end
  ...
end
```

# API通信クライアントを実装
12日の記事でも使用したAPI通信ライブラリのReqを使用します

トークンがあった場合はauthorizationヘッダーにトーケン付与します、ない場合は通信は行えるが`401`が帰ってくるようにクライアントだけは返します

```lib/blog/req.ex
defmodule Blog.Req do
  def client() do
    case File.read(Blog.token_path()) do
      {:ok, token} ->
        Req.new(base_url: "http://localhost:4000/api")
        |> Req.Request.put_header("authorization", "Bearer #{token}")

      {:error, _} ->
        Req.new(base_url: "http://localhost:4000/api")
    end
  end
end
```

# API通信部分を実装
Ectoでクエリを実行している箇所をAPI通信を行う処理に書き換えます

ReqでAPI通信を行い、statusで可否を判断して処理を分けます
ログイン、登録に成功したらトークンファイルを書き出します

```diff_elixir:lib/blog/accounts.ex
defmodule Blog.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
+ alias Ecto.Changeset
  alias Blog.Repo

  alias Blog.Accounts.{User, UserToken, UserNotifier}

  def get_user_by_email_and_password(email, password)
      when is_binary(email) and is_binary(password) do
    attr = %{email: email, password: password}

    {:ok, resp} =
      Req.post(Blog.Req.client(), url: "/login", json: attr)

    case resp.status do
      200 ->
        token = resp.body["token"]
        File.write!(Blog.token_path(), token)
        {:ok, token}

      422 ->
        %User{}
        |> User.registration_changeset(attr)
        |> then(
          &Enum.reduce(resp.body["errors"], &1, fn {k, v}, acc ->
            Changeset.add_error(acc, String.to_atom(k), List.first(v))
          end)
        )
        |> then(&{:error, &1})

      _ ->
        {:error, User.registration_changeset(%User{}, attr)}
    end
  end

  @doc """
  Gets the user with the given signed token.
  """
  def get_user_by_session_token() do
    {:ok, resp} = Req.get(Blog.Req.client(), url: "/status")

    case resp.status do
      200 ->
        %User{id: resp.body["id"], email: resp.body["email"]}

      _ ->
        nil
    end
  end

  def register_user(attrs) do
    {:ok, resp} = Req.post(Blog.Req.client(), url: "/register", json: attrs)

    case resp.status do
      200 ->
        token = resp.body["token"]
        File.write!(Blog.token_path(), token)
        {:ok, %User{email: attrs["email"], password: attrs["password"]}}

      422 ->
        %User{}
        |> User.registration_changeset(attrs)
        |> then(
          &Enum.reduce(resp.body["errors"], &1, fn {k, v}, acc ->
            Changeset.add_error(acc, String.to_atom(k), List.first(v))
          end)
        )
        |> then(&{:error, &1})

      _ ->
        {:error, User.registration_changeset(%User{}, attrs)}
    end
  end
  ...
end
```

# 認証周りの調整
認証状態をチェックする関数は主にセッションの情報を使うので、トークンファイルの有無を使うようにします
またクッキー周りも使わないので削除します

```diff_elixir:lib/blog_web/user_auth.ex
defmodule BlogWeb.UserAuth do
  use BlogWeb, :verified_routes
  
  ...

- def log_in_user(conn, user, params \\ %{}) do
-   token = Accounts.generate_user_session_token(user)
+ def log_in_user(conn, _user, _params \\ %{}) do
+   {:ok, token} = File.read(Blog.token_path())   
    user_return_to = get_session(conn, :user_return_to)

    conn
    |> renew_session()
    |> put_token_in_session(token)
 -   |> maybe_write_remember_me_cookie(token, params)
    |> redirect(to: user_return_to || signed_in_path(conn))
  end

- defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
-   put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
- end
-
- defp maybe_write_remember_me_cookie(conn, _token, _params) do
-   conn
- end

...

  @doc """
  Logs the user out.

  It clears all session data for safety. See renew_session.
  """
  def log_out_user(conn) do
-   user_token = get_session(conn, :user_token)
-   user_token && Accounts.delete_user_session_token(user_token)
+   File.rm!(Blog.token_path())

    if live_socket_id = get_session(conn, :live_socket_id) do
      BlogWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
    end

    conn
    |> renew_session()
-   |> delete_resp_cookie(@remember_me_cookie)
    |> redirect(to: ~p"/")
  end

  @doc """
  Authenticates the user by looking into the session
  and remember me token.
  """
  def fetch_current_user(conn, _opts) do
    {user_token, conn} = ensure_user_token(conn)
-   user = user_token && Accounts.get_user_by_session_token(user_token)
+   user = user_token && Accounts.get_user_by_session_token()
    assign(conn, :current_user, user)
  end

  defp ensure_user_token(conn) do
-   if token = get_session(conn, :user_token) do
-     {token, conn}
-   else
-     conn = fetch_cookies(conn, signed: [@remember_me_cookie])
-
-     if token = conn.cookies[@remember_me_cookie] do
-       {token, put_token_in_session(conn, token)}
-     else
-       {nil, conn}
-     end
+   case File.read(Blog.token_path()) do
+     {:ok, token} -> {token, conn}
+     {:error, _} -> {nil, conn}
    end

  end

...

-  defp mount_current_user(socket, session) do
+  defp mount_current_user(socket, _session) do
    Phoenix.Component.assign_new(socket, :current_user, fn ->
-     if user_token = session["user_token"] do
-      Accounts.get_user_by_session_token(user_token)
-     end
+     Accounts.get_user_by_session_token()
    end)
  end

  ...
- defp signed_in_path(_conn), do: ~p"/"
+ defp signed_in_path(_conn), do: ~p"/users/settings" 
end
```

# ユーザー登録の修正

ユーザー登録のメール送信部分の削除と、ログイン失敗時のAPIのレスポンスからエラーメッセージの表示を行えるようにします

```diff_elixir:lib/blog_web/live/user_registration_live.ex
defmodule BlogWeb.UserRegistrationLive do
  use BlogWeb, :live_view

  alias Blog.Accounts
  alias Blog.Accounts.User


  def handle_event("save", %{"user" => user_params}, socket) do
    case Accounts.register_user(user_params) do
      {:ok, user} ->
-       {:ok, _} =
-         Accounts.deliver_user_confirmation_instructions(
-           user,
-           &url(~p"/users/confirm/#{&1}")
-         )
-
        changeset = Accounts.change_user_registration(user)
        {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}

      {:error, %Ecto.Changeset{} = changeset} ->
-       {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
+       {:noreply,
+          socket
+          |> assign(check_errors: true)
+          |> assign_form(Map.put(changeset, :action, :validate))}
    end
  end
  ...
end
```

ログイン、登録後に`redirect_if_user_is_authenticated`で落とされるので、ログインチェックを行わないscopeに移動


```diff_elixir:lib/blog_web/router.ex
  scope "/", BlogWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]

    live_session :redirect_if_user_is_authenticated,
      on_mount: [{BlogWeb.UserAuth, :redirect_if_user_is_authenticated}] do
      live "/users/register", UserRegistrationLive, :new
      live "/users/log_in", UserLoginLive, :new
      live "/users/reset_password", UserForgotPasswordLive, :new
      live "/users/reset_password/:token", UserResetPasswordLive, :edit
    end
-
-   post "/users/log_in", UserSessionController, :create    
  end

  scope "/", BlogWeb do
    pipe_through [:browser, :require_authenticated_user]

    live_session :require_authenticated_user,
      on_mount: [{BlogWeb.UserAuth, :ensure_authenticated}] do
      live "/users/settings", UserSettingsLive, :edit
      live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
    end
  end

  scope "/", BlogWeb do
    pipe_through [:browser]

    delete "/users/log_out", UserSessionController, :delete
+   post "/users/log_in", UserSessionController, :create

    live_session :current_user,
      on_mount: [{BlogWeb.UserAuth, :mount_current_user}] do
      live "/users/confirm/:token", UserConfirmationLive, :edit
      live "/users/confirm", UserConfirmationInstructionsLive, :new
    end
  end

```

# 動作確認
新規作成、重複チェック、ログアウト、ログインが問題なくできます
動画には写してないですが、再起動後ログイン後画面が表示されます

![a408ebc3a554817ad591f5dab405357a.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/23bf8fbc-10a9-2e18-dec2-0218083813bb.gif)

# 最後に
phx.gen.authやphx.gen.liveでベースファイルを作って、　APIに合わせて変更することで楽にクライアントアプリを作ることができました
画面を生成して、contextをAPI通信に書き換えるだけでクライアント側でバリデーションも行えます
認証周りが使いまわしができますが、どこの関数が実行されたのかわかりにくいので慣れが必要かもしれません

次はスマホアプリとしてシミュレーターで起動した時にローカルホストのサーバーとの通信について解説します

本記事は以上になりますありがとうごうざいました