Phoenix とExpoで作るスマホアプリ ③ファイルアップロード編

[Phoenix とExpoで作るスマホアプリ ①Phoenix セットアップ編 + phx_gen_auth](https://qiita.com/the_haigo/items/76dedef74ffe98230137)
[Phoenix とExpoで作るスマホアプリ ②JWT認証+CRUD編](https://qiita.com/the_haigo/items/e01e671ca117ed2b8648)
[Phoenix とExpoで作るスマホアプリ ③ファイルアップロード編](https://qiita.com/the_haigo/items/f9373d6021bb34c45676) <-本記事

[前回](https://qiita.com/the_haigo/items/e01e671ca117ed2b8648)はJWT認証とCRUD機能を実装しました
今回はRailsのCarrierWaveに似たArcを使用してファイルアップロード機能を実装していきます

# Arcのインストールとアップローダー作成
最初にarcとarcをORMのEctoで使えるようにするarc_ectoを追加します

```[edit]mix.ex
defp deps do
  [
      ....
      {:arc, "~> 0.11.0"},
      {:arc_ecto, "~> 0.11.3"}
  ]
end
```

次にライブラリをインストール後、Imageモデルを作成します
user,post両方から参照したいので、usersとposts２つリレーションを組みます


```
mix deps.get
mix phx.gen.json Images Image images file:string post_id:references:posts user_id:references:users
mix ecto.migrate
```

マイグレーションが完了したら次にアップローダーを作成します

```
mix arc.g image
```

設定としては以下を変更しています
versionsに:thumb追加、transformをコメントインして250x250のpngでサムネイルを作成
ファイル名をアップロードされたファイル名をそのまま使用
保存先をuser_idとpost_idで分ける

```[new]lib/sns_web/uploader/image.ex
defmodule Sns.Image do
  use Arc.Definition
  use Arc.Ecto.Definition

  @versions [:original, :thumb]

  # Whitelist file extensions:
  def validate({file, _}) do
    ~w(.jpg .jpeg .gif .png) |> Enum.member?(Path.extname(file.file_name))
  end

  # Define a thumbnail transformation:
  def transform(:thumb, _) do
    {:convert, "-strip -thumbnail 250x250^ -gravity center -extent 250x250 -format png", :png}
  end

  # Override the persisted filenames:
  def filename(version, {file, _scope}) do
    "#{version}_#{file.file_name}"
  end

  # Override the storage directory:
  def storage_dir(_version, {_file, scope}) do
   "uploads/user/#{scope.user_id}/posts/#{scope.post_id}/images"
  end
end

```

# アップローダーをマウント
phx.genで作成したImageモデルでArcを使用できるように設定します
関連先をcastに、
画像のパスを保存するカラムをcast_attachmentsに渡します

```[new]lib/sns/images/image.ex
defmodule Sns.Images.Image do
  use Ecto.Schema
  use Arc.Ecto.Schema # <- ここ追加
  import Ecto.Changeset

  schema "images" do
    field :file, Sns.Image.Type # <- ここを変更

    belongs_to :post, Sns.Posts.Post
    belongs_to :user, Sns.Users.User

    timestamps()
  end

  @doc false
  def changeset(image, attrs) do
    image
    |> cast(attrs, [:post_id, :user_id]) #<- ここを変更
    |> cast_attachments(attrs,[:file]) # <- ここを追加
    |> validate_required([:file])
  end
end

```

# リレーション設定
前回作成したPostにリレーションの設定とPost作成時に一緒に画像を作成するようにします

```[edit]lib/sns/posts/post.ex
defmodule Sns.Posts.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :body, :string

    belongs_to :user, Sns.Users.User
    has_many :images, Sns.Images.Image # <-追加
    timestamps()
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:body, :user_id])
    |> cast_assoc(:images) # <- ここ追加
    |> validate_required([:body, :user_id])
  end
end

```

post取得時にimageも取得したいのでviewを変更します

```[edit]lib/sns_web/views/api/v1/post_view.ex
defmodule SnsWeb.Api.V1.PostView do
  use SnsWeb, :view
  alias SnsWeb.Api.V1.PostView
  alias SnsWeb.Api.V1.ImageView # <- ここ追加

  ...
  def render("post.json", %{post: post}) do
    %{
      id: post.id,
      body: post.body,
      images: render_many(post.images, ImageView, "image.json") # <- ここ追加
    }
  end
end

```

リレーション先を取得するには予め読み込んでおかないとエラーになるので
preload(:images)を追加します

```[edit]lib/sns/posts.ex
defmodule Sns.Posts do
  ...
  def list_posts do
    Post
    |> preload(:images)
    |> Repo.all()
  end

  def get_post!(id) do
    Post
    |> preload(:images)
    |> Repo.get!(id)
  end
  ...
```

Userにもリレーションの設定

```[edit]lib/sns/users/user.ex
defmodule Sns.Users.User do
...
  schema "users" do
    field :email, :string
    field :password, :string, virtual: true
    field :hashed_password, :string
    field :confirmed_at, :naive_datetime

    has_many :posts, Sns.Posts.Post
    has_many :images, Sns.Images.Image # <- ここ追加
    timestamps()
  end
....
end
```

# モジュールを api/v1 配下に移動
phx.genでは階層を掘れないのでファイルを移動して、モジュール名を変更します

```[new]lib/sns_web/controllers/api/v1/ImageController.ex
defmodule SnsWeb.Api.V1.ImageController do # <-Api.V1を間に追加
...
end
```
```[new]lib/sns_web/views/api/v1/ImageView.ex
defmodule SnsWeb.Api.V1.ImageView do # <-Api.V1を間に追加
  use SnsWeb, :view
  alias SnsWeb.Api.V1.ImageView # <-こちらも
  ...
end
```

最後にrouterにimagesを追加します

```[edit]lib/sns_web/router.ex
defmodule SnsWeb.Router do
...
  scope "/api/v1", SnsWeb do
    pipe_through [:api, :jwt_authenticated]

    get "/mypage", Api.V1.UserController, :show
    resources "/posts", Api.V1.PostController, except: [:new, :edit]
    resources "/images", Api.V1.ImageController, except: [:new, :edit] # <- ここ追加
  end
...
end
```

# endpoint 
アップロードした画像にlocalhost:4000/uploads でアクセスできるように
endpointを追加します

```[edit]lib/sns_web/endpoint.ex
defmodule SnsWeb.Endpoint do
  ...
  plug Plug.Static,
    at: "/uploads",
    from: Path.expand("./uploads"),
    gzip: false
  ...
end

```
gitignoreに追加するのもお忘れなく

```[edit].gitignore
/uploads
```
# 動作確認
bodyタブ->from-dataを選択してください
リレーション先のフォームデータは以下のように指定します
model名[リレーション名][has_manyなら配列][カラム名]
post[images][][file]
![スクリーンショット 2020-09-30 16.38.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/07424b30-7cbb-3ebc-344c-b5f6f5b0c48d.png)

成功しましたがファイル名が返ってきているのでファイルパスが返ってくるようにします
またpost_idとuser_idが入っているかを確認したいので追加で表示するようにします

```[edit]
defmodule SnsWeb.Api.V1.ImageView do
  ...
  def render("image.json", %{image: image}) do
    %{
      id: image.id,
      file: Sns.Image.url({image.file, image}, :thumb), # <- ここを変更
      user_id: image.user_id, # <- ここを追加
      post_id: image_post_id # <- ここを追加
    }
  end
end
```

今度はファイルパスとpost_idはあるが、user_idが入っていません
![スクリーンショット 2020-09-30 16.48.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/8deecfba-1030-cc09-a60e-58fc6838c939.png)

cast_assocのwith optionを使用して
関連先の関数を実行した上でcast_assocするように変更します
withのoptionはモデル名、関数名、関数にわたす引数になります

```[edit]lib/sns/posts/post.ex
defmodule Sns.Posts.Post do
  ...
  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:body, :user_id])
    |> cast_assoc(:images, with: { Sns.Images.Image, :insert_user_id, [attrs["user_id"]]}) # <- ここ変更
    |> validate_required([:body, :user_id])
  end
end
```
第1引数はimage構造体、 第2引数はattrs Mapが自動的に入り
第3引数がwithで指定した値になります


```[edit]lib/sns/images/image.ex
defmodule Sns.Images.Image do
  ... #以下追加
  def insert_user_id(image, params, user_id) do
    image
    |> Map.put(:user_id, user_id)
    |> changeset(params)
  end
end

```

上記の変更で無事Imageにもuser_idが渡されるようになりました!
![スクリーンショット 2020-09-30 16.59.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/08994423-9f01-adda-3cb4-fd1df72c0890.png)

なりましたが、以下の:post_idがnullで保存されたようでアクセスできない・・・
uploads/users/:user_id/posts/:post_id/images

解決策として一度Postを作成してその後Imageを追加するようにしてみましょう
postを作成後imageを作成するcreate_post_with_imageを追加します

```[edit]lib/sns_web/controllers/api/v1/post_controller.ex
defmodule SnsWeb.Api.V1.PostController do
  ...
  def create(conn, %{"post" => post_params}) do
    with {:ok, %Post{} = post} <- Posts.create_post_with_image( # <- ここ変更
      Map.put(post_params, "user_id", conn.user_id)
    ) do
      conn
      |> put_status(:created)
      |> put_resp_header("location", Routes.post_path(conn, :show, post))
      |> render("show.json", post: post)
    end
  end
  ...
end
```
create_post_with_imageの内容は
withでcreate_postの結果を受け取って、imageを作成するchangeset_with_imageを実行します
先程のcreate_post時のcast_assocとは違って、update扱いになるのでpreload()が必要になります

```[edit]lib/sns/posts.ex
defmodule Sns.Posts do
  ... #以下を追加
  def create_post_with_image(attrs \\ %{}) do
    with { :ok,  %Post{} = post } <- create_post(attrs) do
      post
      |> Repo.preload(:images)
      |> Post.changeset_with_image(attrs)
      |> Repo.update()
    end
  end
  ....
end
```

```[edit]lib/sns/posts/post.ex
defmodule Sns.Posts.Post do
  ... #cast_assocの行を削除
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:body, :user_id])
    |> validate_required([:body, :user_id])
  end
 #以下を追加
  def changeset_with_image(post, attrs) do
    post
    |> cast(attrs, [:body, :user_id])
    |> cast_assoc(:images, with: { Sns.Images.Image, :insert_ids, [post] })
  end
end

```
image.exの方もuser_idだけでなくpost_idを渡したいので新しくinsert_ids関数を作ります

```[edit]lib/sns/images/image.ex
defmodule Sns.Images.Image do
  ... # inser_user_id関数を消して以下を追加
  def insert_ids(image, params, post) do
    image
    |> Map.put(:user_id, post.user_id)
    |> Map.put(:post_id, post.id)
    |> changeset(params)
  end
end
```

返ってきたurlにアクセスしてサムネイルが表示されているのを確認できました
![スクリーンショット 2020-09-30 19.12.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/e25b66b6-dc62-694c-5d8a-00234d510bf5.png)

今回は以上になります次は many_to_manyのリレーションを使用してTagを実装します

#今回の差分
https://github.com/thehaigo/sns/commit/8d9b6152462d5ac8f9f0e77d026656be474fc37b
# 参考サイト
https://github.com/stavro/arc
https://github.com/stavro/arc_ecto
https://www.koga1020.com/posts/ecto-assoc-functions
https://hexdocs.pm/ecto/Ecto.Changeset.html?#cast_assoc/3