Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
0
Help us understand the problem. What is going on with this article?
@the_haigo

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

Phoenix とExpoで作るスマホアプリ ①Phoenix セットアップ編 + phx_gen_auth
Phoenix とExpoで作るスマホアプリ ②JWT認証+CRUD編
Phoenix とExpoで作るスマホアプリ ③ファイルアップロード編 <-本記事

前回は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とposts2つリレーションを組みます

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

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

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

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

なりましたが、以下の: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

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

今回の差分

参考サイト

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

0
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
the_haigo
坊主の副業でフリーでプログラマーをしています 仕事はRoRがメインで趣味でReactNative(expo) Elixir Phoenix をやっています
fukuokaex
エンジニア/企業向けにElixirプロダクト開発・SI案件開発を支援する福岡のコミュニティ

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
0
Help us understand the problem. What is going on with this article?