Active Storageとurl_forメソッドを用いた画像添付機能の実装（Ruby on Rails7）  

## はじめに
RoRの学習をするために、尊敬するYouTuberの一人である以下の方の動画を参考に、ブログアプリを作成しました。このアプリに画像投稿機能を実装したので、自分の個人メモとして記事に残すことにしました。

https://www.youtube.com/watch?v=XQtkP8it9Ag&list=PLT5klp7W4r8Se1KZQWcWljWkTELnsvwp6&index=9

## 開発環境
#### フロントエンド
- Next.js 15.0.2 (App Router)
- react 18.2.0 (TypeScript)

#### バックエンド
- ruby 3.1.6
- Rails 7.2.2

#### データベース
- sqlite3

## Active Storageとは
> Rails5.2から導入された、主にファイルをクラウドストレージサービスにアップロードして保持するためのフレームワークです。Active Storageを使用することで、Amazon S3、Google Cloud Storage、Microsoft Azure Storageなどのクラウドストレージサービスへ簡単にファイルをアップロードできるようになります。ファイルの形式は問われず、あらゆるファイルをアップロードできます。
Active Storageにアップロードされたファイルには個別のURLが付加されます。このようなURLには一般的に有効期間が設定されていますが、クラウドストレージサービスを利用している場合は、RailsからURLの生成をクラウドストレージサービスに依頼してくれます。

（引用：Ruby on Rails 7ポケットリファレンス）

## url_forメソッドとは
指定されたオプション（ここではimageファイル）に対応するURLを生成して返すメソッドです。指定できるオプションは公式サイトをご覧ください。

https://railsdoc.com/page/url_for

## 実装手順

### **1. Rails側: APIの設定**

Railsで画像投稿を処理するために、`Active Storage` を使用します。

#### **1-1. 必要なGemのインストール**
`image_processing` のGemが必要です。`Gemfile`に追加してインストールします。

```ruby
gem 'image_processing', '~> 1.2'
```

その後、以下を実行：
```bash
bundle install
rails active_storage:install
rails db:migrate
```
<details><summary>コードの内容</summary>

### 1. **`bundle install`**
#### **役割**:
- `Gemfile` に記述されたGem（ライブラリ）をインストールします。
- 必要なGemをプロジェクト内の環境に追加し、それらを動作可能な状態にします。

#### **詳細**:
- `Gemfile` はRailsアプリケーションで使用するGem（ライブラリ）のリストを記述したファイルです。
- `bundle install` は、`Gemfile` を読み取り、指定されたGemとその依存関係を解決してインストールします。
- Gemは通常、プロジェクトごとに管理されるため、プロジェクトの`Gemfile.lock`にインストールされたバージョンが記録されます。

#### **例**:
- `image_processing` や `active_storage` に関連するGemがインストールされます。
- このコマンドが成功することで、`rails active_storage:install` を実行するための環境が整います。

---

### 2. **`rails active_storage:install`**
#### **役割**:
- **Active Storage** 機能をRailsアプリケーションに追加します。
- Active Storageは、画像やPDF、動画などのファイルをアプリケーションで簡単に管理・操作できるようにするRails標準の機能です。

#### **具体的な動作**:
- このコマンドを実行すると、Active Storageを使用するためのマイグレーションファイルが作成されます。
  - 作成される主なテーブル:
    1. `active_storage_blobs`: ファイルのメタデータを保存するテーブル。
    2. `active_storage_attachments`: モデル（例: Post）とファイルの関連付けを管理するテーブル。

#### **ファイルの例**:
実行後、`db/migrate/`ディレクトリに次のようなマイグレーションファイルが生成されます:
```ruby
class CreateActiveStorageTables < ActiveRecord::Migration[6.0]
  def change
    create_table :active_storage_blobs do |t|
      t.string :key, null: false
      t.string :filename, null: false
      t.string :content_type
      t.text :metadata
      t.string :service_name, null: false
      t.bigint :byte_size, null: false
      t.string :checksum, null: false
      t.datetime :created_at, null: false
    end

    create_table :active_storage_attachments do |t|
      t.string :name, null: false
      t.references :record, null: false, polymorphic: true, index: false
      t.references :blob, null: false
      t.datetime :created_at, null: false
    end
  end
end
```

#### **次に実行する必要があるコマンド**:
このコマンドで生成されたマイグレーションをデータベースに反映させるために、`rails db:migrate` を実行します。

---

### 3. **`rails db:migrate`**
#### **役割**:
- マイグレーションファイルに基づいて、データベース構造を更新します。
- ここでは、`rails active_storage:install` で作成されたActive Storage用のテーブルをデータベースに適用します。

#### **具体的な動作**:
- `db/migrate/` ディレクトリ内の未実行のマイグレーションファイルを探します。
- 各マイグレーションを順番に実行し、データベースのスキーマを最新の状態に更新します。
- 更新されたスキーマ情報は `db/schema.rb` に記録されます。

#### **実行後の効果**:
- Active Storageでファイルを保存するために必要なデータベースの準備が整います。
  - ファイルのメタ情報を保存する`active_storage_blobs`テーブル。
  - モデルとファイルの関連付けを管理する`active_storage_attachments`テーブル。

---

### **これらのコマンドを実行する順序と効果**
1. **`bundle install`**:
   - 必要なGemをインストールしてActive Storageを利用可能にします。

2. **`rails active_storage:install`**:
   - Active Storageを設定するためのマイグレーションファイルを生成します。

3. **`rails db:migrate`**:
   - データベースにActive Storage用のテーブルを作成し、設定を反映します。

</details>

#### **1-2. モデルに画像添付機能を追加**
`Post` モデルで画像を添付可能にします。

**`app/models/post.rb`**:
```ruby
class Post < ApplicationRecord
  has_one_attached :image

  include Rails.application.routes.url_helpers

  def as_json(options = {})
    super(options).merge(
      image_url: image.attached? ? Rails.application.routes.url_helpers.url_for(image) : nil
    )
  end
end
```

<details><summary>コードの内容</summary>

### **1. `class Post < ApplicationRecord`**
- このコードは、`Post` という名前の Rails モデルを定義しています。
- `ApplicationRecord` を継承しており、これにより Active Record の機能を利用できます。
- このモデルはデータベースの `posts` テーブルと関連付けられます。

---

### **2. `include Rails.application.routes.url_helpers`**
- この行は、Rails のルートヘルパー（例: `url_for`, `rails_blob_url`）をこのモデル内で利用可能にするためのものです。
- 通常、`url_for` などのルート生成メソッドはコントローラーやビューでのみ利用できます。この行を追加することで、モデル内でもそれらを使用できるようになります。

---

### **3. `has_one_attached :image`**
- Active Storage を使って、このモデルに1つの画像（`image`）を添付できるようにしています。
- Active Storage を利用することで、画像やファイルのアップロード、保存、関連付けが可能になります。

#### **機能概要**
- 画像が添付されると、Active Storage は以下の3つを管理します：
  - ファイル自体（デフォルトでは `storage` ディレクトリに保存されます）。
  - メタデータ（ファイル名やタイプなど）。
  - モデル（`Post`）との関連付け情報。

#### **利用例**
```ruby
post = Post.new(title: "Sample Post")
post.image.attach(io: File.open("path/to/image.jpg"), filename: "image.jpg")
post.save
```
これにより、`image` フィールドに画像が添付されます。

---

### **4. `def as_json(options = {})`**
- このメソッドは、`Post` モデルを JSON 形式で返すときのカスタマイズを行います。
- Rails では、モデルを JSON に変換する際に `as_json` メソッドが呼び出されます。このメソッドをオーバーライドすることで、出力される JSON に追加情報を含めることができます。

---

### **5. `super(options)`**
- `super(options)` は、親クラス（`ApplicationRecord`）の `as_json` メソッドを呼び出しています。
- `super` により、通常の `Post` モデルの属性（例: `id`, `title`, `content` など）が JSON として返されます。

---

### **6. `merge(image_url: ...)`**
- `merge` を使って、生成された JSON に `image_url` という新しいキーを追加しています。
- この `image_url` は、画像が添付されている場合にその画像へのアクセス URL を含みます。

---

### **7. `image.attached?`**
- `image.attached?` は、Active Storage で画像が添付されているかを判定します。
  - **`true` の場合**: 画像が添付されている。
  - **`false` の場合**: 画像が添付されていない。

#### **例**
```ruby
post.image.attached? # => true or false
```

---

### **8. `Rails.application.routes.url_helpers.url_for(image)`**
- 画像が添付されている場合、`url_for(image)` を使ってその画像への完全な URL を生成します。
- 例えば、ローカル開発環境で以下のような URL が生成されます：
  ```
  http://localhost:3001/rails/active_storage/blobs/redirect/:signed_id/:filename
  ```

#### **`url_for` の仕組み**
- `url_for` は、Rails のルーティング情報を基にして適切な URL を生成します。
- Active Storage のファイルには署名付き ID（`signed_id`）が付与され、セキュリティが確保されています。

---

### **9. `else nil`**
- 画像が添付されていない場合、`image_url` の値として `nil` を返します。
- これにより、画像がない場合に `image_url` が空になることを示します。

---

### **まとめ**
#### **このコードの動作**
1. `Post` モデルに Active Storage の画像添付機能を設定（`has_one_attached :image`）。
2. `as_json` メソッドをカスタマイズして、以下を JSON 出力に含める：
   - 画像の添付状況に応じて、`image_url` を追加。
   - 添付されている場合は画像の URL を生成。
   - 添付されていない場合は `nil` を設定。

---

#### **このコードの利点**
- Active Storage による画像添付を簡単に扱える。
- API レスポンスに画像の URL を自動的に含められるため、フロントエンドでの処理が容易になる。
- 画像が存在しない場合の対応（`nil` を返す）も含まれているため、堅牢性が高い。

</details>


#### **1-3. APIエンドポイントを更新**
画像を含む投稿を処理するエンドポイントを作成します。

**`app/controllers/posts_controller.rb`**:
```ruby
class PostsController < ApplicationController
  def create
    post = Post.new(post_params)
    if post.save
      render json: { post: post, message: 'Post created successfully' }, status: :created
    else
      render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
    end
  end

  def update
    post = Post.find(params[:id])
    if post.update(post_params)
      render json: { post: post, message: 'Post updated successfully' }
    else
      render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
    end
  end

  private

  def post_params
    params.require(:post).permit(:title, :content, :image) # imageを許可
  end
end
```

---

### **2. Next.js側: フロントエンドの設定**

Next.jsで画像を含むフォームを作成し、Rails APIにリクエストを送信します。

#### **2-1. フォームを作成**

**`pages/posts/create-post/page.tsx`**:
```tsx
"use client";

import { useState } from "react";
import axios from "axios";

export default function CreatePost() {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [image, setImage] = useState<File | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const formData = new FormData();
    formData.append("post[title]", title);
    formData.append("post[content]", content);
    if (image) {
      formData.append("post[image]", image);
    }

    try {
      const res = await axios.post("http://localhost:3001/api/v1/posts", formData, {
        headers: { "Content-Type": "multipart/form-data" },
      });
      console.log(res.data);
      alert("投稿が作成されました！");
    } catch (err) {
      console.error(err);
      alert("投稿の作成に失敗しました。");
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>タイトル:</label>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          required
        />
      </div>
      <div>
        <label>本文:</label>
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          required
        />
      </div>
      <div>
        <label>画像:</label>
        <input
          type="file"
          onChange={(e) => setImage(e.target.files ? e.target.files[0] : null)}
        />
      </div>
      <button type="submit">投稿</button>
    </form>
  );
}
```

#### **2-2. APIへのリクエストを確認**
- `axios` を使用して、`Rails` のAPIに画像を含むリクエストを送信しています。
- フォームデータ (`FormData`) を利用することで、画像ファイルを含むデータを送信できます。

**Thunder Clientで確認⇩**
![スクリーンショット 2024-11-14 10.39.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3535387/cbee9c2b-d7c8-6886-f87d-5c1b7e735fd2.png)

---

### **3. Next.jsで画像を表示**

投稿詳細ページで画像を表示します。

#### **投稿詳細ページ**

**`pages/posts/[id]/page.tsx`**:
```tsx
"use client";

import { useEffect, useState } from "react";
import axios from "axios";

export default function PostDetail({ params }: { params: { id: string } }) {
  const [post, setPost] = useState<any>(null);

  useEffect(() => {
    const fetchPost = async () => {
      const res = await axios.get(`http://localhost:3001/api/v1/posts/${params.id}`);
      setPost(res.data);
    };
    fetchPost();
  }, [params.id]);

  if (!post) return <div>Loading...</div>;

  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      {cleanImageUrl && (
        <Image
          src={cleanImageUrl}
          alt={post.title}
          width={500} // 適切な幅を指定
          height={450} // 適切な高さを指定
        />
      )}
    </div>
  );
}
```

---

### **4. Railsで画像URLを返す**

Rails側で画像のURLを含めたレスポンスを返します。

**`app/models/post.rb`**:
```ruby
class Post < ApplicationRecord
  has_one_attached :image

  include Rails.application.routes.url_helpers

  def as_json(options = {})
    super(options).merge(
      image_url: image.attached? ? Rails.application.routes.url_helpers.url_for(image) : nil
    )
  end
end

```

**`config/environments/development.rb`**:
```ruby
Rails.application.routes.default_url_options = { host: "localhost", port: 3001 }
```

---

### **5. 動作確認**

1. **Railsサーバーを起動**:
   ```bash
   rails server
   ```

2. **Next.jsサーバーを起動**:
   ```bash
   npm run dev
   ```

3. **ブラウザでアクセス**:
   - 投稿フォームページ: `http://localhost:3000/posts/create-post`
   - 投稿詳細ページ: `http://localhost:3000/posts/[id]`

こんな感じで表示できました⇩（画像はGPTが作ったうちの娘のアバターですw）
![スクリーンショット 2024-11-14 15.50.10（2）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3535387/14a7564d-69b6-8074-7216-770f79bf4417.png)

---
## おわりに

https://github.com/se1987/rails_blog_app

今回は詳細画面での表示までですが、更新画面でも表示できるようにしたいと思います。
もっとスマートに実装できる方法があれば、ご教示ください。誤りがある場合も、ご指摘いただけるとありがたいです。最後までお読みいただきまして、ありがとうございます！