Rails: Missing template エラーの扱い

Ruby on Rails のバージョン: 5.0, 5.1, 5.2

## 問題

アプリケーションサーバーに例外を検出して通知する仕組みを入れていると、ときどき余計な拡張子を付けたリクエストが来て、Missing template 関連のエラーが通知されます。重大なバグでもないのに、いちいち通知されるのをなんとかしたい。

## とりあえずの結論

対策はあるけど、あまりすっきりしない。

## Missing template 関連エラーの種類

### 0. エラーが起きない場合

viewsの下のテンプレートをまったく使わない場合は、エラーは出ません。

````
def show
  render json: { message: @post.message }
end
````

### 1. views の下にフォーマットに対応したテンプレートがない

`/posts/1.png` を要求されて、`views/posts/show.png` がない場合は、 `ActionController::UnknownFormat` ( ... `is missing a template for this request format and variant` ... ) となります。 

ただし、`/posts/1.aaa` のようにRailsのMIMEリストにない拡張子の場合は、HTMLでレンダリングされます。

### 2. render メソッドを呼んだときに対応したテンプレートがない

コントローラのアクション内で `render` メソッドを使っている場合は、`/posts/1.png` を要求されたときに、1.とは違う例外 `ActionView::MissingTemplate` が出ます。

````
def show
  render 'another'
end
````

### 3. respond_to を使っているときに対応したテンプレートがない

`respond_to` のブロック中にないフォーマットのリクエストが来たときは、`ActionController::UnknownFormat` が発生します。

````
respond_to do |format|
  format.html
  format.json
end
````

## Railsのデフォルトとrescue_from

Railsのproduction環境では、`ActionController::UnknownFormat` 例外が出た場合は、デフォルトで406 (Not Acceptable)を返します。JSONの場合は、ボディは `{"status":406,"error":"Not Acceptable"}`、それ以外はボディが空になります。

しかし、次のように `rescue_from` を書いていると、URLに拡張子を付けただけで通知が飛ぶ、ということになります。

```
if Rails.env.production?
  rescue_from Exception, with: :rescue500
  rescue_from ActionController::RoutingError, with: :rescue404
  rescue_from ActiveRecord::RecordNotFound, with: :rescue404
end

def rescue500(e)
  # ここでなんか通知飛ばす...
  render file: 'errors/internal_server_error.html',
    content_type: 'text/html',
    status: :internal_server_error, layout: 'error'
end
```

## 対策1. rescue_from で406返す

`rescue_from` に `ActionController::UnknownFormat` と `ActionView::MissingTemplate` を加えれば、Railsのデフォルトと似たようなことができます。

```
if Rails.env.production?
  rescue_from Exception, with: :rescue500
  rescue_from ActionController::UnknownFormat, with: :rescue406
  rescue_from ActionView::MissingTemplate, with: :rescue406
  rescue_from ActionController::RoutingError, with: :rescue404
  rescue_from ActiveRecord::RecordNotFound, with: :rescue404
end

def rescue406(e)
  respond_to do |format|
    format.json {
      render json: { status: 406, error: "Not Acceptable" }, status: :not_acceptable
    }
    format.all {
      render body: nil, content_type: 'text/html', status: :not_acceptable
    }
  end
end

```

ただし、この方法だとコントローラであれこれ処理をしたあとで406を返すことになるので、何だか無駄な感じがします。即座に404を返したほうがいいような気もします。

## 対策2. routes.rb で constraints を使う

routes.rb で `constraints` オプションを指定すれば、想定外のフォーマットをルーティングエラーにできます。

````
resources :posts, constraints: { format: 'html' }
````

`constraints` は、`get` や `namespace` 、`scope` にも付けられます。また、`constraints` メソッドでルーティングの設定を囲むこともできます。

「indexアクションではHTMLとJSONを返し、それ以外はHTMLだけ返す」みたいな場合はどうしましょう。 `constraints do` で囲んで `resources` を2回書けばいいですかね。

````
constraints format: 'html' do
  resources :posts
  resources :notifications
end

constraints format: 'json'
  resources :posts, only: %i(index)
end
````

既存のアプリケーションのroutes.rbに `constraints` を加えるのは、かなり面倒くさいです。対策1だけでいいんじゃないかなあ。

## おまけ：エラーハンドラを書くときの注意

`rescue_from` で指定したメソッド内でもMissing template エラーが出ることがあります。次はよくない例です。HTML以外のフォーマットだとエラーハンドラ内でさらにエラーが出ます。

```
def rescue404(e)
  render 'errors/not_found', status: :not_found, layout: 'error'
end
```

何が来てもHTMLで404を返すには、次のようにします。

```
def rescue404(e)
  render file: 'errors/not_found.html',
    content_type: 'text/html', status: :not_found,
    layout: 'error'
end
```

次の例は、JSONのときはJSONで404を返し、それ以外はHTMLで返します。`format.html`ではなく `format.all` を使います。

```
def rescue404(e)
  respond_to do |format|
    format.json { render json: {}, status: :not_found }
    format.all { render file: 'errors/not_found.html',
      content_type: 'text/html',
      status: :not_found, layout: 'error' }
  end
end
```