はじめに
この記事はElixirアドベントカレンダー2025シリーズ2の24日目の記事です。
ファイルアップロードを実装します
Cloud Storageライブラリbucketの設定
最近でたようで幅広いサービスに対応していて
LiveViewにも対応してるみたい
READMEに沿って設定してきます
ライブラリの追加
defp deps do
[
...
- {:ecto_ulid_next, "~> 1.0.2"},
+ {:ecto_ulid_next, "~> 1.0.2"},
+ {:buckets, "~> 1.0.0-rc.4"}
]
end
mix deps.get
以下のファイルを作ります
defmodule Blog.Cloud do
use Buckets.Cloud, otp_app: :blog
end
config :blog, Blog.Cloud,
adapter: Buckets.Adapters.Volume,
bucket: "priv/static/images/uploads",
base_url: "http://localhost:4000",
Imageスキーマ作成
ファイルをアップロードに成功すると以下の構造体が返ってくるようなのでそれを保存します
%Buckets.Object{
uuid: "4b6da09c-09a3-4565-8e12-df5f87db2728",
filename: "DSC_0035-9.jpg",
data: {:file,
"/var/folders/6x/p3n8h50x5hzbnpj8hg9v9f540000gn/T/plug-1684-rYAc/multipart-1766767634-79785914284-1"},
metadata: %{content_type: "image/jpeg", content_size: 5359366},
location: #Buckets.Location<
path: "4b6da09c-09a3-4565-8e12-df5f87db2728/DSC_0035-9.jpg",
...
>,
stored?: true
}
以下のコマンドでマイグレーションファイルを作成します
最悪object_pathがあればなんとかなります
mix phx.gen.schema Posts.Image images content_type:string filename:string object_path:string post_id:references:posts
ULIDに合わせてリレーション周りを修正します
defmodule Blog.Repo.Migrations.CreateImages do
use Ecto.Migration
def change do
create table(:images) do
add :content_type, :string
add :filename, :string
add :object_path, :string
- add :post_id, references(:posts, on_delete: :nothing)
- add :user_id, references(:users, type: :id, on_delete: :delete_all)
+ add :post_id, references(:posts, on_delete: :delete_all)
+ add :user_id, references(:users, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
create index(:images, [:user_id])
create index(:images, [:post_id])
end
end
以下のコマンドでマイグレーションを実行します
mix ecto.migrate
ULIDとリレーション周りの設定をします
defmodule Blog.Posts.Image do
use Ecto.Schema
import Ecto.Changeset
+ @primary_key {:id, Ecto.ULID, autogenerate: true}
+ @foreign_key_type Ecto.ULID
schema "images" do
field :content_type, :string
field :filename, :string
field :object_path, :string
- field :post_id, :id
- field :user_id, :id
+ belong_to :post, Blog.Posts.Post
+ belong_to :user, Blog.Accounts.User
timestamps(type: :utc_datetime)
end
@doc false
def changeset(image, attrs, user_scope) do
image
- |> cast(attrs, [:content_type, :filename, :object_path)
- |> validate_required([:content_type, :filename, :object_path])
+ |> cast(attrs, [:content_type, :filename, :object_path, :post_id])
+ |> validate_required([:content_type, :filename, :object_path, :post_id])
|> put_change(:user_id, user_scope.user.id)
end
end
Upload API
こちらを参考にアップロードするAPIを作成します
バイナリデータの場合はアップロード用の構造体を作るためにfrom_upload()をします
アップロードが完了したらstored_objectが返ってくるので必要な情報でImageを作成します
defmodule BlogWeb.Api.V1.StorageController do
use BlogWeb, :controller
alias Blog.Posts
def upload(conn, %{"file" => file, "id" => post_id}) do
scope = conn.assigns.current_scope
file
|> Buckets.Object.from_upload()
|> Blog.Cloud.insert()
|> case do
{:ok, stored} ->
case Posts.create_image(scope, %{
post_id: post_id,
filename: stored.filename,
object_path: stored.location.path,
content_type: stored.metadata.content_type
}) do
{:ok, _image} -> send_resp(conn, 200, "OK")
{:error, reason} -> {:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
end
Imageを作成する関数はファイルの末尾にこの関数を実装します
alias Blog.Posts.Image
def create_image(%Scope{} = scope, attrs) do
with {:ok, image = %Image{}} <-
%Image{}
|> Image.changeset(attrs, scope)
|> Repo.insert() do
{:ok, image}
end
end
routerに追加します
scope "/api/v1", BlogWeb.Api.V1 do
pipe_through [:api, :require_verify_header]
get "/users/status", UserController, :status
post "/refresh_token", UserController, :refresh_token
post "/logout", UserController, :logout
post "/terminate", UserController, :terminate
+ post "/upload/:id", StorageController, :upload
resources "/posts", PostController, except: [:new, :edit]
end
Postのレスポンスに含める
アップロードしてもレスポンスに含めないとアプリで見えないので色々修正します
defmodule Blog.Posts.Post do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, Ecto.ULID, autogenerate: true}
@foreign_key_type Ecto.ULID
schema "posts" do
field :text, :string
belongs_to :user, Blog.Accounts.User
+ has_many :images, Blog.Posts.Image
timestamps(type: :utc_datetime)
end
end
defp data(%Post{} = post) do
%{
id: post.id,
text: post.text,
+ images: Enum.map(post.images, & &1.object_path)
}
end
Contextを修正します
list,showでpreloadするように修正
create,updateは特になくても大丈夫なのでレスポンスがエラーにならないように空のリストを入れます
def list_posts(%Scope{} = scope) do
Post
|> preload(:images)
|> Repo.all_by(user_id: scope.user.id)
end
def get_post!(%Scope{} = scope, id) do
Post
|> preload(:images)
|> Repo.get_by!(id: id, user_id: scope.user.id)
end
def create_post(%Scope{} = scope, attrs) do
with {:ok, post = %Post{}} <-
%Post{}
|> Post.changeset(attrs, scope)
|> Repo.insert() do
broadcast_post(scope, {:created, post})
{:ok, Map.put(post, :images, [])}
end
end
def update_post(%Scope{} = scope, %Post{} = post, attrs) do
true = post.user_id == scope.user.id
with {:ok, post = %Post{}} <-
post
|> Post.changeset(attrs, scope)
|> Repo.update() do
broadcast_post(scope, {:updated, post})
{:ok, Map.put(post, :images, [])}
end
end
最後に
これでAPIができましたので次はアプリ側を実装します
本記事は以上になりますありがとうございました