Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Support Singleton Routes #4

Merged
merged 1 commit into from

3 participants

@kenmazaika

This is a version of this pull request, for the ActiveResource repo instead of the Rails repo:

rails/rails#5361

I was interfacing with a third party who exposed an API with a RESTful singleton route. In order to support this with ActiveResource I made this patch.

Initially I planned to patch ActiveResource::Base to support this, but very quickly it became convoluted with conditionals to determine which mode the model was in (singleton or standard). It was much easier to make a subclass, and have clients inherit from this.

I tried to be consistent with ActiveResource::Base. The one exception that is not consistent is find. ActiveResource::Base has the following code:

  def find(*arguments)
    scope   = arguments.slice!(0)
    options = arguments.slice!(0) || {}

In the singleton case however, find does not have a concept of a scope (or rather, the scope is always :singleton). Instead of forcing users to type an option that is not configurable, I thought it made more sense to use a sensible default, and alter how the method processes arguments.

To test the change, I made this simple singleton route API with an associated ActiveResource implementation:

https://github.com/kenmazaika/weather

If this is a feature that is not in ActiveResource for a reason, feel free to close this ticket out and not merge it. I did find some other people looking for this functionality though:

https://rails.lighthouseapp.com/projects/8994/tickets/760-activeresource-with-map-resource
https://rails.lighthouseapp.com/projects/8994/tickets/4348-supporting-singleton-resources-in-activeresource
http://railsforum.com/viewtopic.php?id=23172

If there are changes that need to happen to get this merged in, please let me know. I'd be happy to make them.

@jeremy
Owner

Thanks for digging in on this, @kenmazaika.

I usually work around this with something like

class Account < ActiveResource::Base
  def self.find_singleton
    find :one, from: '/account.xml'
  end
end

Native support would be pretty nice, and subclassing sounds reasonable. Could just do class ActiveResource::Singleton < ActiveResource::Base instead of introducing another Base.

@kenmazaika

I would be happy to make the change from

ActiveResource::Singleton::Base

to

ActiveResource::Singleton

Are there any other changes that I should make, while I'm at it?

@jeremy
Owner

@kenmazaika looking at my own code, it'd be hard to use this new subclass since I already rely on an abstract base class: class Basecamp::Base < ActiveResource::Base.

This is a pretty common pattern. Subclassing Singleton throws in a monkey wrench. Perhaps it'd be better served as a Singleton mixin.

@SweeD
Collaborator

I like the idea with the mixin the best.

What about ActiveResource::Singleton?

@kenmazaika

I'd be happy to change this to a mixin.

So that include ActiveResource::Singleton would make anything that inherits from ActiveResource::Base to act like ActiveResource::Singleton::Base in my original pull request.

I'll probably get around to making this change sometime next weekend. I'll add to this pull request, and make a comment to ping you guys to look at it.

If there are any other changes that should happen, let me know. Thanks.

@jeremy
Owner

Great! Thanks @kenmazaika

@kenmazaika

@jeremy @SweeD

I updated the pull request to use a mixin rather than a new sublcass. Let me know if you see anything else that should be changed with this pull request. Thanks.

@SweeD
Collaborator

Good job @kenmazaika.

Looks good to me and tests are green, too.

Could you please squash these 2 commits into 1 clean commit?

@jeremy
Owner

Looks good @kenmazaika! :+1: for merge when that's fixed, @SweeD

@kenmazaika kenmazaika Add support for Singleton Routes.
Implement ActiveResource::Singleton as a mixin, instead of using ActiveResource::Singleton::Base as a subclass.
5ecd87b
@kenmazaika

@SweeD @jeremy done. Also squashed into previous commit.

@SweeD
Collaborator

@kenmazaika Good job, thank you! :)

@SweeD SweeD merged commit fd79797 into rails:master
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Apr 4, 2012
  1. @kenmazaika

    Add support for Singleton Routes.

    kenmazaika authored
    Implement ActiveResource::Singleton as a mixin, instead of using ActiveResource::Singleton::Base as a subclass.
This page is out of date. Refresh to see the latest.
View
1  lib/active_resource.rb
@@ -36,5 +36,6 @@ module ActiveResource
autoload :HttpMock
autoload :Observing
autoload :Schema
+ autoload :Singleton
autoload :Validations
end
View
114 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., <tt>:account_id => 19</tt>
+ # would yield a URL like <tt>/accounts/19/purchases.json</tt>).
+ # +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
+ # * <tt>:params</tt> - 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
View
13 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
+
View
19 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
View
136 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
+
Something went wrong with that request. Please try again.