[WB//Rails4]

Odpowiedzi zależne od nagłówka MIME

While the scaffold generator is great for prototyping, it’s not so great for delivering simple code that is well-tested and works precisely the way we would like it to.

— Y. Katz, R. A. Bigg

Poniższe polecenie:

rails generate scaffold fortune \
    quotation:text source:string

generuje kod dla zasobu (ang. resource) fortune:

app/controllers/fortunes_controller.rb
class FortunesController < ApplicationController
  before_action :set_fortune, only: [:show, :edit, :update, :destroy]
  # GET /fortunes
  # GET /fortunes.json
  def index
    @fortunes = Fortune.all
  end
  # GET /fortunes/1
  # GET /fortunes/1.json
  def show
  end
  # GET /fortunes/new
  def new
    @fortune = Fortune.new
  end
  # GET /fortunes/1/edit
  def edit
  end
  # POST /fortunes
  # POST /fortunes.json
  def create
    @fortune = Fortune.new(fortune_params)

    respond_to do |format|
      if @fortune.save
        format.html { redirect_to @fortune, notice: 'Fortune was successfully created.' }
        format.json { render action: 'show', status: :created, location: @fortune }
      else
        format.html { render action: 'new' }
        format.json { render json: @fortune.errors, status: :unprocessable_entity }
      end
    end
  end
  # PATCH/PUT /fortunes/1
  # PATCH/PUT /fortunes/1.json
  def update
    respond_to do |format|
      if @fortune.update(fortune_params)
        format.html { redirect_to @fortune, notice: 'Fortune was successfully updated.' }
        format.json { head :no_content }
      else
        format.html { render action: 'edit' }
        format.json { render json: @fortune.errors, status: :unprocessable_entity }
      end
    end
  end
  # DELETE /fortunes/1
  # DELETE /fortunes/1.json
  def destroy
    @fortune.destroy
    respond_to do |format|
      format.html { redirect_to fortunes_url }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_fortune
      @fortune = Fortune.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def fortune_params
      params.require(:fortune).permit(:quotation, :source)
    end
end

Jak widać, metody wygenrowanego kontrolera potrafią zwrócić odpowiedź w różnych formatach, HTML i JSON:

tylko HTML:

Odpowiedź zależy od nagłówka MIME (tak naprawdę od nagłówka Accept) przekazanego w żądaniu HTTP lub od roszerzenia w adresie URL.

O kontrolerze wygenerowanym przez generator scaffold mówimy, że jest RESTfull.

Co to jest REST?

[Head First Rails]

If you use REST, your teeth will be brighter, your life will be happier, and all will be goodnes and sunshine with the world.

– David Griffiths

Krótka historia World Wide Web:

Kilka uwag o terminologii:

W aplikacjach Rails operacje CRUD wykonujemy korzystając z REST API:

  1. Dane są zasobami (ang. resources). Fortunka to zbiór cytatów, dlatego cytaty są resources.
  2. Każdy zasób ma swój unikalny URI.

Polecenie:

rake routes

wypisuje szczegóły REST API aplikacji.

Rendering response

…czyli renderowaniem odpowiedzi HTTP zajmuje się jeden wiersz kodu w bloku respond_to:

respond_to do |format|
  format.html  { redirect_to fortunes_url }
  format.json  { head :no_content }
  format.js    # use destroy.js.erb template
end

What that says is:

  1. If the client wants HTML in response to this action, redirect and use the default template for this action (for index it is index.html.erb).

  2. If the client wants JSON, return response 204
    (gem install cheat; cheat http).

  3. If the client wants JS, use the default template for this action (for destroy it is destroy.js.erb).

Rails determines the desired response format from the HTTP Accept header submitted by the client.

Klientem może być przeglądarka, ale może też być inny program, na przykład curl:

curl -I -X GET -H 'Accept: application/json' \
    localhost:3000/fortunes/1
curl -H 'Accept: application/json' \
    localhost:3000/fortunes/1

Critical Ruby On Rails Security Issue

W ostatnich wersjach Rails (zob. Critical Ruby On Rails Issue Threatens 240,000 Websites) wymagane jest przesłanie tokena CSRF (Cross Site Request Forgery).

Token CSRF jest generowany w trakcie renderowania layotu:

app/views/layouts/application.html.erb
<%= csrf_meta_tags %>

Uwaga: Rails korzysta z authenticity token tylko w żądaniach POST, PUT i DELETE.

Oznacza, to że polecenia z curl i z jednym z powyższych VERB zwrócą błąd.

Dlatego dla wygody, w trakcie poniższych eksperymentów z programem curl (lub na konsoli przeglądarki) powinniśmy wykonać jedną z trzech rzeczy:

1. Usunąć zabezpieczenie CSRF z layoutu.
Uwaga: Niestety to nie działa od 2013.04. Dostajemy komunikat:

curl -I -X DELETE localhost:3000/fortunes/1.json
HTTP/1.1 422 Unprocessable Entity

Co to oznacza?

2. Dodać ten kod do kodu kontrolera ApplicationController:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  skip_before_action :verify_authenticity_token, if: :json_request?

  protected
  def json_request?
    request.format.json?
  end
end

Teraz poniższe polecenia powinny wykonać się bez błędów:

curl    -X DELETE \
   -H 'Accept: application/json'\
   localhost:3000/fortunes/1
curl -I -X DELETE \
   localhost:3000/fortunes/1.json
# mało eleganckie; ale też działa
curl -I -X DELETE \
   localhost:3000/fortunes/1

curl -v -X POST \
  --data-urlencode "fortune[quotation]=I hear and I forget" \
  --data-urlencode "fortune[source]=unknown" \
  localhost:3000/fortunes.json
curl    -X POST \
  -H 'Content-Type: application/json' \
  --data-urlencode "fortune[quotation]=I hear and I forget." \
  --data-urlencode "fortune[source]=unknown" \
  localhost:3000/fortunes

W powyższych poleceniach zamiast --data-urlencode można użyć --data, lub -d:

curl -v -X POST \
  -d "fortune[quotation]=I hear and I forget" \
  -d "fortune[source]=unknown" \
  localhost:3000/fortunes.json

Nie musimy nic zmieniać w kodzie aplikacji, aby zostały wykonane powyższe polecenia. Możemy je wykonać wysyłając z konsoli odpowiednio przygotowane żądanie AJAX. Na przykład, tak usuniemy rekord z id=6:

$.ajax({
  url: 'http://localhost:3000/fortunes/6.json',
  type: 'DELETE',
  headers: {
    'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
  }
})

(W kodzie powyżej korzystam z jQuery).

Ten sposób ma tę wadę, że musimy być na stronie aplikacji. Inaczej nie odszukamy "csrf-token". Tej wady nie ma poniższy, choć dwuetapowy, sposób.

3. Pobieramy ciasteczko oraz odfiltrowujemy token CSRF:

# zapisujemy cookie do pliku *cookie*
curl -s localhost:3000/fortunes --cookie-jar cookie  | grep csrf

Kopiujemy token CSRF do polecenia poniżej, na przykład:

curl -X DELETE -H 'X-CSRF-Token: 0Dm3mqmcWcajzHkSAzqczDnLllmhlVVaNYB5Fo1tYA0=' \
  --cookie cookie localhost:3000/fortunes/10.json

Ponieważ nie było przekierowania, możemy ponownie użyć tego samego tokenu do usunięcia kolejnego rekordu:

curl -X DELETE -H 'X-CSRF-Token: 0Dm3mqmcWcajzHkSAzqczDnLllmhlVVaNYB5Fo1tYA0=' \
  --cookie cookie localhost:3000/fortunes/11.json

Dodatkowa lektura:

Linki do dokumentacji:

Odpowiedź CSV generowana przez kontroler

W pliku fortunes_controller.rb podmieniamy kod metody index na:

app/controllers/fortunes_controller.rb
def index
  @fortunes = Fortune.all
  respond_to do |format|
    format.html
    format.csv { send_data @fortunes.to_csv, filename: "fortunes-#{Date.today}.csv" }
  end
end

Dopisujemy w pliku application.rb:

config/application.rb
require File.expand_path('../boot', __FILE__)
require 'csv'        #<= NEW!

W kodzie modelu Fortune dodajemy metodę to_csv:

app/models/fortune.rb
def self.to_csv
  CSV.generate do |csv|
    csv << column_names
    all.each do |fortune|
      csv << fortune.attributes.values_at(*column_names)
    end
  end
end

Uwaga: W metodzie to_csv możemy podać nazwy kolumn, na przykład:

def self.to_csv
  CSV.generate do |csv|
    csv << ["last_name", "first_name"]
    all.each do |list|
      csv << list.attributes.values_at("last_name", "first_name")
    end
  end
end

Sprawdzamy jak to działa:

curl http://localhost:3000/fortunes.csv

Możemy też dodać na stronie index.html.erb link:

<p>Pobierz:
  <%= link_to "Export to CSV", fortunes_path(format: "csv") %> |
</p>

i kliknąć go.

Zobacz też:

Odpowiedź CSV z szablonu index.csv.ruby

W pliku fortunes_controller.rb podmieniamy kod metody index na:

def index
  @fortunes = Fortune.all

  respond_to do |format|
    format.html
    format.csv
  end
end

i w pliki index.csv.ruby wpisujemy:

app/views/lists/index.csv.ruby
"hello CSV world"

Teraz sprawdzamy czy aplikacja użyje tego pliku:

curl localhost:3000/fortunes.csv

Jeśli na konsoli zostanie wypisany napis hello world będzie to oznaczać że kod zadziałał.

Teraz podmieniamy zawartość pliku index.csv.ruby na:

response.headers["Content-Disposition"] = "attachment; filename='fortunes-#{Date.today}.csv'"

CSV.generate do |csv|
  csv << ["id", "quotation", "source"]
  @fortunes.each do |fortune|
    csv << [
      fortune.id,
      fortune.quotation,
      fortune.source
    ]
  end
end

I jeszcze raz sprawdzamy czy to działa.

Markdown via Ruby Template Handler

Zdefiniujemy swój program obsługi plików w formacie Markdown. Powiążemy go z rozszerzeniami .md.markdown. Oznacza to, na przykład, że zamiast widoku show.html.erb będzie można użyć widoku show.html.md

Program obsługi zaimplementujemy tak, aby w widokach można było użyć wstawek ERB.

W kodzie skorzystamy z gemu redcarpet:

Gemfile
gem "redcarpet"

Przykładowa implementacja:

config/initializers/markdown_template_handler.rb
class MarkdownTemplateHandler
  def self.call(template)
    erb = ActionView::Template.registered_template_handler(:erb)
    source = erb.call(template)
    <<-SOURCE
    renderer = Redcarpet::Render::HTML.new
    options = { fenced_code_blocks: true }
    Redcarpet::Markdown.new(renderer, options).render(begin;#{source};end)
    SOURCE
  end
end
ActionView::Template.register_template_handler(:md, :markdown, MarkdownTemplateHandler)

Zob. RailsCasts #379.

Teraz po usunięciu szablonu show.html.erb:

rm app/views/fortunes/show.html.erb

i utworzeniu szablonu show.html.md, na przykład takiego:

app/views/fortunes/show.html.md
<%- model_class = Fortune -%>
<div id="notice" class="alert alert-success" role="alert"><%= notice %></div>

<%= @fortune.quotation %>

*<%= @fortune.source %>*

[Back](<%= fortunes_path %>) |
[Edit](<%= edit_fortune_path(@fortune) %>) |
<%= link_to 'Destroy', fortune_path(@fortune),
  method: :delete, data: { confirm: 'Are you sure?' },
  class: 'btn btn-danger' %>

zostanie on użyty zamiast usuniętego szablonu .html.erb.

Zobacz też José Valim, Multipart templates with Markerb. Nazwa gemu markerb to skrót na „multipart templates made easy with Markdown + ERb”.