Phoenix とExpoで作るスマホアプリ ①Phoenix セットアップ編 + phx_gen_auth
Phoenix とExpoで作るスマホアプリ ②JWT認証+CRUD編
Phoenix とExpoで作るスマホアプリ ③ファイルアップロード編 <-本記事
前回はJWT認証とCRUD機能を実装しました
今回はRailsのCarrierWaveに似たArcを使用してファイルアップロード機能を実装していきます
Arcのインストールとアップローダー作成
最初にarcとarcをORMのEctoで使えるようにするarc_ectoを追加します
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で分ける
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に渡します
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作成時に一緒に画像を作成するようにします
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を変更します
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)を追加します
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にもリレーションの設定
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では階層を掘れないのでファイルを移動して、モジュール名を変更します
defmodule SnsWeb.Api.V1.ImageController do # <-Api.V1を間に追加
...
end
defmodule SnsWeb.Api.V1.ImageView do # <-Api.V1を間に追加
use SnsWeb, :view
alias SnsWeb.Api.V1.ImageView # <-こちらも
...
end
最後にrouterにimagesを追加します
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を追加します
defmodule SnsWeb.Endpoint do
...
plug Plug.Static,
at: "/uploads",
from: Path.expand("./uploads"),
gzip: false
...
end
gitignoreに追加するのもお忘れなく
/uploads
動作確認
bodyタブ->from-dataを選択してください
リレーション先のフォームデータは以下のように指定します
model名[リレーション名][has_manyなら配列][カラム名]
post[images][][file]
成功しましたがファイル名が返ってきているのでファイルパスが返ってくるようにします
また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が入っていません
cast_assocのwith optionを使用して
関連先の関数を実行した上でcast_assocするように変更します
withのoptionはモデル名、関数名、関数にわたす引数になります
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で指定した値になります
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が渡されるようになりました!
なりましたが、以下の:post_idがnullで保存されたようでアクセスできない・・・
uploads/users/:user_id/posts/:post_id/images
解決策として一度Postを作成してその後Imageを追加するようにしてみましょう
postを作成後imageを作成するcreate_post_with_imageを追加します
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()が必要になります
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
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関数を作ります
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にアクセスしてサムネイルが表示されているのを確認できました
今回は以上になります次は 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