5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ElixirDesktopで作るブログアプリ API bucketによるファイルアップロード

Posted at

はじめに

この記事はElixirアドベントカレンダー2025シリーズ2の24日目の記事です。

ファイルアップロードを実装します

Cloud Storageライブラリbucketの設定

最近でたようで幅広いサービスに対応していて
LiveViewにも対応してるみたい

READMEに沿って設定してきます

ライブラリの追加

mix.exs
  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

以下のファイルを作ります

lib/blog/cloud.ex
defmodule Blog.Cloud do
  use Buckets.Cloud, otp_app: :blog
end
config/dev.exs
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に合わせてリレーション周りを修正します

priv/repo/migrations/20251226142604_create_images.exs
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とリレーション周りの設定をします

lib/blog/posts/image.ex
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を作成します

lib/blog_web/controllers/api/v1/storage_controller.ex
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を作成する関数はファイルの末尾にこの関数を実装します

lib/blog/posts.ex
  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に追加します

lib/blog_web/router.ex
  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のレスポンスに含める

アップロードしてもレスポンスに含めないとアプリで見えないので色々修正します

lib/blog/posts/post.ex
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
lib/blog_web/controllers/api/v1/post_json.ex
  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は特になくても大丈夫なのでレスポンスがエラーにならないように空のリストを入れます

lib/blog/posts.ex
  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ができましたので次はアプリ側を実装します
本記事は以上になりますありがとうございました

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?