diff options
-rw-r--r-- | activeresource/CHANGELOG | 2 | ||||
-rw-r--r-- | activeresource/README | 231 | ||||
-rw-r--r-- | activeresource/lib/active_resource/base.rb | 62 | ||||
-rw-r--r-- | activeresource/lib/active_resource/connection.rb | 19 | ||||
-rw-r--r-- | activeresource/lib/active_resource/http_mock.rb | 2 | ||||
-rw-r--r-- | activeresource/lib/active_resource/struct.rb | 9 | ||||
-rw-r--r-- | activeresource/lib/active_resource/validations.rb | 36 |
7 files changed, 336 insertions, 25 deletions
diff --git a/activeresource/CHANGELOG b/activeresource/CHANGELOG index 44108fb8ce..1f30c14f6f 100644 --- a/activeresource/CHANGELOG +++ b/activeresource/CHANGELOG @@ -1,5 +1,7 @@ *SVN* +* Mega documentation patches. #7025, #7069 [rwdaigle] + * Base.exists?(id, options) and Base#exists? check whether the resource is found. #6970 [rwdaigle] * Query string support. [untext, Jeremy Kemper] diff --git a/activeresource/README b/activeresource/README index f1013b54e0..a657e3a471 100644 --- a/activeresource/README +++ b/activeresource/README @@ -1 +1,230 @@ -= Active Resource -- Object-oriented REST services
\ No newline at end of file += Active Resource -- Object-oriented REST services + +Active Resource (ARes) connects business objects and REST web services. It is a library +intended to provide transparent proxying capabilities between a client and a RESTful +service (for which Rails provides the {Simply RESTful routing}[http://dev.rubyonrails.org/browser/trunk/actionpack/lib/action_controller/resources.rb] implementation). + +=== Configuration & Usage + +Configuration is as simple as inheriting from ActiveResource::Base and providing a site +class variable: + + class Person < ActiveResource::Base + self.site = "http://api.people.com:3000/" + end + +Person is now REST enable and can invoke REST services very similarly to how ActiveRecord invokes +lifecycle methods that operate against a persistent store. + + # Find a person with id = 1 + # This will invoke the following Http call: + # GET http://api.people.com:3000/people/1.xml + # and will load up the XML response into a new + # Person object + # + ryan = Person.find(1) + Person.exists?(1) #=> true + + # To create a new person - instantiate the object and call 'save', + # which will invoke this Http call: + # POST http://api.people.com:3000/people.xml + # (and will submit the XML format of the person object in the request) + # + ryan = Person.new(:first => 'Ryan', :last => 'Daigle') + ryan.save #=> true + ryan.id #=> 2 + Person.exists?(ryan.id) #=> true + ryan.exists? #=> true + + # Updating is done with 'save' as well + # PUT http://api.people.com:3000/people/1.xml + # + ryan = Person.find(1) + ryan.first = 'Rizzle' + ryan.save #=> true + + # And destruction + # DELETE http://api.people.com:3000/people/1.xml + # + ryan = Person.find(1) + ryan.destroy #=> true # Or Person.delete(ryan.id) + + +=== Protocol + +ARes is built on a standard XML format for requesting and submitting resources. It mirrors the +RESTful routing built into ActionController, though it's useful to discuss what ARes expects +outside the context of ActionController as it is not dependent on a Rails-based RESTful implementation. + +==== Find + +GET Http requests expect the XML form of whatever resource/resources is/are being requested. So, +for a request for a single element - the XML of that item is expected in response: + + # Expects a response of + # + # <person><id>1</id><attribute1>value1</attribute1><attribute2>..</attribute2></person> + # + # for GET http://api.people.com:3000/people/1.xml + # + ryan = Person.find(1) + +The XML document that is received is used to build a new object of type Person, with each +XML element becoming an attribute on the object. + + ryan.is_a? Person #=> true + ryan.attribute1 #=> 'value1' + +Any complex element (one that contains other elements) becomes its own object: + + # With this response: + # + # <person><id>1</id><attribute1>value1</attribute1><complex><attribute2>value2</attribute2></complex></person> + # + # for GET http://api.people.com:3000/people/1.xml + # + ryan = Person.find(1) + ryan.complex #=> <Person::Complex::xxxxx> + ryan.complex.attribute2 #=> 'value2' + +Collections can also be requested in a similar fashion + + # Expects a response of + # + # <people> + # <person><id>1</id><first>Ryan</first></person> + # <person><id>2</id><first>Jim</first></person> + # </people> + # + # for GET http://api.people.com:3000/people.xml + # + people = Person.find(:all) + people.first #=> <Person::xxx 'first' => 'Ryan' ...> + people.last #=> <Person::xxx 'first' => 'Jim' ...> + +==== Create + +Creating a new resource submits the xml form of the resource as the body of the request and expects +a 'Location' header in the response with the RESTful URL location of the newly created resource. The +id of the newly created resource is parsed out of the Location response header and automatically set +as the id of the ARes object. + + # <person><first>Ryan</first></person> + # + # is submitted as the body on + # + # POST http://api.people.com:3000/people.xml + # + # when save is called on a new Person object. An empty response is + # is expected with a 'Location' header value: + # + # Response (200): Location: http://api.people.com:3000/people/2 + # + ryan = Person.new(:first => 'Ryan') + ryan.new? #=> true + ryan.save #=> true + ryan.new? #=> false + ryan.id #=> 2 + +==== Update + +'save' is also used to update an existing resource - and follows the same protocol as creating a resource +with the exception that no response headers are needed - just an empty response when the update on the +server side was successful. + + # <person><first>Ryan</first></person> + # + # is submitted as the body on + # + # PUT http://api.people.com:3000/people/1.xml + # + # when save is called on an existing Person object. An empty response is + # is expected with code (204) + # + ryan = Person.find(1) + ryan.first #=> 'Ryan' + ryan.first = 'Rizzle' + ryan.save #=> true + +==== Delete + +Destruction of a resource can be invoked as a class and instance method of the resource. + + # A request is made to + # + # DELETE http://api.people.com:3000/people/1.xml + # + # for both of these forms. An empty response with + # is expected with response code (200) + # + ryan = Person.find(1) + ryan.destroy #=> true + ryan.exists? #=> false + Person.delete(2) #=> true + Person.exists?(2) #=> false + + +=== Errors & Validation + +Error handling and validation is handled in much the same manner as you're used to seeing in +ActiveRecord. Both the response code in the Http response and the body of the response are used to +indicate that an error occurred. + +==== Resource errors + +When a get is requested for a resource that does not exist, the Http '404' (resource not found) +response code will be returned from the server which will raise an ActiveResource::ResourceNotFound +exception. + + # GET http://api.people.com:3000/people/1.xml + # #=> Response (404) + # + ryan = Person.find(1) #=> Raises ActiveResource::ResourceNotFound + +==== Validation errors + +Creating and updating resources can lead to validation errors - i.e. 'First name cannot be empty' etc... +These types of errors are denoted in the response by a response code of 400 and the xml representation +of the validation errors. The save operation will then fail (with a 'false' return value) and the +validation errors can be accessed on the resource in question. + + # When + # + # PUT http://api.people.com:3000/people/1.xml + # + # is requested with invalid values, the expected response is: + # + # Response (400): + # <errors><error>First cannot be empty</error></errors> + # + ryan = Person.find(1) + ryan.first #=> '' + ryan.save #=> false + ryan.errors.invalid?(:first) #=> true + ryan.errors.full_messages #=> ['First cannot be empty'] + + +==== Response errors + +If the underlying Http request for an ARes operation results in an error response code, an +exception will be raised. The following Http response codes will result in these exceptions: + + 200 - 399: Valid response, no exception + 400: ActiveResource::ResourceInvalid (automatically caught by ARes validation) + 404: ActiveResource::ResourceNotFound + 409: ActiveResource::ResourceConflict + 401 - 499: ActiveResource::ClientError + 500 - 599: ActiveResource::ServerError + + +=== Authentication + +Many REST apis will require username/password authentication, usually in the form of +Http authentication. This can easily be specified by putting the username and password +in the Url of the ARes site: + + class Person < ActiveResource::Base + self.site = "http://ryan:password@api.people.com:3000/" + end + +For obvious reasons it is best if such services are available over https.
\ No newline at end of file diff --git a/activeresource/lib/active_resource/base.rb b/activeresource/lib/active_resource/base.rb index 8babc18507..f530923224 100644 --- a/activeresource/lib/active_resource/base.rb +++ b/activeresource/lib/active_resource/base.rb @@ -8,6 +8,7 @@ module ActiveResource cattr_accessor :logger class << self + # Gets the URI of the resource's site def site if defined?(@site) @site @@ -16,20 +17,24 @@ module ActiveResource end end + # Set the URI for the REST resources def site=(site) @connection = nil @site = create_site_uri_from(site) end + # Base connection to remote service def connection(refresh = false) @connection = Connection.new(site) if refresh || @connection.nil? @connection end - attr_accessor_with_default(:element_name) { to_s.underscore } - attr_accessor_with_default(:collection_name) { element_name.pluralize } - attr_accessor_with_default(:primary_key, 'id') - + attr_accessor_with_default(:element_name) { to_s.underscore } #:nodoc: + attr_accessor_with_default(:collection_name) { element_name.pluralize } #:nodoc: + attr_accessor_with_default(:primary_key, 'id') #:nodoc: + + # Gets the resource prefix + # prefix/collectionname/1.xml def prefix(options={}) default = site.path default << '/' unless default[-1..-1] == '/' @@ -37,6 +42,8 @@ module ActiveResource prefix(options) end + # Sets the resource prefix + # prefix/collectionname/1.xml def prefix=(value = '/') prefix_call = value.gsub(/:\w+/) { |key| "\#{options[#{key}]}" } instance_eval <<-end_eval, __FILE__, __LINE__ @@ -48,23 +55,24 @@ module ActiveResource raise end - alias_method :set_prefix, :prefix= + alias_method :set_prefix, :prefix= #:nodoc: - alias_method :set_element_name, :element_name= - alias_method :set_collection_name, :collection_name= + alias_method :set_element_name, :element_name= #:nodoc: + alias_method :set_collection_name, :collection_name= #:nodoc: def element_path(id, options = {}) "#{prefix(options)}#{collection_name}/#{id}.xml#{query_string(options)}" end - def collection_path(options = {}) + def collection_path(options = {}) "#{prefix(options)}#{collection_name}.xml#{query_string(options)}" end - alias_method :set_primary_key, :primary_key= + alias_method :set_primary_key, :primary_key= #:nodoc: - # Person.find(1) # => GET /people/1.xml - # StreetAddress.find(1, :person_id => 1) # => GET /people/1/street_addresses/1.xml + # Core method for finding resources. Used similarly to ActiveRecord's find method. + # Person.find(1) # => GET /people/1.xml + # StreetAddress.find(1, :person_id => 1) # => GET /people/1/street_addresses/1.xml def find(*arguments) scope = arguments.slice!(0) options = arguments.slice!(0) || {} @@ -80,7 +88,7 @@ module ActiveResource connection.delete(element_path(id)) end - # True if the resource is found. + # Evalutes to <tt>true</tt> if the resource is found. def exists?(id, options = {}) id && !find_single(id, options).nil? rescue ActiveResource::ResourceNotFound @@ -88,16 +96,19 @@ module ActiveResource end private + # Find every resource. def find_every(options) collection = connection.get(collection_path(options)) || [] collection.collect! { |element| new(element, options) } end - # { :person => person1 } + # Find a single resource. + # { :person => person1 } def find_single(scope, options) new(connection.get(element_path(scope, options)), options) end + # Accepts a URI and creates the site URI from that. def create_site_uri_from(site) site.is_a?(URI) ? site.dup : URI.parse(site) end @@ -106,6 +117,7 @@ module ActiveResource @prefix_parameters ||= prefix_source.scan(/:\w+/).map { |key| key[1..-1].to_sym }.to_set end + # Builds the query string for the request. def query_string(options) # Omit parameters which appear in the URI path. query_params = options.reject { |key, value| prefix_parameters.include?(key) } @@ -129,8 +141,8 @@ module ActiveResource end end - attr_accessor :attributes - attr_accessor :prefix_options + attr_accessor :attributes #:nodoc: + attr_accessor :prefix_options #:nodoc: def initialize(attributes = {}, prefix_options = {}) @attributes = {} @@ -138,19 +150,22 @@ module ActiveResource @prefix_options = prefix_options end + # Is the resource a new object? def new? id.nil? end + # Get the id of the object. def id attributes[self.class.primary_key] end + # Set the id of the object. def id=(id) attributes[self.class.primary_key] = id end - # True if and only if +other+ is the same object or is an instance of the same class, is not new?, and has the same id. + # True if and only if +other+ is the same object or is an instance of the same class, is not +new?+, and has the same +id+. def ==(other) other.equal?(self) || (other.instance_of?(self.class) && !other.new? && other.id == id) end @@ -166,19 +181,22 @@ module ActiveResource id.hash end + # Delegates to +create+ if a new object, +update+ if its old. def save new? ? create : update end + # Delete the resource. def destroy connection.delete(element_path) end - # True if this resource is found. + # Evaluates to <tt>true</tt> if this resource is found. def exists? !new? && self.class.exists?(id, prefix_options) end + # Convert the resource to an XML string def to_xml(options={}) attributes.to_xml({:root => self.class.element_name}.merge(options)) end @@ -215,17 +233,19 @@ module ActiveResource self.class.connection(refresh) end + # Update the resource on the remote service. def update connection.put(element_path, to_xml) end + # Create (i.e., save to the remote service) the new resource. def create returning connection.post(collection_path, to_xml) do |response| self.id = id_from_response(response) end end - # takes a response from a typical create post and pulls the ID out + # Takes a response from a typical create post and pulls the ID out def id_from_response(response) response['Location'][/\/([^\/]*?)(\.\w+)?$/, 1] end @@ -239,10 +259,12 @@ module ActiveResource end private + # Tries to find a resource for a given collection name; if it fails, then the resource is created def find_or_create_resource_for_collection(name) find_or_create_resource_for(name.to_s.singularize) end - + + # Tries to find a resource for a given name; if it fails, then the resource is created def find_or_create_resource_for(name) resource_name = name.to_s.camelize resource_name.constantize @@ -253,7 +275,7 @@ module ActiveResource resource end - def method_missing(method_symbol, *arguments) + def method_missing(method_symbol, *arguments) #:nodoc: method_name = method_symbol.to_s case method_name.last diff --git a/activeresource/lib/active_resource/connection.rb b/activeresource/lib/active_resource/connection.rb index c52d4d4839..528a7cc678 100644 --- a/activeresource/lib/active_resource/connection.rb +++ b/activeresource/lib/active_resource/connection.rb @@ -24,7 +24,7 @@ module ActiveResource class ServerError < ConnectionError; end # 5xx Server Error - + # Class to handle connections to remote services. class Connection attr_reader :site @@ -44,27 +44,37 @@ module ActiveResource self.site = site end + # Set URI for remote service. def site=(site) @site = site.is_a?(URI) ? site : URI.parse(site) end + # Execute a GET request. + # Used to get (find) resources. def get(path) from_xml_data(Hash.from_xml(request(:get, path, build_request_headers).body).values.first) end + # Execute a DELETE request (see HTTP protocol documentation if unfamiliar). + # Used to delete resources. def delete(path) request(:delete, path, build_request_headers) end + # Execute a PUT request (see HTTP protocol documentation if unfamiliar). + # Used to update resources. def put(path, body = '') request(:put, path, body, build_request_headers) end + # Execute a POST request. + # Used to create new resources. def post(path, body = '') request(:post, path, body, build_request_headers) end private + # Makes request to remote service. def request(method, path, *arguments) logger.info "#{method.to_s.upcase} #{site.scheme}://#{site.host}:#{site.port}#{path}" if logger result = nil @@ -73,6 +83,7 @@ module ActiveResource handle_response(result) end + # Handles response and error codes from remote service. def handle_response(response) case response.code.to_i when 200...400 @@ -92,6 +103,8 @@ module ActiveResource end end + # Creates new (or uses currently instantiated) Net::HTTP instance for communication with + # remote service and resources. def http unless @http @http = Net::HTTP.new(@site.host, @site.port) @@ -102,15 +115,17 @@ module ActiveResource @http end + # Builds headers for request to remote service. def build_request_headers authorization_header.update(self.class.default_header) end + # Sets authorization header; authentication information is pulled from credentials provided with site URI. def authorization_header (@site.user || @site.password ? { 'Authorization' => 'Basic ' + ["#{@site.user}:#{ @site.password}"].pack('m').delete("\r\n") } : {}) end - def logger + def logger #:nodoc: ActiveResource::Base.logger end diff --git a/activeresource/lib/active_resource/http_mock.rb b/activeresource/lib/active_resource/http_mock.rb index f37bc7d2ad..3ce31a50ea 100644 --- a/activeresource/lib/active_resource/http_mock.rb +++ b/activeresource/lib/active_resource/http_mock.rb @@ -1,7 +1,7 @@ require 'active_resource/connection' module ActiveResource - class InvalidRequestError < StandardError; end + class InvalidRequestError < StandardError; end #:nodoc: class HttpMock class Responder diff --git a/activeresource/lib/active_resource/struct.rb b/activeresource/lib/active_resource/struct.rb index 6f4ffecc20..ee1f15d781 100644 --- a/activeresource/lib/active_resource/struct.rb +++ b/activeresource/lib/active_resource/struct.rb @@ -1,4 +1,13 @@ module ActiveResource + # Class that allows a connection to a remote resource. + # Person = ActiveResource::Struct.new do |p| + # p.uri "http://www.mypeople.com/people" + # p.credentials :username => "mycreds", :password => "wordofpassage" + # end + # + # person = Person.find(1) + # person.name = "David" + # person.save! class Struct def self.create Class.new(Base) diff --git a/activeresource/lib/active_resource/validations.rb b/activeresource/lib/active_resource/validations.rb index c45655d1ea..76aa7d2f00 100644 --- a/activeresource/lib/active_resource/validations.rb +++ b/activeresource/lib/active_resource/validations.rb @@ -1,7 +1,9 @@ module ActiveResource - class ResourceInvalid < ClientError + class ResourceInvalid < ClientError #:nodoc: end + # Active Resource validation is reported to and from this object, which is used by Base#save + # to determine whether the object in a valid state to be saved. See usage example in Validations. class Errors include Enumerable attr_reader :errors @@ -100,6 +102,38 @@ module ActiveResource end end + # Module to allow validation of ActiveResource objects, which are implemented by overriding +Base#validate+ or its variants. + # Each of these methods can inspect the state of the object, which usually means ensuring that a number of + # attributes have a certain value (such as not empty, within a given range, matching a certain regular expression). For example: + # + # class Person < ActiveResource::Base + # self.site = "http://www.localhost.com:3000/" + # protected + # def validate + # errors.add_on_empty %w( first_name last_name ) + # errors.add("phone_number", "has invalid format") unless phone_number =~ /[0-9]*/ + # end + # + # def validate_on_create # is only run the first time a new object is saved + # unless valid_member?(self) + # errors.add("membership_discount", "has expired") + # end + # end + # + # def validate_on_update + # errors.add_to_base("No changes have occurred") if unchanged_attributes? + # end + # end + # + # person = Person.new("first_name" => "Jim", "phone_number" => "I will not tell you.") + # person.save # => false (and doesn't do the save) + # person.errors.empty? # => false + # person.errors.count # => 2 + # person.errors.on "last_name" # => "can't be empty" + # person.attributes = { "last_name" => "Halpert", "phone_number" => "555-5555" } + # person.save # => true (and person is now saved to the remote service) + # + # An Errors object is automatically created for every resource. module Validations def self.included(base) # :nodoc: base.class_eval do |