AtomPubな何かを作ってみよう その4 〜実装編 Part.03「子リソースの作成」〜

前回は、作成したAtomPubリソースの動作確認をしてみました。今回は、前回作成したリソースの子供の関係にあたるリソースを作ってみたいと思います。

DBのマイグレーションを行う

まずDBに新しくテーブルを作ります。ファイル名を"db/migrate/002_create_childresources.rb"とでもしてマイグレーションファイルを作成しましょう。

class CreateChildresources < ActiveRecord::Migration
  def self.up
    create_table :namedwords, :force => true do |t|
      t.column :meaning_id, :string
      t.column :name, :string
      t.column :author, :string
      t.column :comment, :string
      t.column :updated, :timestamp
    end

    create_table :meaningscomments, :force => true do |t|
      t.column :meaning_id, :string
      t.column :author, :string
      t.column :comment, :string
      t.column :updated, :timestamp
    end

    add_index :namedwords, :name
  end

  def self.down
    [:namedwords, :meaningscomments].each do |t|
      drop_table t
    end
  end
end

作成したら、マイグレーションを実行します。

> rake db:migrate

ルーティングの設定を行う

続いて、ルーティング設定を追加します。"config/routes.rb"を書き換えます。

ActionController::Routing::Routes.draw do |map|
  # '/meanings' or '/meanings/{意味語}' => MeaningsController
  map.resources :meanings, :path_prefix => ''

  # '/meanings/{意味語}'のbaseを設定
  meaning_base = '/meanings/:meaning'

  # '/meanings/{意味語}/namedwords' => NamedwordsController
  map.resources :namedwords, :path_prefix => meaning_base

  # '/meanings/{意味語}/comments' => MeaningscommentsController
  map.resources :comments, :controller => 'meaningscomments', :path_prefix => meaning_base, :name_prefix => 'meanings_'
end

commentsリソースの設定がちょっとややこしくなっていますが、これは、後々namedwordsの方にもコメントリソースを作った時に名前の重複による動作不正が起きるのを避ける為の伏線です。

Modelを作る

Modelに関しては特別な設定はしないので、ここでは省略。コピペで作ればいいだけなんですが、クラス名を書き換えておくのを忘れずに。

Controllerを作る

Controllerは、基本的には前々回の記事で作成した"meanings_controller.rb"を元に、"meaning"を、"namedword"と"meaningcomment"に書き換えたものを作る形になります。実際のソースコードはこの記事の最後に載せておきたいと思います。

ただ1点ポイントがありまして、namedwordsリソースもcommentsリソースも、URLに含まれる"意味語"をキーとして、指定された"意味語"に属するもののみを、出力するフィード/エントリの対象にする必要があります。その為、URLで与えられた"意味語"をキーとして検索するメソッドを、before_filterとして定義しておきます。

以下のように、"app/controllers/application.rb"にmust_specify_meaningメソッドを追記します。

class ApplicationController < ActionController::Base
  def must_specify_meaning
    if params[:meaning]
      @meaning = Meaning.find_by_name(params[:meaning])
      unless @meaning
        render :text => '見つかりませんでした。', :status => '404 Not Found'
        return false
      end
    end
    return true
  end
end

これを、"namedwords_controller.rb"と"meaningscomments_controller.rb"にbefore_filterとして設定するようにします。

Viewを作る

Viewも、基本的にはmeaningsリソースのものを書き換えて作ればOKですね。*1 こちらもソースコードは記事の最後に載せておきます。

完成!

という事で、ここまでで作ったものだけでも、単語のCRUD、単語に対する訳語のCRUD、単語に対するコメントのCRUDという機能が使えるWebサービスができた事になります。

とは言え、これだけじゃ面白くも何ともないし、認証機能も作ってないので誰でも好き放題し放題だったりとか、まだ色々と考慮すべき点があるので、それらについても追々作っていきたいと思います。

次回は?

次回は、namedwordsリソースの子リソースにあたる、"/meanings/{meaning}/namedwords/{namedword}/comments"リソースと、"/meanings/{meaning}/namedwords/{namedword}/stars"リソースを作ってみたいと思います。

今回作成したControllerのソースコード

app/controllers/namedwords_controller.rb
class NamedwordsController < ApplicationController
  # filter
  before_filter :must_specify_meaning

  # GET /meanings/{meaning}/namedwords
  def index
    @namedwords = Namedword.find(:all, :conditions => ['meaning_id=?', @meaning.id], :order => 'updated desc')
    headers['Content-Type'] = 'application/atom+xml;type=feed'
    render :action => 'feed'
  end

  # POST /meanings/{meaning}/namedwords
  def create
    parse_xml
    name = @parsed_params[:name]
    @namedword = Namedword.find_by_meaning_id_and_name(@meaning.id, name)
    if @namedword
      headers['Location'] = namedword_url(@meaning.name, @namedword.name)
      render :nothing => true, :status => '409 Conflict'
    else
      @namedword = Namedword.new(@parsed_params)
      if @namedword.save
        headers['Location'] = namedword_url(@meaning.name, @parsed_params[:name])
        render :action => 'entry', :status => '201 Created'
      else
        render :xml => @namedword.errors.to_xml, :status => '400 Bad Request'
      end
    end
  end

  # GET /meanings/{meaning}/namedwords/{nameword}
  def show
    @namedword = Namedword.find_by_meaning_id_and_name(@meaning.id, params[:id])
    if @namedword
      headers['Content-Type'] = 'application/atom+xml;type=entry'
      render :action => 'entry'
    else
      render :status => 404, :text => '指定したエントリは存在しません'
    end
  end

  # PUT /meanings/{meaning}/namedwords/{nameword}
  def update
    parse_xml
    @namedword = Namedword.find_by_meaning_id_and_name(@meaning.id, params[:id])
    if @namedword
      @namedword.update_attributes(@parsed_params)
      if @namedword.save
        render :nothing => true, :status => '200 OK'
      else
        render :xml => @namedword.errors.to_xml, :status => '400 Bad Request'
      end
    end
  end

  # DELETE /meanings/{meaning}/namedwords/{nameword}
  def destroy
    @namedword = Namedword.find_by_meaning_id_and_name(@meaning.id, params[:id])
    if @namedword
      @namedword.destroy
      render :nothing => true, :status => '200 OK'
    end
  end

private
  # POST/PUTされたXMLを解析し、@parsed_paramsに保存
  def parse_xml
  xml = REXML::Document.new(request.raw_post)
  @parsed_params = {
    :name => xml.elements['entry/title'].text,
    :author => xml.elements['entry/author/name'].text,
    :comment => xml.elements['entry/content'].text,
    :meaning_id => @meaning.id,
    :updated => Time.now,
  }
  end

end
app/controllers/meaningscomments_controller.rb
class MeaningscommentsController < ApplicationController
  # filter
  before_filter :must_specify_meaning

  # GET /meanings/{meaning}/comments
  def index
    @meaningscomments = Meaningscomment.find(:all, :conditions => ['meaning_id=?', @meaning.id], :order => 'updated desc')
    headers['Content-Type'] = 'application/atom+xml;type=feed'
    render :action => 'feed'
  end

  # POST /meanings/{meaning}/comments
  def create
    parse_xml
    @meaningscomment = Meaningscomment.new(@parsed_params)
    if @meaningscomment.save
      headers['Location'] = meanings_comment_url(@meaning.name, @meaningscomment[:id])
      render :action => 'entry', :status => '201 Created'
    else
      render :xml => @meaningscomment.errors.to_xml, :status => '400 Bad Request'
    end
  end

  # GET /meanings/{meaning}/comments/{commentid}
  def show
    @meaningscomment = Meaningscomment.find_by_id(params[:id])
    if @meaningscomment
      headers['Content-Type'] = 'application/atom+xml;type=entry'
      render :action => 'entry'
    else
      render :status => 404, :text => '指定したエントリは存在しません'
    end
  end

  # PUT /meanings/{meaning}/comments/{commentid}
  def update
    parse_xml
    @meaningscomment = Meaningscomment.find_by_id(params[:id])
    if @meaningscomment
      @meaningscomment.update_attributes(@parsed_params)
      if @meaningscomment.save
        render :nothing => true, :status => '200 OK'
      else
        render :xml => @meaningscomment.errors.to_xml, :status => '400 Bad Request'
      end
    end
  end

  # DELETE /meanings/{meaning}/comments/{commentid}
  def destroy
    @meaningscomment = Meaningscomment.find_by_id(params[:id])
    if @meaningscomment
      @meaningscomment.destroy
      render :nothing => true, :status => '200 OK'
    end
  end

private
  # POST/PUTされたXMLを解析し、@parsed_paramsに保存
  def parse_xml
  xml = REXML::Document.new(request.raw_post)
  @parsed_params = {
    :author => xml.elements['entry/author/name'].text,
    :comment => xml.elements['entry/content'].text,
    :meaning_id => @meaning.id,
    :updated => Time.now,
  }
  end

end

今回作成したViewのソースコード

app/views/namedwords/feed.rxml
xml.instruct!(:xml, :version => 1.0, :encoding => 'UTF-8')
xml.feed(:language => 'ja', 'xmlns' => 'http://www.w3.org/2005/Atom', 'xmlns:app' => 'http://www.w3.org/2007/app') do |feed|
  feed.id('tag:tom@nadukete.name,2008:meanings/'+@meaning.id.to_s+'/namedwords')
  feed.title(''+@meaning.name+'」の訳語候補一覧')
  feed.author do |author|
    author.name('nadukete.name')
  end
  feed.updated((@namedwords.size > 0) ? @namedwords.map{|namedword| namedword.updated}.max.localtime.iso8601 : Time.now.localtime.iso8601 )
  feed.link(:rel => 'self', :href => namedwords_url)
  @namedwords.each do |namedword|
    feed.entry do |entry|
      entry.id('tag:tom@nadukete.name,2008:meanings/'+@meaning.id.to_s+'/namedwords/'+namedword.id.to_s)
      entry.updated(namedword.updated.localtime.iso8601)
      entry.author do |author|
        author.name(namedword.author)
      end
      entry.title(namedword.name)
      entry.content(namedword.comment, :type => 'text')
      entry.link(:rel => 'edit', :href => namedword_url(@meaning.name, namedword.name))
    end
  end
end
app/views/namedwords/entry.rxml
xml.instruct!(:xml, :version => 1.0, :encoding => 'UTF-8')
xml.entry(:language => 'ja', 'xmlns' => 'http://www.w3.org/2005/Atom', 'xmlns:app' => 'http://www.w3.org/2007/app') do |entry|
  entry.id('tag:tom@nadukete.name,2008:meanings/'+@meaning.id.to_s+'/namedwords/'+@namedword.id.to_s)
  entry.updated(@namedword.updated.localtime.iso8601)
  entry.author do |author|
    author.name(@namedword.author)
  end
  entry.title(@namedword.name)
  entry.content(@namedword.comment, :type => 'text')
  entry.link(:rel => 'edit', :href => namedword_url(@meaning.name, @namedword.name))
end
app/views/meaningscomments/feed.rxml
xml.instruct!(:xml, :version => 1.0, :encoding => 'UTF-8')
xml.feed(:language => 'ja', 'xmlns' => 'http://www.w3.org/2005/Atom', 'xmlns:app' => 'http://www.w3.org/2007/app') do |feed|
  feed.id('tag:tom@nadukete.name,2008:meanings/'+@meaning.id.to_s+'/comments')
  feed.title(''+@meaning.name+'」のコメント一覧')
  feed.author do |author|
    author.name('nadukete.name')
  end
  feed.updated((@meaningscomments.size > 0) ? @meaningscomments.map{|meaningscomment| meaningscomment.updated}.max.localtime.iso8601 : Time.now.localtime.iso8601 )
  feed.link(:rel => 'self', :href => meanings_comments_url)
  @meaningscomments.each do |meaningscomment|
    feed.entry do |entry|
      entry.id('tag:tom@nadukete.name,2008:meanings/'+@meaning.id.to_s+'/comments/'+meaningscomment.id.to_s)
      entry.updated(meaningscomment.updated.localtime.iso8601)
      entry.author do |author|
        author.name(meaningscomment.author)
      end
      entry.title('コメントID:'+meaningscomment.id.to_s)
      entry.content(meaningscomment.comment, :type => 'text')
      entry.link(:rel => 'edit', :href => meanings_comment_url(@meaning.name, meaningscomment.id.to_s))
    end
  end
end
app/views/meaningscomments/entry.rxml
xml.instruct!(:xml, :version => 1.0, :encoding => 'UTF-8')
xml.entry(:language => 'ja', 'xmlns' => 'http://www.w3.org/2005/Atom', 'xmlns:app' => 'http://www.w3.org/2007/app') do |entry|
  entry.id('tag:tom@nadukete.name,2008:meanings/'+@meaning.id.to_s+'/comments/'+@meaningscomment.id.to_s)
  entry.updated(@meaningscomment.updated.localtime.iso8601)
  entry.author do |author|
    author.name(@meaningscomment.author)
  end
  entry.title('コメントID:'+@meaningscomment.id.to_s)
  entry.content(@meaningscomment.comment, :type => 'text')
  entry.link(:rel => 'edit', :href => meanings_comment_url(@meaning.name, @meaningscomment.id.to_s))
end

*1:書き換えて作ればOKというのは裏を返せば"DRYではない"という事なので、似通っている部分は外出しして、もうちょっとスマートに書けるかもしれないですね…。