diff --git a/actionpack/CHANGELOG b/actionpack/CHANGELOG index 55d32d7592776b7f07aa7e64f4fb1c77edcedee3..a72715e0e5e7c8d186e707623f4a0e270081cb51 100644 --- a/actionpack/CHANGELOG +++ b/actionpack/CHANGELOG @@ -1,5 +1,14 @@ *SVN* +* Add singleton resources. [Rick Olson] + + map.resource :account + + GET /account + GET /account;edit + UPDATE /account + DELEE /account + * respond_to recognizes JSON. render :json => @person.to_json automatically sets the content type and takes a :callback option to specify a client-side function to call using the rendered JSON as an argument. #4185 [Scott Raymond, eventualbuddha] # application/json response with body 'Element.show({:name: "David"})' respond_to do |format| diff --git a/actionpack/lib/action_controller/resources.rb b/actionpack/lib/action_controller/resources.rb index b2174b3c9a0f66cb3760e1563a147ae548534557..7ca9007072474da05f436df47d1e2029a14f9b5d 100644 --- a/actionpack/lib/action_controller/resources.rb +++ b/actionpack/lib/action_controller/resources.rb @@ -18,26 +18,26 @@ def initialize(entities, options) end def controller - (options[:controller] || plural).to_s + @controller ||= (options[:controller] || plural).to_s end def path - "#{path_prefix}/#{plural}" + @path ||= "#{path_prefix}/#{plural}" end def new_path - "#{path}/new" + @new_path ||= "#{path}/new" end def member_path - "#{path}/:id" + @member_path ||= "#{path}/:id" end def nesting_path_prefix - "#{path}/:#{singular}_id" + @nesting_path_prefix ||= "#{path}/:#{singular}_id" end - private + protected def arrange_actions @collection_methods = arrange_actions_by_methods(options.delete(:collection)) @member_methods = arrange_actions_by_methods(options.delete(:member)) @@ -65,7 +65,34 @@ def add_default_action(collection, method, action) (collection[method] ||= []).unshift(action) end end - + + class SingletonResource < Resource #:nodoc: + def initialize(entity, options) + @singular = entity + @plural = options[:plural] || singular.to_s.pluralize + + @options = options + arrange_actions + add_default_actions + set_prefixes + end + + def controller + @controller ||= (options[:controller] || singular).to_s + end + + def path + @path ||= "#{path_prefix}/#{singular}" + end + + def new_path + nil + end + + alias_method :member_path, :path + alias_method :nesting_path_prefix, :path + end + # Creates named routes for implementing verb-oriented controllers. This is # useful for implementing REST API's, where a single resource has different # behavior based on the HTTP verb (method) used to access it. @@ -209,6 +236,55 @@ def resources(*entities, &block) entities.each { |entity| map_resource entity, options.dup, &block } end + # Creates named routes for implementing verb-oriented controllers for a singleton resource. + # A singleton resource is global to the current user visiting the application, such as a user's + # /account profile. + # + # See map.resources for general conventions. These are the main differences: + # - a singular name is given to map.resource. The default controller name is taken from the singular name. + # - To specify a custom plural name, use the :plural option. There is no :singular option + # - No default index, new, or create routes are created for the singleton resource controller. + # - When nesting singleton resources, only the singular name is used as the path prefix (example: 'account/messages/1') + # + # Example: + # + # map.resource :account + # + # class AccountController < ActionController::Base + # # GET account_url + # def show + # # find and return a specific message + # end + # + # # GET edit_account_url + # def edit + # # return an HTML form for editing a specific message + # end + # + # # PUT account_url + # def update + # # find and update a specific message + # end + # + # # DELETE account_url + # def destroy + # # delete a specific message + # end + # end + # + # Along with the routes themselves, #resource generates named routes for use in + # controllers and views. map.resource :account produces the following named routes and helpers: + # + # Named Route Helpers + # account account_url(id), hash_for_account_url(id), + # account_path(id), hash_for_account_path(id) + # edit_account edit_account_url(id), hash_for_edit_account_url(id), + # edit_account_path(id), hash_for_edit_account_path(id) + def resource(*entities, &block) + options = entities.last.is_a?(Hash) ? entities.pop : { } + entities.each { |entity| map_singleton_resource entity, options.dup, &block } + end + private def map_resource(entities, options = {}, &block) resource = Resource.new(entities, options) @@ -224,6 +300,18 @@ def map_resource(entities, options = {}, &block) end end + def map_singleton_resource(entities, options = {}, &block) + resource = SingletonResource.new(entities, options) + + with_options :controller => resource.controller do |map| + map_member_actions(map, resource) + + if block_given? + with_options(:path_prefix => resource.singular, &block) + end + end + end + def map_collection_actions(map, resource) resource.collection_methods.each do |method, actions| route_options = requirements_for(method) @@ -284,7 +372,7 @@ def map_member_actions(map, resource) map.connect(resource.member_path, :action => "destroy", :conditions => { :method => :delete }) map.connect("#{resource.member_path}.:format", :action => "destroy", :conditions => { :method => :delete }) end - + def requirements_for(method) method == :any ? {} : { :conditions => { :method => method } } end diff --git a/actionpack/test/controller/resources_test.rb b/actionpack/test/controller/resources_test.rb index 32b554604d294effd2447ea0a0dde7cb78f5d960..c4fabc5f52092051a22ee657567ae06681af99b2 100644 --- a/actionpack/test/controller/resources_test.rb +++ b/actionpack/test/controller/resources_test.rb @@ -2,6 +2,7 @@ class ResourcesController < ActionController::Base def index() render :nothing => true end + alias_method :show, :index def rescue_action(e) raise e end end @@ -9,6 +10,8 @@ class ThreadsController < ResourcesController; end class MessagesController < ResourcesController; end class CommentsController < ResourcesController; end +class AccountController < ResourcesController; end +class AdminController < ResourcesController; end class ResourcesTest < Test::Unit::TestCase def test_should_arrange_actions @@ -172,6 +175,66 @@ def test_restful_routes_dont_generate_duplicates end end + def test_should_create_singleton_resource_routes + with_singleton_resources :account do + assert_singleton_restful_for :account + end + end + + def test_should_create_multiple_singleton_resource_routes + with_singleton_resources :account, :admin do + assert_singleton_restful_for :account + assert_singleton_restful_for :admin + end + end + + def test_should_create_nested_singleton_resource_routes + with_routing do |set| + set.draw do |map| + map.resource :admin do |admin| + admin.resource :account + end + end + + assert_singleton_restful_for :admin + assert_singleton_restful_for :account, :path_prefix => 'admin/' + end + end + + def test_singleton_resource_with_member_action + [:put, :post].each do |method| + with_singleton_resources :account, :member => { :reset => method } do + reset_options = {:action => 'reset'} + reset_path = "/account;reset" + assert_singleton_routes_for :account do |options| + assert_recognizes(options.merge(reset_options), :path => reset_path, :method => method) + end + + assert_singleton_named_routes_for :account do |options| + assert_named_route reset_path, :reset_account_path, reset_options + end + end + end + end + + def test_singleton_resource_with_two_member_actions_with_same_method + [:put, :post].each do |method| + with_singleton_resources :account, :member => { :reset => method, :disable => method } do + %w(reset disable).each do |action| + action_options = {:action => action} + action_path = "/account;#{action}" + assert_singleton_routes_for :account do |options| + assert_recognizes(options.merge(action_options), :path => action_path, :method => method) + end + + assert_singleton_named_routes_for :account do |options| + assert_named_route action_path, "#{action}_account_path".to_sym, action_options + end + end + end + end + end + protected def with_restful_routing(*args) with_routing do |set| @@ -179,6 +242,13 @@ def with_restful_routing(*args) yield end end + + def with_singleton_resources(*args) + with_routing do |set| + set.draw { |map| map.resource(*args) } + yield + end + end # runs assert_restful_routes_for and assert_restful_named_routes for on the controller_name and options, without passing a block. def assert_simply_restful_for(controller_name, options = {}) @@ -186,6 +256,11 @@ def assert_simply_restful_for(controller_name, options = {}) assert_restful_named_routes_for controller_name, options end + def assert_singleton_restful_for(singleton_name, options = {}) + assert_singleton_routes_for singleton_name, options + assert_singleton_named_routes_for singleton_name, options + end + def assert_restful_routes_for(controller_name, options = {}) (options[:options] ||= {})[:controller] = controller_name.to_s @@ -242,6 +317,35 @@ def assert_restful_named_routes_for(controller_name, singular_name = nil, option yield options[:options] if block_given? end + def assert_singleton_routes_for(singleton_name, options = {}) + (options[:options] ||= {})[:controller] ||= singleton_name.to_s + + full_path = "/#{options[:path_prefix]}#{singleton_name}" + with_options options[:options] do |controller| + controller.assert_routing full_path, :action => 'show' + controller.assert_routing "#{full_path}.xml", :action => 'show', :format => 'xml' + end + + assert_recognizes(options[:options].merge(:action => 'update'), :path => full_path, :method => :put) + assert_recognizes(options[:options].merge(:action => 'destroy'), :path => full_path, :method => :delete) + + yield options[:options] if block_given? + end + + def assert_singleton_named_routes_for(singleton_name, options = {}) + (options[:options] ||= {})[:controller] ||= singleton_name.to_s + @controller = "#{options[:options][:controller].camelize}Controller".constantize.new + @request = ActionController::TestRequest.new + @response = ActionController::TestResponse.new + get :show, options[:options] + options[:options].delete :action + + full_path = "/#{options[:path_prefix]}#{singleton_name}" + + assert_named_route "#{full_path}", "#{singleton_name}_path", options[:options] + assert_named_route "#{full_path}.xml", "formatted_#{singleton_name}_path", options[:options].merge(:format => 'xml') + end + def assert_named_route(expected, route, options) actual = @controller.send(route, options) rescue $!.class.name assert_equal expected, actual, "Error on route: #{route}(#{options.inspect})"