Railsでのpreflightリクエストの実装およびテスト例

はじめに

Railsでpreflightリクエストの処理を実装する機会があったのでまとめてみました。

preflightリクエストとは

CORS(Cross-Origin Resource Sharing)という、originをまたいだAjax通信の際に発生するリクエストのことです。
発生条件など、詳しくは以下のサイトで述べられているので、このエントリでは詳しく解説しません。

実装例

RailsJSON形式のAPIを作成しているとして、/api以下のアクセスに対して異なるoriginからのアクセスを許可したいとします。

Controller

class Api::BaseController < ApplicationController
  before_action :validate_http_origin

  private

  def validate_http_origin
    request_origin = request.headers['HTTP_ORIGIN']
    return if request_origin.blank?   # 通常のリクエストであれば何もしない

    if request_origin == 'http://localhost:3000'
      response.headers['Access-Control-Allow-Origin'] = request_origin
    else
      render nothing: true, status: :not_acceptable
    end
  end
end
class Api::OptionsRequestController < Api::BaseController
  ACCESS_CONTROL_ALLOW_METHODS = %w(GET OPTIONS).freeze
  ACCESS_CONTROL_ALLOW_HEADERS = %w(Accept Origin Content-Type).freeze
  ACCESS_CONTROL_MAX_AGE = 86400

  def preflight
    return render nothing: true, status: :not_found if request.headers['HTTP_ORIGIN'].blank?
    return render nothing: true, status: :not_acceptable unless valid_request?

    set_preflight_headers!
    render nothing: true
  end

  private

  def valid_request?
    request_method = request.headers['Access-Control-Request-Method']
    request_headers = request.headers['Access-Control-Request-Headers'].to_s.split(/,\s*/)

    allow_headers = Regexp.new(Regexp.union(ACCESS_CONTROL_ALLOW_HEADERS).source, Regexp::IGNORECASE)

    request_method.in?(ACCESS_CONTROL_ALLOW_METHODS) && request_headers.present? &&
      request_headers.all? { |header| header.downcase =~ allow_headers }
  end

  def set_preflight_headers!
    response.headers['Access-Control-Max-Age'] = ACCESS_CONTROL_MAX_AGE
    response.headers['Access-Control-Allow-Headers'] = ACCESS_CONTROL_ALLOW_HEADERS.join(',')
    response.headers['Access-Control-Allow-Methods'] = ACCESS_CONTROL_ALLOW_METHODS.join(',')
  end
end

config/routes.rb

Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    match '*path' => 'options_request#preflight', via: :options
  end
end

Test (RSpec)

require 'rails_helper'

RSpec.describe Api::OptionsRequestController, type: :controller do
  describe '#preflight' do
    before do
      request.headers['HTTP_ORIGIN'] = http_origin
      request.headers['Access-Control-Request-Method'] = request_method
      request.headers['Access-Control-Request-Headers'] = request_headers

      process :preflight, 'OPTIONS', path: :api
    end

    context 'HTTP_ORIGINヘッダーがない通常のリクエストの場合' do
      let(:http_origin) { nil }
      let(:request_method) { 'GET' }
      let(:request_headers) { 'accept, origin, content-type' }

      describe 'response' do
        subject { response }

        its(:status) { is_expected.to eq 404 }
        its(:body) { is_expected.to be_blank }
      end
    end

    context '許可されていないメソッドをリクエストした場合' do
      let(:http_origin) { 'http://localhost:3000' }
      let(:request_method) { 'POST' }
      let(:request_headers) { 'accept, origin, content-type' }

      describe 'response' do
        subject { response }

        its(:status) { is_expected.to eq 406 }
        its(:body) { is_expected.to be_blank }
      end
    end

    context '許可されていないヘッダーをリクエストした場合' do
      let(:http_origin) { 'http://localhost:3000' }
      let(:request_method) { 'GET' }
      let(:request_headers) { 'hoge' }

      describe 'response' do
        subject { response }

        its(:status) { is_expected.to eq 406 }
        its(:body) { is_expected.to be_blank }
      end
    end

    context '正常なリクエストの場合' do
      let(:http_origin) { 'http://localhost:3000' }
      let(:request_method) { 'GET' }
      let(:request_headers) { 'accept, origin, content-type' }

      describe 'response' do
        subject { response }

        its(:status) { is_expected.to eq 200 }
        its(:body) { is_expected.to be_blank }
      end

      describe 'headers' do
        subject { response.headers }

        its(['Access-Control-Max-Age']) { is_expected.to eq 86400 }
        its(['Access-Control-Allow-Headers']) { is_expected.to eq 'Accept,Origin,Content-Type' }
        its(['Access-Control-Allow-Methods']) { is_expected.to eq 'GET,OPTIONS' }
      end
    end
  end
end

おわりに

RSpecでテストケースを書くときは、rspec -f dで実行した時の出力が読みやすいように心がけています。
こうすると、あとで仕様との突き合わせが楽なのでオススメです。