diff --git a/lib/active_resource.rb b/lib/active_resource.rb
index 1d970436f4..204d5acd8f 100644
--- a/lib/active_resource.rb
+++ b/lib/active_resource.rb
@@ -36,5 +36,6 @@ module ActiveResource
autoload :HttpMock
autoload :Observing
autoload :Schema
+ autoload :Singleton
autoload :Validations
end
diff --git a/lib/active_resource/singleton.rb b/lib/active_resource/singleton.rb
new file mode 100644
index 0000000000..9718c718a9
--- /dev/null
+++ b/lib/active_resource/singleton.rb
@@ -0,0 +1,114 @@
+module ActiveResource
+ module Singleton
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ attr_writer :singleton_name
+
+ def singleton_name
+ @singleton_name ||= model_name.element
+ end
+
+ # Gets the singleton path for the object. If the +query_options+ parameter is omitted, Rails
+ # will split from the \prefix options.
+ #
+ # ==== Options
+ # +prefix_options+ - A \hash to add a \prefix to the request for nested URLs (e.g., :account_id => 19
+ # would yield a URL like /accounts/19/purchases.json).
+ # +query_options+ - A \hash to add items to the query string for the request.
+ #
+ # ==== Examples
+ # Weather.singleton_path
+ # # => /weather.json
+ #
+ # class Inventory < ActiveResource::Base
+ # self.site = "https://37s.sunrise.com"
+ # self.prefix = "/products/:product_id/"
+ # end
+ #
+ # Inventory.singleton_path(:product_id => 5)
+ # # => /products/5/inventory.json
+ #
+ # Inventory.singleton_path({:product_id => 5}, {:sold => true})
+ # # => /products/5/inventory.json?sold=true
+ #
+ def singleton_path(prefix_options = {}, query_options = nil)
+ check_prefix_options(prefix_options)
+
+ prefix_options, query_options = split_options(prefix_options) if query_options.nil?
+ "#{prefix(prefix_options)}#{singleton_name}.#{format.extension}#{query_string(query_options)}"
+ end
+
+ # Core method for finding singleton resources.
+ #
+ # ==== Arguments
+ # Takes a single argument of options
+ #
+ # ==== Options
+ # * :params - Sets the query and \prefix (nested URL) parameters.
+ #
+ # ==== Examples
+ # Weather.find
+ # # => GET /weather.json
+ #
+ # Weather.find(:params => {:degrees => 'fahrenheit'})
+ # # => GET /weather.json?degrees=fahrenheit
+ #
+ # == Failure or missing data
+ # A failure to find the requested object raises a ResourceNotFound
+ # exception.
+ #
+ # Inventory.find
+ # # => raises ResourceNotFound
+ def find(options={})
+ find_singleton(options)
+ end
+
+ private
+ # Find singleton resource
+ def find_singleton(options)
+ prefix_options, query_options = split_options(options[:params])
+
+ path = singleton_path(prefix_options, query_options)
+ resp = self.format.decode(self.connection.get(path, self.headers).body)
+ instantiate_record(resp, {})
+ end
+
+ end
+ # Deletes the resource from the remove service.
+ #
+ # ==== Examples
+ # weather = Weather.find
+ # weather.destroy
+ # Weather.find # 404 (Resource Not Found)
+ def destroy
+ connection.delete(singleton_path, self.class.headers)
+ end
+
+
+ protected
+
+ # Update the resource on the remote service
+ def update
+ connection.put(singleton_path(prefix_options), encode, self.class.headers).tap do |response|
+ load_attributes_from_response(response)
+ end
+ end
+
+ # Create (i.e. \save to the remote service) the \new resource.
+ def create
+ connection.post(singleton_path, encode, self.class.headers).tap do |response|
+ self.id = id_from_response(response)
+ load_attributes_from_response(response)
+ end
+ end
+
+ private
+
+ def singleton_path(options = nil)
+ self.class.singleton_path(options || prefix_options)
+ end
+
+ end
+
+end
diff --git a/test/fixtures/inventory.rb b/test/fixtures/inventory.rb
new file mode 100644
index 0000000000..4df01f419d
--- /dev/null
+++ b/test/fixtures/inventory.rb
@@ -0,0 +1,13 @@
+class Inventory < ActiveResource::Base
+ include ActiveResource::Singleton
+ self.site = 'http://37s.sunrise.i:3000'
+ self.prefix = '/products/:product_id/'
+
+ schema do
+ integer :total
+ integer :used
+
+ string :status
+ end
+end
+
diff --git a/test/fixtures/weather.rb b/test/fixtures/weather.rb
new file mode 100644
index 0000000000..d0fc1c34e3
--- /dev/null
+++ b/test/fixtures/weather.rb
@@ -0,0 +1,19 @@
+class Weather < ActiveResource::Base
+ include ActiveResource::Singleton
+ self.site = 'http://37s.sunrise.i:3000'
+
+ schema do
+ string :status
+ string :temperature
+ end
+end
+
+class WeatherDashboard < ActiveResource::Base
+ include ActiveResource::Singleton
+ self.site = 'http://37s.sunrise.i:3000'
+ self.singleton_name = 'dashboard'
+
+ schema do
+ string :status
+ end
+end
diff --git a/test/singleton_test.rb b/test/singleton_test.rb
new file mode 100644
index 0000000000..bfad3510ca
--- /dev/null
+++ b/test/singleton_test.rb
@@ -0,0 +1,136 @@
+require 'abstract_unit'
+require 'fixtures/weather'
+require 'fixtures/inventory'
+
+class SingletonTest < ActiveSupport::TestCase
+ def setup_weather
+ weather = { :status => 'Sunny', :temperature => 67 }
+ ActiveResource::HttpMock.respond_to do |mock|
+ mock.get '/weather.json', {}, weather.to_json
+ mock.get '/weather.json?degrees=fahrenheit', {}, weather.merge(:temperature => 100).to_json
+ mock.post '/weather.json', {}, weather.to_json, 201, 'Location' => '/weather.json'
+ mock.delete '/weather.json', {}, nil
+ mock.put '/weather.json', {}, nil, 204
+ end
+ end
+
+ def setup_weather_not_found
+ ActiveResource::HttpMock.respond_to do |mock|
+ mock.get '/weather.json', {}, nil, 404
+ end
+ end
+
+ def setup_inventory
+ inventory = {:status => 'Sold Out', :total => 10, :used => 10}.to_json
+
+ ActiveResource::HttpMock.respond_to do |mock|
+ mock.get '/products/5/inventory.json', {}, inventory
+ end
+ end
+
+ def test_custom_singleton_name
+ assert_equal 'dashboard', WeatherDashboard.singleton_name
+ end
+
+ def test_singleton_path
+ assert_equal '/weather.json', Weather.singleton_path
+ end
+
+ def test_singleton_path_with_parameters
+ assert_equal '/weather.json?degrees=fahrenheit', Weather.singleton_path(:degrees => 'fahrenheit')
+ assert_equal '/weather.json?degrees=false', Weather.singleton_path(:degrees => false)
+ assert_equal '/weather.json?degrees=', Weather.singleton_path(:degrees => nil)
+
+ assert_equal '/weather.json?degrees=fahrenheit', Weather.singleton_path('degrees' => 'fahrenheit')
+
+ # Use include? because ordering of param hash is not guaranteed
+ path = Weather.singleton_path(:degrees => 'fahrenheit', :lunar => true)
+ assert path.include?('weather.json')
+ assert path.include?('degrees=fahrenheit')
+ assert path.include?('lunar=true')
+
+ path = Weather.singleton_path(:days => ['monday', 'saturday and sunday', nil, false])
+ assert_equal '/weather.json?days%5B%5D=monday&days%5B%5D=saturday+and+sunday&days%5B%5D=&days%5B%5D=false', path
+
+ path = Inventory.singleton_path(:product_id => 5)
+ assert_equal '/products/5/inventory.json', path
+
+ path = Inventory.singleton_path({:product_id =>5}, {:sold => true})
+ assert_equal '/products/5/inventory.json?sold=true', path
+ end
+
+ def test_find_singleton
+ setup_weather
+ weather = Weather.send(:find_singleton, Hash.new)
+ assert_not_nil weather
+ assert_equal 'Sunny', weather.status
+ assert_equal 67, weather.temperature
+ end
+
+ def test_find
+ setup_weather
+ weather = Weather.find
+ assert_not_nil weather
+ assert_equal 'Sunny', weather.status
+ assert_equal 67, weather.temperature
+ end
+
+ def test_find_with_param_options
+ setup_inventory
+ inventory = Inventory.find(:params => {:product_id => 5})
+
+ assert_not_nil inventory
+ assert_equal 'Sold Out', inventory.status
+ assert_equal 10, inventory.used
+ assert_equal 10, inventory.total
+ end
+
+ def test_find_with_query_options
+ setup_weather
+
+ weather = Weather.find(:params => {:degrees => 'fahrenheit'})
+ assert_not_nil weather
+ assert_equal 'Sunny', weather.status
+ assert_equal 100, weather.temperature
+ end
+
+ def test_not_found
+ setup_weather_not_found
+
+ assert_raise ActiveResource::ResourceNotFound do
+ Weather.find
+ end
+ end
+
+ def test_create_singleton
+ setup_weather
+ weather = Weather.create(:status => 'Sunny', :temperature => 67)
+ assert_not_nil weather
+ assert_equal 'Sunny', weather.status
+ assert_equal 67, weather.temperature
+ end
+
+ def test_destroy
+ setup_weather
+
+ # First Create the Weather
+ weather = Weather.create(:status => 'Sunny', :temperature => 67)
+ assert_not_nil weather
+
+ # Now Destroy it
+ weather.destroy
+ end
+
+ def test_update
+ setup_weather
+
+ # First Create the Weather
+ weather = Weather.create(:status => 'Sunny', :temperature => 67)
+ assert_not_nil weather
+
+ # Then update it
+ weather.status = 'Rainy'
+ weather.save
+ end
+end
+