OSDN Git Service

XML REST API for issues that provides CRUD operations for Issues (#1214).
authorJean-Philippe Lang <jp_lang@yahoo.fr>
Wed, 13 Jan 2010 19:29:19 +0000 (19:29 +0000)
committerJean-Philippe Lang <jp_lang@yahoo.fr>
Wed, 13 Jan 2010 19:29:19 +0000 (19:29 +0000)
git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@3310 e93f8b46-1217-0410-a6f0-8f06a7374b81

app/controllers/application_controller.rb
app/controllers/issues_controller.rb
app/views/issues/index.xml.builder [new file with mode: 0644]
app/views/issues/show.xml.builder [new file with mode: 0644]
config/routes.rb
test/integration/issues_api_test.rb [new file with mode: 0644]

index 20a8e57..d696955 100644 (file)
@@ -70,8 +70,8 @@ class ApplicationController < ActionController::Base
     elsif params[:format] == 'atom' && params[:key] && accept_key_auth_actions.include?(params[:action])
       # RSS key authentication does not start a session
       User.find_by_rss_key(params[:key])
-    elsif Setting.rest_api_enabled? && ['xml', 'json'].include?(params[:format]) && accept_key_auth_actions.include?(params[:action])
-      if params[:key].present?
+    elsif Setting.rest_api_enabled? && ['xml', 'json'].include?(params[:format])
+      if params[:key].present? && accept_key_auth_actions.include?(params[:action])
         # Use API key
         User.find_by_api_key(params[:key])
       else
@@ -194,18 +194,35 @@ class ApplicationController < ActionController::Base
   
   def render_403
     @project = nil
-    render :template => "common/403", :layout => (request.xhr? ? false : 'base'), :status => 403
+    respond_to do |format|
+      format.html { render :template => "common/403", :layout => (request.xhr? ? false : 'base'), :status => 403 }
+      format.atom { head 403 }
+      format.xml { head 403 }
+      format.json { head 403 }
+    end
     return false
   end
     
   def render_404
-    render :template => "common/404", :layout => !request.xhr?, :status => 404
+    respond_to do |format|
+      format.html { render :template => "common/404", :layout => !request.xhr?, :status => 404 }
+      format.atom { head 404 }
+      format.xml { head 404 }
+      format.json { head 404 }
+    end
     return false
   end
   
   def render_error(msg)
-    flash.now[:error] = msg
-    render :text => '', :layout => !request.xhr?, :status => 500
+    respond_to do |format|
+      format.html { 
+        flash.now[:error] = msg
+        render :text => '', :layout => !request.xhr?, :status => 500
+      }
+      format.atom { head 500 }
+      format.xml { head 500 }
+      format.json { head 500 }
+    end
   end
   
   def invalid_authenticity_token
index bb2e55f..5da0aa2 100644 (file)
@@ -46,7 +46,7 @@ class IssuesController < ApplicationController
   helper :timelog
   include Redmine::Export::PDF
 
-  verify :method => :post,
+  verify :method => [:post, :delete],
          :only => :destroy,
          :render => { :nothing => true, :status => :method_not_allowed }
            
@@ -59,6 +59,7 @@ class IssuesController < ApplicationController
       limit = per_page_option
       respond_to do |format|
         format.html { }
+        format.xml { }
         format.atom { limit = Setting.feeds_limit.to_i }
         format.csv  { limit = Setting.issues_export_limit.to_i }
         format.pdf  { limit = Setting.issues_export_limit.to_i }
@@ -74,6 +75,7 @@ class IssuesController < ApplicationController
       
       respond_to do |format|
         format.html { render :template => 'issues/index.rhtml', :layout => !request.xhr? }
+        format.xml  { render :layout => false }
         format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
         format.csv  { send_data(issues_to_csv(@issues, @project), :type => 'text/csv; header=present', :filename => 'export.csv') }
         format.pdf  { send_data(issues_to_pdf(@issues, @project, @query), :type => 'application/pdf', :filename => 'export.pdf') }
@@ -113,6 +115,7 @@ class IssuesController < ApplicationController
     @time_entry = TimeEntry.new
     respond_to do |format|
       format.html { render :template => 'issues/show.rhtml' }
+      format.xml  { render :layout => false }
       format.atom { render :action => 'changes', :layout => false, :content_type => 'application/atom+xml' }
       format.pdf  { send_data(issue_to_pdf(@issue), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
     end
@@ -155,10 +158,20 @@ class IssuesController < ApplicationController
         attach_files(@issue, params[:attachments])
         flash[:notice] = l(:notice_successful_create)
         call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
-        redirect_to(params[:continue] ? { :action => 'new', :tracker_id => @issue.tracker } :
-                                        { :action => 'show', :id => @issue })
+        respond_to do |format|
+          format.html {
+            redirect_to(params[:continue] ? { :action => 'new', :tracker_id => @issue.tracker } :
+                                            { :action => 'show', :id => @issue })
+          }
+          format.xml  { render :action => 'show', :status => :created, :location => url_for(:controller => 'issues', :action => 'show', :id => @issue) }
+        end
         return
-      end              
+      else
+        respond_to do |format|
+          format.html { }
+          format.xml  { render(:xml => @issue.errors, :status => :unprocessable_entity); return }
+        end
+      end
     end        
     @priorities = IssuePriority.all
     render :layout => !request.xhr?
@@ -184,7 +197,9 @@ class IssuesController < ApplicationController
       @issue.safe_attributes = attrs
     end
 
-    if request.post?
+    if request.get?
+      # nop
+    else
       @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
       @time_entry.attributes = params[:time_entry]
       if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.valid?
@@ -201,9 +216,18 @@ class IssuesController < ApplicationController
             flash[:notice] = l(:notice_successful_update)
           end
           call_hook(:controller_issues_edit_after_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
-          redirect_to(params[:back_to] || {:action => 'show', :id => @issue})
+          respond_to do |format|
+            format.html { redirect_to(params[:back_to] || {:action => 'show', :id => @issue}) }
+            format.xml  { head :ok }
+          end
+          return
         end
       end
+      # failure
+      respond_to do |format|
+        format.html { }
+        format.xml  { render :xml => @issue.errors, :status => :unprocessable_entity }
+      end
     end
   rescue ActiveRecord::StaleObjectError
     # Optimistic locking exception
@@ -346,12 +370,17 @@ class IssuesController < ApplicationController
           TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
         end
       else
-        # display the destroy form
-        return
+        unless params[:format] == 'xml'
+          # display the destroy form if it's a user request
+          return
+        end
       end
     end
     @issues.each(&:destroy)
-    redirect_to :action => 'index', :project_id => @project
+    respond_to do |format|
+      format.html { redirect_to :action => 'index', :project_id => @project }
+      format.xml  { head :ok }
+    end
   end
   
   def gantt
@@ -484,7 +513,8 @@ private
   end
   
   def find_project
-    @project = Project.find(params[:project_id])
+    project_id = (params[:issue] && params[:issue][:project_id]) || params[:project_id]
+    @project = Project.find(project_id)
   rescue ActiveRecord::RecordNotFound
     render_404
   end
diff --git a/app/views/issues/index.xml.builder b/app/views/issues/index.xml.builder
new file mode 100644 (file)
index 0000000..4c848b5
--- /dev/null
@@ -0,0 +1,31 @@
+xml.instruct!
+xml.issues :type => 'array', :count => @issue_count do
+  @issues.each do |issue|
+           xml.issue :id => issue.id do
+                       xml.project(:id => issue.project_id, :name => issue.project.name) unless issue.project.nil?
+                       xml.tracker(:id => issue.tracker_id, :name => issue.tracker.name) unless issue.tracker.nil?
+                       xml.status(:id => issue.status_id, :name => issue.status.name) unless issue.status.nil?
+                       xml.priority(:id => issue.priority_id, :name => issue.priority.name) unless issue.priority.nil?
+                       xml.author(:id => issue.author_id, :name => issue.author.name) unless issue.author.nil?
+                       xml.assigned_to(:id => issue.assigned_to_id, :name => issue.assigned_to.name) unless issue.assigned_to.nil?
+                 xml.category(:id => issue.category_id, :name => issue.category.name) unless issue.category.nil?
+                 xml.fixed_version(:id => issue.fixed_version_id, :name => issue.fixed_version.name) unless issue.fixed_version.nil?
+      
+      xml.subject              issue.subject
+      xml.description issue.description
+      xml.start_date   issue.start_date
+      xml.due_date             issue.due_date
+      xml.done_ratio   issue.done_ratio
+      xml.estimated_hours issue.estimated_hours
+      
+      xml.custom_fields do
+       issue.custom_field_values.each do |custom_value|
+               xml.custom_field custom_value.value, :id => custom_value.custom_field_id, :name => custom_value.custom_field.name
+       end
+      end
+      
+      xml.created_on issue.created_on
+      xml.updated_on issue.updated_on
+    end
+  end
+end
diff --git a/app/views/issues/show.xml.builder b/app/views/issues/show.xml.builder
new file mode 100644 (file)
index 0000000..b73f984
--- /dev/null
@@ -0,0 +1,54 @@
+xml.instruct!
+xml.issue :id => @issue.id do
+       xml.project(:id => @issue.project_id, :name => @issue.project.name) unless @issue.project.nil?
+       xml.tracker(:id => @issue.tracker_id, :name => @issue.tracker.name) unless @issue.tracker.nil?
+       xml.status(:id => @issue.status_id, :name => @issue.status.name) unless @issue.status.nil?
+       xml.priority(:id => @issue.priority_id, :name => @issue.priority.name) unless @issue.priority.nil?
+       xml.author(:id => @issue.author_id, :name => @issue.author.name) unless @issue.author.nil?
+       xml.assigned_to(:id => @issue.assigned_to_id, :name => @issue.assigned_to.name) unless @issue.assigned_to.nil?
+  xml.category(:id => @issue.category_id, :name => @issue.category.name) unless @issue.category.nil?
+  xml.fixed_version(:id => @issue.fixed_version_id, :name => @issue.fixed_version.name) unless @issue.fixed_version.nil?
+  
+  xml.subject          @issue.subject
+  xml.description @issue.description
+  xml.start_date       @issue.start_date
+  xml.due_date                 @issue.due_date
+  xml.done_ratio       @issue.done_ratio
+  xml.estimated_hours @issue.estimated_hours
+  if User.current.allowed_to?(:view_time_entries, @project)
+       xml.spent_hours         @issue.spent_hours
+       end
+  
+  xml.custom_fields do
+       @issue.custom_field_values.each do |custom_value|
+               xml.custom_field custom_value.value, :id => custom_value.custom_field_id, :name => custom_value.custom_field.name
+       end
+  end unless @issue.custom_field_values.empty?
+  
+  xml.created_on @issue.created_on
+  xml.updated_on @issue.updated_on
+  
+  xml.changesets do
+       @issue.changesets.each do |changeset|
+               xml.changeset :revision => changeset.revision do
+                       xml.user(:id => changeset.user_id, :name => changeset.user.name) unless changeset.user.nil?
+                       xml.comments changeset.comments
+                       xml.committed_on changeset.committed_on
+               end
+       end
+  end if User.current.allowed_to?(:view_changesets, @project) && @issue.changesets.any?
+  
+  xml.journals do
+       @issue.journals.each do |journal|
+               xml.journal :id => journal.id do
+                               xml.user(:id => journal.user_id, :name => journal.user.name) unless journal.user.nil?
+                       xml.notes journal.notes
+                       xml.details do
+                               journal.details.each do |detail|
+                                       xml.detail :property => detail.property, :name => detail.prop_key, :old => detail.old_value, :new => detail.value
+                               end
+                       end
+               end
+       end
+  end unless @issue.journals.empty?
+end
index e2560c1..d64fad7 100644 (file)
@@ -119,9 +119,17 @@ ActionController::Routing::Routes.draw do |map|
       issues_views.connect 'issues/:id/move', :action => 'move', :id => /\d+/
     end
     issues_routes.with_options :conditions => {:method => :post} do |issues_actions|
+      issues_actions.connect 'issues', :action => 'index'
       issues_actions.connect 'projects/:project_id/issues', :action => 'new'
       issues_actions.connect 'issues/:id/quoted', :action => 'reply', :id => /\d+/
       issues_actions.connect 'issues/:id/:action', :action => /edit|move|destroy/, :id => /\d+/
+      issues_actions.connect 'issues.:format', :action => 'new', :format => /xml/
+    end
+    issues_routes.with_options :conditions => {:method => :put} do |issues_actions|
+      issues_actions.connect 'issues/:id.:format', :action => 'edit', :id => /\d+/, :format => /xml/
+    end
+    issues_routes.with_options :conditions => {:method => :delete} do |issues_actions|
+      issues_actions.connect 'issues/:id.:format', :action => 'destroy', :id => /\d+/, :format => /xml/
     end
     issues_routes.connect 'issues/:action'
   end
diff --git a/test/integration/issues_api_test.rb b/test/integration/issues_api_test.rb
new file mode 100644 (file)
index 0000000..4406f36
--- /dev/null
@@ -0,0 +1,158 @@
+# Redmine - project management software
+# Copyright (C) 2006-2010  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# 
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require "#{File.dirname(__FILE__)}/../test_helper"
+
+class IssuesApiTest < ActionController::IntegrationTest
+  fixtures :projects,
+    :users,
+    :roles,
+    :members,
+    :member_roles,
+    :issues,
+    :issue_statuses,
+    :versions,
+    :trackers,
+    :projects_trackers,
+    :issue_categories,
+    :enabled_modules,
+    :enumerations,
+    :attachments,
+    :workflows,
+    :custom_fields,
+    :custom_values,
+    :custom_fields_projects,
+    :custom_fields_trackers,
+    :time_entries,
+    :journals,
+    :journal_details,
+    :queries
+
+  def setup
+    Setting.rest_api_enabled = '1'
+  end
+    
+  def test_index_routing
+    assert_routing(
+      {:method => :get, :path => '/issues.xml'},
+      :controller => 'issues', :action => 'index', :format => 'xml'
+    )
+  end
+  
+  def test_index
+    get '/issues.xml'
+    assert_response :success
+    assert_equal 'application/xml', @response.content_type
+  end
+    
+  def test_show_routing
+    assert_routing(
+      {:method => :get, :path => '/issues/1.xml'},
+      :controller => 'issues', :action => 'show', :id => '1', :format => 'xml'
+    )
+  end
+  
+  def test_show
+    get '/issues/1.xml'
+    assert_response :success
+    assert_equal 'application/xml', @response.content_type
+  end
+    
+  def test_create_routing
+    assert_routing(
+      {:method => :post, :path => '/issues.xml'},
+      :controller => 'issues', :action => 'new', :format => 'xml'
+    )
+  end
+  
+  def test_create
+    attributes = {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}
+    assert_difference 'Issue.count' do
+      post '/issues.xml', {:issue => attributes}, :authorization => credentials('jsmith')
+    end
+    assert_response :created
+    assert_equal 'application/xml', @response.content_type
+    issue = Issue.first(:order => 'id DESC')
+    attributes.each do |attribute, value|
+      assert_equal value, issue.send(attribute)
+    end
+  end
+  
+  def test_create_failure
+    attributes = {:project_id => 1}
+    assert_no_difference 'Issue.count' do
+      post '/issues.xml', {:issue => attributes}, :authorization => credentials('jsmith')
+    end
+    assert_response :unprocessable_entity
+    assert_equal 'application/xml', @response.content_type
+    assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
+  end
+    
+  def test_update_routing
+    assert_routing(
+      {:method => :put, :path => '/issues/1.xml'},
+      :controller => 'issues', :action => 'edit', :id => '1', :format => 'xml'
+    )
+  end
+  
+  def test_update
+    attributes = {:subject => 'API update'}
+    assert_no_difference 'Issue.count' do
+      assert_difference 'Journal.count' do
+        put '/issues/1.xml', {:issue => attributes}, :authorization => credentials('jsmith')
+      end
+    end
+    assert_response :ok
+    assert_equal 'application/xml', @response.content_type
+    issue = Issue.find(1)
+    attributes.each do |attribute, value|
+      assert_equal value, issue.send(attribute)
+    end
+  end
+  
+  def test_update_failure
+    attributes = {:subject => ''}
+    assert_no_difference 'Issue.count' do
+      assert_no_difference 'Journal.count' do
+        put '/issues/1.xml', {:issue => attributes}, :authorization => credentials('jsmith')
+      end
+    end
+    assert_response :unprocessable_entity
+    assert_equal 'application/xml', @response.content_type
+    assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
+  end
+    
+  def test_destroy_routing
+    assert_routing(
+      {:method => :delete, :path => '/issues/1.xml'},
+      :controller => 'issues', :action => 'destroy', :id => '1', :format => 'xml'
+    )
+  end
+  
+  def test_destroy
+    assert_difference 'Issue.count', -1 do
+      delete '/issues/1.xml', {}, :authorization => credentials('jsmith')
+    end
+    assert_response :ok
+    assert_equal 'application/xml', @response.content_type
+    assert_nil Issue.find_by_id(1)
+  end
+  
+  def credentials(user, password=nil)
+    ActionController::HttpAuthentication::Basic.encode_credentials(user, password || user)
+  end
+end