43
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Rails × Carrierwave で作成した画像アップロードAPIをReact側から叩くためのチュートリアル

Last updated at Posted at 2021-05-03

概要

  • バックエンド: Rails(APIモード)
  • フロントエンド: React(もしくはVue)

最近、こんな感じで役割分担しつつ開発する人が増えていると思います。(特に未経験からのエンジニア転職を目指している人の多くは大体そんな感じでポートフォリオを作られている印象)

自分自身も上記の構成が好きでシコシコ個人開発してたりするわけですが、画像アップロード機能を実装しようと思った際に少し手こずったのでメモ書きとして残しておきます。

※「チュートリアル」と銘打ってはいるものの、細かいコードの説明などはあまりありません。一応、手順通りに進めれば同じものは作れるはずなのでまずは自分の手を動かした後に各コードを読み込んでみてください。

完成イメージ

マイ-ムービー(2).gif

画像プレビュー機能なども付けてそれっぽく仕上げてみました。

使用技術

  • Rails6(APIモード)
  • React
  • TypeScript

バックエンド

まず最初にバックエンド側から作っていきましょう。

ディレクトリ&各種ファイルを作成

$ mkdir rails-react-carrierwave-backend && cd rails-react-carrierwave-backend
$ touch Dockerfile
$ touch docker-compose.yml
$ touch entrypoint.sh
$ touch Gemfile
$ touch Gemfile.lock
./Dockerfile
FROM ruby:3.0

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs

ENV APP_PATH /myapp

RUN mkdir $APP_PATH
WORKDIR $APP_PATH

COPY Gemfile $APP_PATH/Gemfile
COPY Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install

COPY . $APP_PATH

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]
./docker-compose.yml
version: "3"
services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: password
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - mysql-data:/var/lib/mysql
      - /tmp/dockerdir:/etc/mysql/conf.d/
    ports:
      - 3306:3306
  api:
    build:
      context: .
      dockerfile: Dockerfile
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
      - ./vendor/bundle:/myapp/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
    ports:
      - "3001:3000"
    depends_on:
      - db
volumes:
  mysql-data:
./entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /myapp/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
./Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails", "~> 6"
./Gemfile.lock
# 空欄でOK

rails new

おなじみのコマンドでプロジェクトを作成します。

$ docker-compose run api rails new . --force --no-deps -d mysql --api

Gemfileが更新されたので再ビルド。

$ docker-compose build

「.config/database.yml」を編集

./config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password # デフォルトだと空欄になっているはずなので変更
  host: db # デフォルトだとlocalhostになっているはずなので変更

development:
  <<: *default
  database: myapp_development

test:
  <<: *default
  database: myapp_test

production:
  <<: *default
  database: <%= ENV["DATABASE_NAME"] %>
  username: <%= ENV["DATABASE_USERNAME"] %>
  password: <%= ENV["DATABASE_PASSWORD"] %>
  host: <%= ENV["DATABASE_HOST"] %>

データベースを作成

$ docker-compose run api rails db:create

動作確認

$ docker-compose up -d

スクリーンショット 2021-05-04 4.44.25.png

localhost:3001 にアクセスしていつもの画面が表示されればOK。

APIを作成

carrierwaveをインストール。

./Gemfile
gem 'carrierwave'

Gemfileを更新したので再ビルド。

$ docker-compose build

アップローダーを作成。

$ docker-compose run api rails g uploader Image

すると「./app/uploaders/image_uploader.rb」が自動生成されるので次のように変更します。

.app/uploaders/image_uploader.rb
class ImageUploader < CarrierWave::Uploader::Base
  storage :file

  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

  # 受け付け可能なファイルの拡張子を指定
  def extension_allowlist
    %w(jpg jpeg png)
  end
end

また、「./config/initializers/」配下にcarrierwave設定用のファイルを作成。

$ touch config/initializers/carrierwave.rb
./config/initializers/carrierwave.rb
CarrierWave.configure do |config|
  config.asset_host = "http://localhost:3001"
  config.storage = :file
  config.cache_storage = :file
end

Postモデルを作成。

$ docker-compose run api rails g model Post content:text image:string
$ docker-compose run api rails db:migrate

先ほど作成したアップローダーをマウントします。(ついでにバリデーションもやっておきましょう。)

./app/models/post.rb
class Post < ApplicationRecord
  mount_uploader :image, ImageUploader
  
  validates :content, presence: true, length: { maximum: 140 }
end

コントローラーを作成。

$ docker-compose run api rails g controller api::v1::posts
./app/controllers/api/v1/posts_controller.rb
class Api::V1::PostsController < ApplicationController
  before_action :set_post, only: %i[destroy]

  def index
    render json: { posts: Post.all.order("created_at DESC") }
  end

  def create
    post = Post.new(post_params)  
    post.save
  end

  def destroy
    post = Post.find(params[:id])
    post.destroy
  end

  private

    def set_post
      @post = Post.find(params[:id])
    end

    def post_params
      params.permit(:content, :image)
    end
end

ルーティングを記述。

./config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
     resources :posts, only: %i[index create destroy]
    end 
  end 
end

これで準備は完了です。

スクリーンショット 2021-05-03 23.54.50.png

あとは動作確認のためルートディレクトリに適当な画像を「sample.jpg」という名前配置し、次のcurlコマンドを実行しましょう。

$ curl -F "content=test" -F "image=@sample.jpg" http://localhost:3001/api/v1/posts

特にエラーっぽいレスポンスが返ってこなければ上手くいっているはずなので、次のcurlコマンドで確認します。

$ curl -X GET http://localhost:3001/api/v1/posts
{
    "posts": [
        {
            "id": 1,
            "content": "test",
            "image": {
                "url": "http://localhost:3001/uploads/post/image/1/sample.jpg"
            },
            "created_at": "2021-05-03T17:36:33.147Z",
            "updated_at": "2021-05-03T17:36:33.147Z"
        }
    ]
}

こんな感じで画像のパスが保存されていればOK。

CORSの設定

これでAPIは完成ですが、今の状態のままReact側から呼び出そうとするとセキュリティ的な問題でエラーが生じます。

そこで、CORSの設定を行わなければなりません。

参照: CORS とは?

RailsにはCORSの設定を簡単に行えるgemが存在するのでインストールしましょう。

rb./Gemfile
gem 'rack-cors'

APIモードで作成している場合、すでにGemfile内に記載されているのでそちらのコメントアウトを外せばOKです。

Gemfileを更新したので再度ビルド。

$ docker-compose build

あとは「./config/initializers/」配下にある設定ファイルをいじくり外部からアクセス可能なようにしておきます。

./config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "localhost:3000" # React側はポート番号3000で作るので「localhost:3000」を指定

    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

これで設定は完了。

フロントエンド

フロントエンド側は別リポジトリで作成します。(その方がコードの見通しも良くなるので)

create-react-app

$ mkdir rails-react-carrierwave-frontend && cd rails-react-carrierwave-frontend
$ npx create-react-app . --template typescript

不要なファイルを整理

$ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts 

「./src/index.tsx」と「./src/App.tsx」を次のように変更します。

./src/index.tsx
import React from "react"
import ReactDOM from "react-dom"
import "./index.css"
import App from "./App"

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
)
./src/App.tsx
import React from "react"

const App: React.FC = () => {
  return (
    <h1>Hello World!</h1>
  )
}

export default App

一旦、動作確認してみましょう。

$ yarn start

スクリーンショット 2021-05-04 0.37.55.png

localhost:3000 にアクセスして「Hello World!」と返ってくればOK。

各種ディレクトリ・ファイルを準備

$ mkdir components
$ mkdir components/post
$ mkdir interfaces
$ mkdir lib
$ mkdir lib/api
$ touch components/post/PostForm.tsx
$ touch components/post/PostItem.tsx
$ touch components/post/PostList.tsx
$ touch interfaces/index.ts
$ touch lib/api/client.ts
$ touch lib/api/posts.ts
$ mv components interfaces lib src

最終的に次のような構成になっていればOK。

rails-react-carrierwave-frontend
├── node_modules
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── components
│   │   └── post
│   │       ├── PostForm.tsx
│   │       ├── PostItem.tsx
│   │       └── PostList.tsx
│   ├── interfaces
│   │   └── index.ts
│   ├── lib
│   │   └── api
│   │       ├── client.ts
│   │       └── posts.ts
│   ├── App.tsx
│   ├── index.css
│   ├── index.tsx
│   ├── react-app-env.d.ts
├── .gitignore
├── package.json
├── README.md
├── tsconfig.json
└── yarn.lock

各種ライブラリをインストール

$ yarn add @material-ui/core@next @material-ui/icons@next @material-ui/lab@next @material-ui/styled-engine @emotion/react @emotion/styled axios react-router-dom @types/react-router-dom react-router-dom
  • material-ui
    • UIを整える用のライブラリ。(ついこの前最新バージョンv5が発表されたので試しにそちらを使用)
  • emotion
    • material-uiの最新バージョンを使う際に追加で必要なライブラリ。
  • axios
    • APIリクエスト用のライブラリ。
  • react-router-dom
    • ルーティング設定用のライブラリ。

型定義

./src/interfaces/index.ts
export interface Post {
  id: string
  content: string
  image?: {
    url: string
  }
}

export interface PostApiJson {
  posts: Post[]
}

APIクライアントを作成

./src/lib/api/client.ts
import axios, { AxiosInstance, AxiosResponse }  from "axios"

let client: AxiosInstance

export default client =  axios.create({
  baseURL: "http://localhost:3001/api/v1",
  headers: {
    "Content-Type": "multipart/form-data" // 画像ファイルを取り扱うのでform-dataで送信
  }
})

client.interceptors.response.use(
  (response: AxiosResponse): AxiosResponse => {
      const data = response.data
      return { ...response.data, data }
  }
)
./src/lib/api/posts.ts
import { AxiosPromise } from "axios"

import client from "./client"
import { PostApiJson } from "../../interfaces/index"

// post取得
export const getPosts = (): AxiosPromise<PostApiJson> => {
  return client.get("/posts")
}

// post作成
export const createPost = (data: FormData): AxiosPromise => {
  return client.post("/posts", data)
}

// post削除
export const deletePost = (id: string): AxiosPromise => {
  return client.delete(`/posts/${id}`)
}

ビュー部分を作成

./src/components/PostList.tsx
import React, { useEffect, useState } from "react"

import { Container, Grid } from "@material-ui/core"
import { makeStyles } from "@material-ui/core/styles"

import PostForm from "./PostForm"
import PostItem from "./PostItem"

import { getPosts } from "../../lib/api/posts"
import { Post } from "../../interfaces/index"

const useStyles = makeStyles(() => ({
  container: {
    marginTop: "3rem"
  }
}))

const PostList: React.FC = () => {
  const classes = useStyles()
  const [posts, setPosts] = useState<Post[]>([])

  const handleGetPosts = async () => {
    const { data }  = await getPosts()
    setPosts(data.posts)
  }

  useEffect(() => {
    handleGetPosts()
  }, [])

  return (
    <Container maxWidth="lg" className={classes.container}>
      <Grid container direction="row" justifyContent="center">
        <Grid item>
          <PostForm
            handleGetPosts={handleGetPosts}
          />
          { posts?.map((post: Post) => {
            return (
              <PostItem
                key={post.id}
                post={post}
                handleGetPosts={handleGetPosts}
              />
            )}
          )}
        </Grid>   
      </Grid>
    </Container>
  )
}

export default PostList
./src/components/post/PostItem.tsx
import React, { useState } from "react"

import { makeStyles } from "@material-ui/core/styles"
import Card from "@material-ui/core/Card"
import CardHeader from "@material-ui/core/CardHeader"
import CardMedia from "@material-ui/core/CardMedia"
import CardContent from "@material-ui/core/CardContent"
import CardActions from "@material-ui/core/CardActions"
import Avatar from "@material-ui/core/Avatar"
import IconButton from "@material-ui/core/IconButton"
import Typography from "@material-ui/core/Typography"
import FavoriteBorderIcon from "@material-ui/icons/FavoriteBorder"
import FavoriteIcon from "@material-ui/icons/Favorite"
import ShareIcon from "@material-ui/icons/Share"
import DeleteIcon from "@material-ui/icons/Delete"
import MoreVertIcon from "@material-ui/icons/MoreVert"

import { Post } from "../../interfaces/index"
import { deletePost } from "../../lib/api/posts"

const useStyles = makeStyles(() => ({
  card: {
    width: 320,
    marginTop: "2rem",
    transition: "all 0.3s",
    "&:hover": {
      boxShadow:
        "1px 0px 20px -1px rgba(0,0,0,0.2), 0px 0px 20px 5px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12)",
      transform: "translateY(-3px)"
    }
  },
  delete: {
    marginLeft: "auto"
  }
}))

interface PostItemProps {
  post: Post
  handleGetPosts: Function
}

const PostItem = ({ post, handleGetPosts }: PostItemProps) => {
  const classes = useStyles()
  const [like, setLike] = useState<boolean>(false)

  const handleDeletePost = async (id: string) => {
    await deletePost(id)
    .then(() => {
      handleGetPosts()
    })
  }

  return (
    <>
      <Card className={classes.card}>
        <CardHeader
          avatar={
            <Avatar>
              U
            </Avatar>
          }
          action={
            <IconButton>
              <MoreVertIcon />
            </IconButton>
          }
          title="User Name"
        />
        { post.image?.url ?
          <CardMedia
            component="img"
            src={post.image.url}
            alt="post image"
          /> : null
        }
        <CardContent>
          <Typography variant="body2" color="textSecondary" component="span">
            { post.content.split("\n").map((content: string, index: number) => {
                return (
                  <p key={index}>{content}</p>
                )
              })
            }
          </Typography>
        </CardContent>
        <CardActions disableSpacing>
          <IconButton onClick={() => like ? setLike(false) : setLike(true)}>
            { like ? <FavoriteIcon /> : <FavoriteBorderIcon /> }
          </IconButton>
          <IconButton>
            <ShareIcon />
          </IconButton>
          <div className={classes.delete}>
            <IconButton
              onClick={() => handleDeletePost(post.id)}
            >
              <DeleteIcon />
            </IconButton>
          </div>
        </CardActions>
      </Card>
    </>
  )
}

export default PostItem
./src/components/post/PostForm.tsx
import React, { useCallback, useState } from "react"

import { experimentalStyled as styled } from '@material-ui/core/styles';
import { makeStyles, Theme } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import Button from "@material-ui/core/Button"
import Box from "@material-ui/core/Box"
import IconButton from "@material-ui/core/IconButton"
import PhotoCameraIcon from "@material-ui/icons/PhotoCamera"
import CancelIcon from "@material-ui/icons/Cancel"

import { createPost } from "../../lib/api/posts"

const useStyles = makeStyles((theme: Theme) => ({
  form: {
    display: "flex",
    flexWrap: "wrap",
    width: 320
  },
  inputFileBtn: {
    marginTop: "10px"
  },
  submitBtn: {
    marginTop: "10px",
    marginLeft: "auto"
  },
  box: {
    margin: "2rem 0 4rem",
    width: 320
  },
  preview: {
    width: "100%"
  }
}))

const Input = styled("input")({
  display: "none"
})

const borderStyles = {
  bgcolor: "background.paper",
  border: 1,
}

interface PostFormProps {
  handleGetPosts: Function
}

const PostForm = ({ handleGetPosts }: PostFormProps) => {
  const classes = useStyles()

  const [content, setContent] = useState<string>("")
  const [image, setImage] = useState<File>()
  const [preview, setPreview] = useState<string>("")

  const uploadImage = useCallback((e) => {
    const file = e.target.files[0]
    setImage(file)
  }, [])

  // プレビュー機能
  const previewImage = useCallback((e) => {
    const file = e.target.files[0]
    setPreview(window.URL.createObjectURL(file))
  }, [])

  // FormData形式でデータを作成
  const createFormData = (): FormData => {
    const formData = new FormData()

    formData.append("content", content)
    if (image) formData.append("image", image)

    return formData
  }

  const handleCreatePost  = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()

    const data = createFormData()

    await createPost(data)
    .then(() => {
      setContent("")
      setPreview("")
      setImage(undefined)
      handleGetPosts()
    })
  }

  return (
    <>
      <form className={classes.form} noValidate onSubmit={handleCreatePost}>
        <TextField
          placeholder="Hello World"
          variant="outlined"
          multiline
          fullWidth
          rows="4"
          value={content}
          onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
            setContent(e.target.value)
          }}
        />
        <div className={classes.inputFileBtn}>
          <label htmlFor="icon-button-file">
            <Input
              accept="image/*"
              id="icon-button-file" 
              type="file"
              onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
                uploadImage(e)
                previewImage(e)
              }}
            />
            <IconButton color="inherit" component="span">
              <PhotoCameraIcon />
            </IconButton>
          </label>
        </div>
        <div className={classes.submitBtn}>
          <Button
            type="submit"
            variant="contained"
            size="large"
            color="inherit"
            disabled={!content || content.length > 140}
            className={classes.submitBtn}
          >
            Post
          </Button>
        </div>
      </form>
      { preview ?
        <Box
          sx={{ ...borderStyles, borderRadius: 1, borderColor: "grey.400" }}
          className={classes.box}
        >
          <IconButton
            color="inherit"
            onClick={() => setPreview("")}
          >
            <CancelIcon />
          </IconButton>
          <img
            src={preview}
            alt="preview img"
            className={classes.preview}
          />
        </Box> : null
      }
    </>
  )
}

export default PostForm
./src/App.tsx
import React from "react"
import { BrowserRouter as Router, Switch, Route } from "react-router-dom"

import PostList from "./components/post/PostList"

const App: React.FC = () => {
  return (
    <Router>
      <Switch>
        <Route exact path="/" component={PostList} />
      </Switch>
    </Router>
  )
}

export default App

スクリーンショット 2021-05-04 4.34.55.png

http://localhost:3000 にアクセスしてこんな感じになっていればOKです。

投稿を作成できるか、画像プレビュー機能が動いているか、投稿を削除できるかなど一通り確認してください。

あとがき

お疲れ様でした。もし手順通りに進めて不具合などありましたらコメント欄にて指摘していただけると幸いです。

今回作成したコード

43
38
1

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
43
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?