RailsでAtomPubに対応してみる

WEB+DB Pressで、RESTに関する連載を読んだ。
vol.40はAtomPubについてだった。
RESTクライアントは、Rails 2.0 から正式にサポートされるらしいが「今つくってるのにほしい!」からやってみよう。

目標

  • AtomPubによるエントリのCRUD操作、
  • モデル生成、入力チェックなどはRailsっぽくパラメータの代入だけで。
  • WSSE認証
  • テストの作成

サンプルアプリ

  • 日記アプリ
  • AtomPubエンドポイントは全てWSSEで認証が必要
  • エンドポイント

モデル

app/models/diary.rb

# create_table :diaries do |t|
#   t.column :subject,    :string
#   t.column :content,    :string
#   t.column :updated_at, :datetime
#   t.column :created_at, :datetime
# end
class Diary < ActiveRecord::Base
  validates_presence_of :subject, :content
end

AtomPub?

フィードの取得

リクエス

GET /atom/diary
Host: localhost:3001

レスポンス

HTTP/1.1 200 OK
Content-Type: application/atom+xml; type=feed

<feed xmlns="http://www.w3.org/2005/Atom" xmlns:diary="http://localhost:3001/xmlns">
  <id>tag:localhost,2007:/atom/diary</id>
  <title>日記</title>
  <link rel="alternate" ref="http://localhost:3001/"/>
  <link rel="self" ref="http://localhost:3001/atom/diary"/>
  <author>
    <name>lam</name>
  </author>
  <updated>2007-10-12T16:27:20Z</updated>
  <entry>
    <id>tag:localhost,2007-10-13:entry:2</id>
    <title>日記2日目</title>
    <diary:subject>日記2日目</diary:subject>
    <diary:content>目標三日坊主阻止。</diary:content>
    <updated>2007-10-13T01:27:20Z</updated>
    <link rel="alternate" ref="http://localhost:3001/diary/2" />
    <link rel="edit" ref="http://localhost:3001/atom/diary/2" />
  </entry>
  <entry>
    <id>tag:localhost,2007-10-12:entry:1</id>
    <title>日記はじめました</title>
    <diary:subject>日記はじめました</diary:subject>
    <diary:content>はじめちゃいましたよ。</diary:content>
    <updated>2007-10-12T05:47:04Z</updated>
    <link rel="alternate" ref="http://localhost:3001/diary/1" />
    <link rel="edit" ref="http://localhost:3001/atom/diary/1" />
  </entry>
</feed> 
エントリへのCRUD操作

フィードに記載されている各エントリの

 <link rel="edit" ref="http://localhost:3001/atom/diary/1" />

へ、HTTPの、GET/POST/PUT/DELETEメソッドでリクエストを送信することで行う。
※CreateはフィードのURI

操作 メソッド 成功時のレスポンスコード
Create POST 201
Read GET 200
Update PUT 200
Delete DELETE 200

※現在のWebブラウザではPUT及びDELETEは未実装。

Read
リクエス

GET /atom/diary/1
Host: localhost:3001

レスポンス

HTTP/1.1 200 OK
Content-Type: application/atom+xml; type=entry

<entry xmlns="http://www.w3.org/2005/Atom" xmlns:diary="http://localhost:3001/xmlns">
  <id>tag:localhost,2007-10-12:entry:1</id>
  <title>日記はじめました</title>
  <diary:subject>日記はじめました</diary:subject>
  <diary:content>はじめちゃいましたよ。</diary:content>
  <updated>2007-10-12T05:47:04Z</updated>
  <link rel="alternate" ref="http://localhost:3001/diary/1" />
  <link rel="edit" ref="http://localhost:3001/atom/diary/1" />
</entry>

Create
リクエス

POST /atom/diary
Host: localhost:3001
Content-Type: application/atom+xml; type=entry

<entry xmlns="http://www.w3.org/2005/Atom" xmlns:diary="http://localhost:3001/xmlns">
  <diary:subject>3日坊主阻止成功!</diary:subject>
  <diary:content>やったぜ!</diary:content>
</entry>

レスポンス

HTTP/1.1 201 Created
Content-Type: application/atom+xml; type=entry
Location: http://localhost:3001/atom/diary/3

<entry xmlns="http://www.w3.org/2005/Atom" xmlns:diary="http://localhost:3001/xmlns">
  <id>tag:localhost,2007-10-14:entry:3</id>
  <title>3日坊主阻止成功!</title>
  <diary:subject>3日坊主阻止成功!</diary:subject>
  <diary:content>やったぜ!</diary:content>
  <updated>2007-10-14T03:20:43Z</updated>
  <link rel="alternate" ref="http://localhost:3001/diary/3" />
  <link rel="edit" ref="http://localhost:3001/atom/diary/3" />
</entry>

ステータスコードは201を返すことに注意。
失敗したら500。

Update
リクエス

PUT /atom/diary/1
Host: localhost:3001
Content-Type: application/atom+xml; type=entry

<entry xmlns="http://www.w3.org/2005/Atom" xmlns:diary="http://localhost:3001/xmlns">
  <id>tag:localhost,2007-10-12:entry:1</id>
  <title>日記はじめました</title>
  <diary:subject>日記はじめました</diary:subject>
  <diary:content>はじめちゃいましたよ。※追記 3日坊主阻止成功!</diary:content>
  <updated>2007-10-12T05:47:04Z</updated>
  <link rel="alternate" ref="http://localhost:3001/diary/1" />
  <link rel="edit" ref="http://localhost:3001/atom/diary/1" />
</entry>

GETで取得したエントリを変更する箇所だけ変更して、その他はそのまま送信することに注意。
拡張性の実現のために必要だそう。

レスポンス

HTTP/1.1 200 OK

失敗したら500を返す。
(と、あれば失敗した理由などをbodyで。)

Delete
リクエス

DELETE /atom/diary/1
Host: localhost:3001

レスポンス

HTTP/1.1 200 OK

アプリ作成

いつもの手順で、コントローラとモデルを作成する。

  • app/controllers/atom/diary_controller.rb
  • app/models/diary.rb
app/models/diary.rb

モデルはいつも通りで。

class Diary < ActiveRecord::Base
  validates_presence_of :subject, :content
end
app/controllers/atom/diary_controller.rb

上で紹介したような動作をさせるには以下のような機能が必要。

  • 同じアクションで、HTTPメソッドの種類によって処理を振り分ける。
  • 受け取ったAtomエントリ(XML)をパースして、モデルの属性を取得する。
  • 適切なレスポンスコードを返す。(必要であればメッセージやAtomエントリをbodyに入れる)
class Atom::DiaryController < ApplicationController
  def index
    if request.get? && params[:id].nil?
      headers['Content-Type'] = 'application/atom+xml; type=feed'
      feed
    else
      headers['Content-Type'] = 'application/atom+xml; type=entry'
      send(request.method)
    end
  end
  
  private
  # Atomフィードを返す
  def feed
    @diaries = Diary.find(:all, :order => 'created_at DESC')
    render :action => 'feed'
  end

  # Read
  def get
    @diary = Diary.find(params[:id])
    render :action => 'entry'
  rescue ActiveRecord::RecordNotFound => e
    render :status => 404, :text => '指定したエントリは存在しません'
  rescue => e
    render :status => 500, :text => e.message
  end

  # Create
  def post
    @diary = Diary.new(xml_to_attributes(request.raw_post))
    @diary.save!  # 失敗で例外を投げる
    response.headers['Location'] = url_for(:id => @diary)
    render :status => 201, :action => 'entry'
  rescue ActiveRecord::RecordInvalid => e
    # 入力チェックに失敗したのでエラーメッセージを返す
    render :status => 500, :text => @diary.errors.full_messages.join("\n")
  rescue => e
    render :status => 500, :text => e.message
  end

  # Update
  def put
    @diary = Diary.find(params[:id])
    @diary.update_attributes!(xml_to_attributes(request.raw_post))
    render :status => 200, :text => '更新しました'
  rescue ActiveRecord::RecordNotFound => e
    render :status => 404, :text => '指定したエントリは存在しません'
  rescue ActiveRecord::RecordInvalid => e
    render :status => 500, :text => @diary.errors.full_messages.join("\n")
  rescue REXML::ParseException => e
    render :status => 500, :text => 'XMLの構文に誤りがあります'
  rescue => e
    render :status => 500, :text => e.message
  end

  # Delete
  def delete
    @diary = Diary.find(params[:id])
    @diary.destroy
  rescue ActiveRecord::RecordNotFound => e
    render :status => 404, :text => '指定したエントリは存在しません'
  rescue => e
    render :status => 500, :text => e.message
  end

  # RAW_POSTで送られたAtomエントリをパースして、モデルの属性値を取り出す
  # 属性の追加・変更などがあった際に変更をなくせるように、
  # diaryネームスペースを設定して自動的に値を取り出せるようにしてみた。
  # ※ダメ?
  def xml_to_attributes(source)
    puts source
    data = HashWithIndifferentAccess.new
    Entry.column_names.each{|column_name| data[column_name] = nil}
    REXML::Document.new(source).each_element('entry/child::*/namespace::diary') do |element|
      
      data[element.name] = element.text if data.member?(element.name)
    end

    data
  end
end
ビュー

XMLを返すときは、.rxmlを使う。

app/views/atom/diary/feed.rxml

xml.instruct! :xml, :version => 1.0, :encoding => 'UTF-8'
xml.feed(
  'xmlns' => "http://www.w3.org/2005/Atom",
  'xmlns:diary' => "http://localhost:3001/xmlns"
){
  xml.id("tag:#{@request.host},#{@diaries.last.created_at.year}:#{@request.path}")
  xml.title("日記")
  xml.author{
    xml.name 'lam'
  }
  xml.updated Diary.find(:first, :select => 'updated_at', :order => "updated_at DESC").updated_at.utc.iso8601
  xml.link(:ref => full_url_for(:controller => '/diary'), :rel => "alternate")
  xml.link(:ref => full_url_for(:controller => '/atom/diary'), :rel => "self")
  @diaries.each do |diary|
    xml.entry {
      xml.id("tag:#{@request.host},#{diary.created_at.utc.strftime('%Y-%m-%d')}:diary:#{diary.id}")
      xml.title(diary.subject)
      xml.entry(:subject, diary.subject)
      xml.entry(:content, diary.content)
      xml.updated diary.updated_at.utc.iso8601
      xml.link(:ref => full_url_for(:controller => '/diary', :id => diary), :rel => "alternate")
      xml.link(:ref => full_url_for(:controller => '/atom/diary', :id => diary), :rel => "edit")
    }
  end
}

app/views/atom/diary/entry.rxml

xml.entry(
  'xmlns' => "http://www.w3.org/2005/Atom",
  'xmlns:diary' => "http://localhost:3001/xmlns"
){
  xml.id("tag:#{@request.host},#{@diary.created_at.utc.strftime('%Y-%m-%d')}:diary:#{@diary.id}")
  xml.title(@diary.subject)
  xml.updated(@diary.updated_at.utc.iso8601)
      
  xml.diary(:subject, @diary.subject)
  xml.diary(:content, @diary.content)
      
  xml.link(:ref => url_for(:controller => '/diary', :id => @diary), :rel => "alternate")
  xml.link(:ref => url_for(:controller => '/atom/diary', :id => @diary), :rel => "edit")
}

app/helpers/application_helper.rb

module ApplicationHelper
  require 'uri'
  # url_for(:controller ...)の出力をフルパスで
  def full_url_for(*options)
    case options[0]
    when String
      url_for(options[0])
    when Hash
      URI::HTTP.build(
        :host => request.host,
        :port => request.port,
        :path => url_for(options[0])).to_s
    end
  end
end
config/routes.rb
  map.connect '/diary/:id', :controller => 'diary', :action => 'index'
  map.connect '/atom/diary/:id', :controller => 'atom/diary', :action => 'index'

テストの書き方

PUT/DELETE

普通にメソッドがある。

get     :index, :id => 1
post    :index
put     :index, :id => 1
delete  :index, :id => 1
Atomエントリをraw_postで渡す

@request.env['RAW_POST_DATA']を使えばできる模様。
Atomエントリを作るには、Builder::XmlMarkupが使える。

def test_post
  data = ''
  xml = Builder::XmlMarkup.new(:target => data, :indent => 2)
  xml.instruct! :xml, :version => 1.0, :encoding => 'UTF-8'
  xml.entry(
    'xmlns' => "http://www.w3.org/2005/Atom",
    'xmlns:diary' => "http://localhost:3001/xmlns"
  ){
  xml.diary :title, '新規記事'
  xml.diary :content, '新しい記事です。'
  }
  @request.env['RAW_POST_DATA'] = data
  post :index
  assert_response 201
end