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