🪂

Ruby on Rails へ Fetch API で非同期通信を行う

2021/03/26に公開

Turbo とか使えばわざわざ自前で Fetch API 使って非同期通信実装することなんてあんまりないのかなと思ったのですが書きました

TL;DR

この記事でやること、やらないこと

  • この記事でやること
    • Rails サーバーに JavaScript の Fetch API を使って非同期通信で GET / POST
    • Rails の CSRF対策用 Token を使った POST
    • POST の Request Body に JSON を指定して Rails サーバー側で受け取った値を確認する方法
  • この記事でやらないこと
    • HTTP Request についての詳しい話
      • GET とは何か / POST とは何か / Request Body とは / Request Header とは
    • Fetch API についての詳しい話
      • Promise の使い方 / 書き方など
    • Rails を API モードとして使って外部 HTML から API を叩けるようにする方法
    • GET / POST 以外の非同期通信
    • XMLHTTPRequest を使った非同期通信

GET/POST Method を受け付けるAPIを作成

GET と POST をそれぞれ受け付けるコントローラーを作っても良いのですが
今回は GET と POST 両方を受け付けるコントローラーを用意しましょう。
どこに用意しても良いのですが今回は home#get_post に用意します。

app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
  end

  def get_post
  end
end

config/routes.rb にこんな感じで書くことで
http://localhost:3000/get_post で GET も POST も受け付けることができます。

config/routes.rb
Rails.application.routes.draw do
  root to: 'home#index' # これは home#index を root に割り当てる定義
  match 'get_post', to: 'home#get_post', via: [:get, :post]
end

で、ブラウザからアクセスして GET した時用にviewも用意しておきましょう。

app/views/home/get_post.html.erb
<h1>Home#get_post</h1>
<p>Find me in app/views/home/get_post.html.erb</p>

OKですね。試しにブラウザからアクセスしてみましょう。

いいですね。

Fetch API で GET Request してみる

home#index に JavaScript を追加して
http://localhost:3000/get_post に Fetch API で GET Request してみます。

app/views/home/index.html.erb
<h1>Home#index</h1>
<p>Find me in app/views/home/index.html.erb</p>
<button type="button" id="button_get">Fetch API : GET</button>
<script>
  // -------------------------------------------
  // button: Fetch API : GET
  // -------------------------------------------
  const button_get = document.getElementById('button_get');
  button_get.addEventListener('click',function(){
    fetch('/get_post', {
      method: 'GET'
    })
    .then(function(response){
      const response_message = response.status + ':' + response.statusText
      console.log(response_message);
      window.alert('response_message=' + response_message);
    });
  });
</script>

ボタンの onClick イベントで Fetch API を実行しているだけです。
Fetch API は method を省略して実行することで default GET となりますが
ここではわかりやすさのために method: 'GET' を明示しています。

Fetch API が成功したら response オブジェクトの情報を alert で表示しています。
丁寧にやるのであれば response が成功したかどうかを response.ok でチェックしたり
ネットワークエラーで Fetch API そのものの失敗を catch したりの記述も必要ですが
今回は省略しています。

Fetch API で POST Request してみる

GET ができたので POST もしてみましょう。

まずは同じ感じで書いてみます。

app/views/home/index.html.erb
<h1>Home#index</h1>
<p>Find me in app/views/home/index.html.erb</p>
<button type="button" id="button_get">Fetch API : GET</button> | 
<button type="button" id="button_post">Fetch API : POST</button>
<script>
  // -------------------------------------------
  // button: Fetch API : GET
  // -------------------------------------------
  const button_get = document.getElementById('button_get');
  button_get.addEventListener('click',function(){
    fetch('/get_post', {
      method: 'GET'
    })
    .then(function(response){
      const response_message = response.status + ':' + response.statusText
      console.log(response_message);
      window.alert('response_message=' + response_message);
    });
  });
  
  // -------------------------------------------
  // button: Fetch API : POST
  // -------------------------------------------
  const button_post = document.getElementById('button_post');
  button_post.addEventListener('click',function(){
    fetch('/get_post', {
      method: 'POST'
    })
    .then(function(response){
      const response_message = response.status + ':' + response.statusText
      console.log(response_message);
      window.alert('response_message=' + response_message);
    });
  });
</script>

……あらら、HTTP 422 Unprocessable Entity ERROR になってますね。

これは、Rails の default の仕様で、
app/views/layouts/application.html.erb に最初から書かれている
<%= csrf_meta_tags %> などによって
GET以外のあらゆる非同期通信Requestでは正しい X-CSRF-Token を Request Header に含めないと
サーバー側は Request を弾くようにしているためです。
クロスサイトリクエストフォージェリ(CSRF)というサイバー攻撃対策用のTokenを用いた仕組みですね。

Request Header に X-CSRF-Token を含めるようにする

ではどうやって POST Request を送れるようにするのかというと、
app/views/layouts/application.html.erb にある <%= csrf_meta_tags %>
すなわちレンダリングされたページの <head> タグ内にある
<meta name="csrf-token" content="XXXXXXX"> を読み取って
POST Request の Request Header に含めてやります。

また、Google で検索すれば色々とやり方には辿り着けますね。

CSRF-TOKEN で session の確認もできるので、
例えば devise でログイン機能を作ってあった時でもログイン済みのPOSTとして機能するようになります。

というわけで実装してみましょう。

app/views/home/index.html.erb
<h1>Home#index</h1>
<p>Find me in app/views/home/index.html.erb</p>

<button type="button" id="button_get">Fetch API : GET</button> | 
<button type="button" id="button_post">Fetch API : POST</button>

<script>
  // -------------------------------------------
  // button: Fetch API : GET
  // -------------------------------------------
  const button_get = document.getElementById('button_get');
  button_get.addEventListener('click',function(){
    fetch('/get_post', {
      method: 'GET'
    })
    .then(function(response){
      const response_message = response.status + ':' + response.statusText
      console.log(response_message);
      window.alert('response_message=' + response_message);
    });
  });

  // -------------------------------------------
  // button: Fetch API : POST
  // -------------------------------------------
  const button_post = document.getElementById('button_post');
  button_post.addEventListener('click',function(){
    fetch('/get_post', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
                'X-CSRF-Token': getCsrfToken()
      },
    })
    .then(function(response){
      const response_message = response.status + ':' + response.statusText
      console.log(response_message);
      window.alert('response_message=' + response_message);
    });
  });

  // -------------------------------------------
  // app/views/layouts/application.html.erb に csrf_meta_tags で設定されている csrf-token' の取得
  // -------------------------------------------
  const getCsrfToken = () => {
      const metas = document.getElementsByTagName('meta');
      for (let meta of metas) {
          if (meta.getAttribute('name') === 'csrf-token') {
              console.log('csrf-token:', meta.getAttribute('content'));
              return meta.getAttribute('content');
          }
      }
      return '';
  }
</script>

レンダリングされたページの <head> タグ内にある
<meta name="csrf-token" content="XXXXXXX"> を読み取るメソッドを作成し、
POST Request を行う Fetch API に以下を追記しました。

credentials: 'same-origin',
headers: {
	'X-CSRF-Token': getCsrfToken()
},

POST が 200 OK になりましたね!

JSON 形式で Request Body を送ってみる

せっかく POST できたので Request Body も送ってみましょう。
まずはJavaScript オブジェクトをハードコーディングで記述して Request Body に指定してみます。
JavaScript オブジェクトを JSON.stringify するだけですね。
Request Header に 'Content-Type': 'application/json' を追記するのも忘れないようにしましょう。
こんな感じです。

app/views/home/index.html.erb
<h1>Home#index</h1>
<p>Find me in app/views/home/index.html.erb</p>

<button type="button" id="button_get">Fetch API : GET</button> | 
<button type="button" id="button_post">Fetch API : POST</button>

<script>
  // -------------------------------------------
  // button: Fetch API : GET
  // -------------------------------------------
  const button_get = document.getElementById('button_get');
  button_get.addEventListener('click',function(){
    fetch('/get_post', {
      method: 'GET'
    })
    .then(function(response){
      const response_message = response.status + ':' + response.statusText
      console.log(response_message);
      window.alert('response_message=' + response_message);
    });
  });

  // -------------------------------------------
  // button: Fetch API : POST
  // -------------------------------------------
  const button_post = document.getElementById('button_post');
  button_post.addEventListener('click',function(){
    const post_data = {
      key1: "data1",
      key2: "data2",
      key3: 3
    };

    fetch('/get_post', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
                'Content-Type': 'application/json',
                'X-CSRF-Token': getCsrfToken()
      },
      body: JSON.stringify(post_data),
    })
    .then(function(response){
      const response_message = response.status + ':' + response.statusText
      console.log(response_message);
      window.alert('response_message=' + response_message);
    });
  });

  // -------------------------------------------
  // app/views/layouts/application.html.erb に csrf_meta_tags で設定されている csrf-token' の取得
  // -------------------------------------------
  const getCsrfToken = () => {
      const metas = document.getElementsByTagName('meta');
      for (let meta of metas) {
          if (meta.getAttribute('name') === 'csrf-token') {
              console.log('csrf-token:', meta.getAttribute('content'));
              return meta.getAttribute('content');
          }
      }
      return '';
  }
</script>

POST されたデータがちゃんとRails側で受け取れているかは
Controller 側で params を確認すれば良いです。
binding.pry して params を確認してやるのも良いですね。

app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
  end

  def get_post
    # binding.pry
    pp params
  end
end

テキストボックスの値を POST する

ここまでくるともう Rails + Fetch API の範疇を超えてきますが、
せっかくなのでよくやりたいことである 画面上のテキストボックスの値をPOSTするのも
やってみましょう。

特に難しいことはないですね。
JavaScript オブジェクトを動的に作成するには
data[key] = hoge の形で指定してやればいいってことぐらいです。
(もちろん、key が固定値で良いなら data["Fuga"] = hoge でもOKです)

app/views/home/index.html.erb
<h1>Home#index</h1>
<p>Find me in app/views/home/index.html.erb</p>

<input type="text" id="input_text" name="name_input_text" maxlength="8" size="10">
<br>
<button type="button" id="button_get">Fetch API : GET</button> | 
<button type="button" id="button_post">Fetch API : POST</button>

<script>
  // -------------------------------------------
  // button: Fetch API : GET
  // -------------------------------------------
  const button_get = document.getElementById('button_get');
  button_get.addEventListener('click',function(){
    fetch('/get_post', {
      method: 'GET'
    })
    .then(function(response){
      const response_message = response.status + ':' + response.statusText
      console.log(response_message);
      window.alert('response_message=' + response_message);
    });
  });

  // -------------------------------------------
  // button: Fetch API : POST
  // -------------------------------------------
  const button_post = document.getElementById('button_post');
  button_post.addEventListener('click',function(){
    const post_data = {
      key1: "data1",
      key2: "data2",
      key3: 3
    };

    const input_text = document.getElementById('input_text');
    post_data[input_text.name] = input_text.value;

    fetch('/get_post', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
                'Content-Type': 'application/json',
                'X-CSRF-Token': getCsrfToken()
      },
      body: JSON.stringify(post_data),
    })
    .then(function(response){
      const response_message = response.status + ':' + response.statusText
      console.log(response_message);
      window.alert('response_message=' + response_message);
    });
  });

  // -------------------------------------------
  // app/views/layouts/application.html.erb に csrf_meta_tags で設定されている csrf-token' の取得
  // -------------------------------------------
  const getCsrfToken = () => {
      const metas = document.getElementsByTagName('meta');
      for (let meta of metas) {
          if (meta.getAttribute('name') === 'csrf-token') {
              console.log('csrf-token:', meta.getAttribute('content'));
              return meta.getAttribute('content');
          }
      }
      return '';
  }
</script>

Rails 側のログをみると……

Started POST "/get_post" for XXX.XXX.XXX.XX at 2021-03-25 00:00:00
Processing by HomeController#get_post as */*
  Parameters: {"key1"=>"data1", "key2"=>"data2", "key3"=>3, "name_input_text"=>"ttt", "home"=>{"key1"=>"data1", "key2"=>"data2", "key3"=>3, "name_input_text"=>"ttt"}}
#<ActionController::Parameters {"key1"=>"data1", "key2"=>"data2", "key3"=>3, "name_input_text"=>"ttt", "controller"=>"home", "action"=>"get_post", "home"=>{"key1"=>"data1", "key2"=>"data2", "key3"=>3, "name_input_text"=>"ttt"}} permitted: false>
"get_post"
  Rendering layout layouts/application.html.erb
  Rendering home/get_post.html.erb within layouts/application
  Rendered home/get_post.html.erb within layouts/application (Duration: 0.2ms | Allocations: 33)
  Rendered layout layouts/application.html.erb (Duration: 4.6ms | Allocations: 155)
Completed 200 OK in 9ms (Views: 7.9ms | Allocations: 639)

OK ですね! ちゃんと送信されてきています。
というか別に pp params しなくてもパラメータはログに出ていますね……笑

テキストボックスの値をまとめて取得してみる

これも Rails + Fetch API の範疇を超えてきますが、
テキストボックスの値をまとめて取得してPOSTしてみましょう。
document.querySelectorAll を使ってみたかっただけです。

こんな感じの画面で、

ここを

{
  keywords: [
    {keyword: "aaa"},
    {keyword: "bbb"},
    {keyword: "ccc"},
  ]
}

こんな感じの JSON として送信して Rails 側で受け取ってみようと思います。

app/views/home/index.html.erb
<h1>Home#index</h1>
<p>Find me in app/views/home/index.html.erb</p>

input_text: <input type="text" id="input_text" name="name_input_text" maxlength="8" size="10">
<br>
keywords: <input type="text" group='keyword' maxlength="8" size="10">
<input type="text" group='keyword' maxlength="8" size="10">
<input type="text" group='keyword' maxlength="8" size="10">
<br>
<button type="button" id="button_get">Fetch API : GET</button> | 
<button type="button" id="button_post">Fetch API : POST</button>

<script>
  // -------------------------------------------
  // button: Fetch API : GET
  // -------------------------------------------
  const button_get = document.getElementById('button_get');
  button_get.addEventListener('click',function(){
    fetch('/get_post', {
      method: 'GET'
    })
    .then(function(response){
      const response_message = response.status + ':' + response.statusText
      console.log(response_message);
      window.alert('response_message=' + response_message);
    });
  });

  // -------------------------------------------
  // button: Fetch API : POST
  // -------------------------------------------
  const button_post = document.getElementById('button_post');
  button_post.addEventListener('click',function(){
    // 固定値
    const post_data = {
      key1: "data1",
      key2: "data2",
      key3: 3
    };

    // id で取得
    const input_text = document.getElementById('input_text');
    post_data[input_text.name] = input_text.value;

    // attribute で取得
    const keyword_nodes = document.querySelectorAll("input[group='keyword']");
    const keyword_data = [];
    for (let keyword_node of keyword_nodes) {
      const keyword = {keyword: keyword_node.value};
      keyword_data.push(keyword);
    }
    post_data['keywords'] = keyword_data;

    fetch('/get_post', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
                'Content-Type': 'application/json',
                'X-CSRF-Token': getCsrfToken()
      },
      body: JSON.stringify(post_data),
    })
    .then(function(response){
      const response_message = response.status + ':' + response.statusText
      console.log(response_message);
      window.alert('response_message=' + response_message);
    });
  });

  // -------------------------------------------
  // app/views/layouts/application.html.erb に csrf_meta_tags で設定されている csrf-token' の取得
  // -------------------------------------------
  const getCsrfToken = () => {
      const metas = document.getElementsByTagName('meta');
      for (let meta of metas) {
          if (meta.getAttribute('name') === 'csrf-token') {
              console.log('csrf-token:', meta.getAttribute('content'));
              return meta.getAttribute('content');
          }
      }
      return '';
  }
</script>
app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
  end

  def get_post
    # String Parameters
    permit_parameters = params.permit(:key1, :key2, :key3, :name_input_text, keywords: [:keyword]).to_h

    pp permit_parameters[:key1]
    pp permit_parameters[:key2]
    pp permit_parameters[:key3]
    pp permit_parameters[:name_input_text]
    permit_parameters[:keywords].each do |keyword|
      pp keyword[:keyword]
    end
  end
end

こんな感じですね。

POST すると、Rails 側の出力はこんな感じです。

  Parameters: {"key1"=>"data1", "key2"=>"data2", "key3"=>3, "name_input_text"=>"111", "keywords"=>[{"keyword"=>"aaa"}, {"keyword"=>"bbb"}, {"keyword"=>"ccc"}], "home"=>{"key1"=>"data1", "key2"=>"data2", "key3"=>3, "name_input_text"=>"111", "keywords"=>[{"keyword"=>"aaa"}, {"keyword"=>"bbb"}, {"keyword"=>"ccc"}]}}
Unpermitted parameter: :home
"data1"
"data2"
3
"111"
"aaa"
"bbb"
"ccc"

JavaScript としてのポイントとしてはこの辺り、

Rails 側のポイントとしては StrongParameters を通してハッシュ化することで
受け取った params をコントローラー側で扱えるようにするあたりがポイントとなります。

以上です。おつかれさまでした。
今回のコードは以下リポジトリに含めています。
https://github.com/JUNKI555/rails_activejob_practice01

Discussion