1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Big documentation upgrade for ARes (closes #8694) [jeremymcanally]

git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@7098 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
This commit is contained in:
David Heinemeier Hansson 2007-06-23 17:29:54 +00:00
parent 753cbf1cd4
commit ae4838fff2
5 changed files with 674 additions and 189 deletions

View file

@ -1,65 +1,57 @@
= Active Resource -- Object-oriented REST services = Active Resource
Active Resource (ARes) connects business objects and REST web services. It is a library Active Resource (ARes) connects business objects and Representational State Transfer (REST)
intended to provide transparent proxying capabilities between a client and a RESTful web services. It implements object-relational mapping for REST webservices to provide transparent
service (for which Rails provides the {Simply RESTful routing}[http://dev.rubyonrails.org/browser/trunk/actionpack/lib/action_controller/resources.rb] implementation). proxying capabilities between a client (ActiveResource) and a RESTful service (which is provided by Simply RESTful routing
in ActionController::Resources).
=== Configuration & Usage == Philosophy
Configuration is as simple as inheriting from ActiveResource::Base and providing a site Active Resource attempts to provide a coherent wrapper object-relational mapping for REST
class variable: web services. It follows the same philosophy as Active Record, in that one of its prime aims
is to reduce the amount of code needed to map to these resources. This is made possible
by relying on a number of code- and protocol-based conventions that make it easy for Active Resource
to infer complex relations and structures. These conventions are outlined in detail in the documentation
for ActiveResource::Base.
== Overview
Model classes are mapped to remote REST resources by Active Resource much the same way Active Record maps model classes to database
tables. When a request is made to a remote resource, a REST XML request is generated, transmitted, and the result
received and serialized into a usable Ruby object.
=== Configuration and Usage
Putting ActiveResource to use is very similar to ActiveRecord. It's as simple as creating a model class
that inherits from ActiveResource::Base and providing a <tt>site</tt> class variable to it:
class Person < ActiveResource::Base class Person < ActiveResource::Base
self.site = "http://api.people.com:3000/" self.site = "http://api.people.com:3000/"
end end
Person is now REST enable and can invoke REST services very similarly to how ActiveRecord invokes Now the Person class is REST enabled and can invoke REST services very similarly to how ActiveRecord invokes
lifecycle methods that operate against a persistent store. lifecycle methods that operate against a persistent store.
# Find a person with id = 1 # 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) ryan = Person.find(1)
Person.exists?(1) #=> true Person.exists?(1) #=> true
# To create a new person - instantiate the object and call 'save', As you can see, the methods are quite similar to Active Record's methods for dealing with database
# which will invoke this Http call: records. But rather than dealing with
# 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
# Resource creation can also use the convenience <tt>create</tt> method which ==== Protocol
# will request a resource save after instantiation.
ryan = Person.create(:first => 'Ryan', :last => 'Daigle')
ryan.exists? #=> true
# Updating is done with 'save' as well Active Resource is built on a standard XML format for requesting and submitting resources over HTTP. It mirrors the RESTful routing
# PUT http://api.people.com:3000/people/1.xml built into ActionController but will also work with any other REST service that properly implements the protocol.
# REST uses HTTP, but unlike "typical" web applications, it makes use of all the verbs available in the HTTP specification:
ryan = Person.find(1)
ryan.first = 'Rizzle'
ryan.save #=> true
# And destruction * GET requests are used for finding and retrieving resources.
# DELETE http://api.people.com:3000/people/1.xml * POST requests are used to create new resources.
# * PUT requests are used to update existing resources.
ryan = Person.find(1) * DELETE requests are used to delete resources.
ryan.destroy #=> true # Or Person.delete(ryan.id)
For more information on how this protocol works with Active Resource, see the ActiveResource::Base documentation;
=== Protocol for more general information on REST web services, see the article here[http://en.wikipedia.org/wiki/Representational_State_Transfer].
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 ==== Find
@ -169,67 +161,5 @@ Destruction of a resource can be invoked as a class and instance method of the r
Person.exists?(2) #=> false Person.exists?(2) #=> false
=== Errors & Validation You can find more usage information in the ActiveResource::Base documentation.
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 422 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 (422):
# <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
404: ActiveResource::ResourceNotFound
409: ActiveResource::ResourceConflict
422: ActiveResource::ResourceInvalid (rescued by save as validation errors)
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.

View file

@ -3,12 +3,155 @@ require 'cgi'
require 'set' require 'set'
module ActiveResource module ActiveResource
# ActiveResource::Base is the main class for mapping RESTful resources as models in a Rails application.
#
# For an outline of what Active Resource is capable of, see link:files/README.html.
#
# == Automated mapping
#
# Active Resource objects represent your RESTful resources as manipulatable Ruby objects. To map resources
# to Ruby objects, Active Resource only needs a class name that corresponds to the resource name (e.g., the class
# Person maps to the resources people, very similarly to Active Record) and a +site+ value, which holds the
# URI of the resources.
#
# class Person < ActiveResource::Base
# self.site = "http://api.people.com:3000/"
# end
#
# Now the Person class is mapped to RESTful resources located at <tt>http://api.people.com:3000/people/</tt>, and
# you can now use Active Resource's lifecycles methods to manipulate resources.
#
# == Lifecycle methods
#
# Active Resource exposes methods for creating, finding, updating, and deleting resources
# from REST web services.
#
# ryan = Person.new(:first => 'Ryan', :last => 'Daigle')
# ryan.save #=> true
# ryan.id #=> 2
# Person.exists?(ryan.id) #=> true
# ryan.exists? #=> true
#
# ryan = Person.find(1)
# # => Resource holding our newly create Person object
#
# ryan.first = 'Rizzle'
# ryan.save #=> true
#
# ryan.destroy #=> true
#
# As you can see, these are very similar to Active Record's lifecycle methods for database records.
# You can read more about each of these methods in their respective documentation.
#
# === Custom REST methods
#
# Since simple CRUD/lifecycle methods can't accomplish every task, Active Resource also supports
# defining your own custom REST methods.
#
# Person.new(:name => 'Ryan).post(:register)
# # => { :id => 1, :name => 'Ryan', :position => 'Clerk' }
#
# Person.find(1).put(:promote, :position => 'Manager')
# # => { :id => 1, :name => 'Ryan', :position => 'Manager' }
#
# For more information on creating and using custom REST methods, see the
# ActiveResource::CustomMethods documentation.
#
# == Validations
#
# You can validate resources client side by overriding validation methods in the base class.
#
# class Person < ActiveResource::Base
# self.site = "http://api.people.com:3000/"
# protected
# def validate
# errors.add("last", "has invalid characters") unless last =~ /[a-zA-Z]*/
# end
# end
#
# See the ActiveResource::Validations documentation for more information.
#
# == Authentication
#
# Many REST APIs will require authentication, usually in the form of basic
# HTTP authentication. Authentication can be specified by putting the credentials
# in the +site+ variable of the Active Resource class you need to authenticate.
#
# class Person < ActiveResource::Base
# self.site = "http://ryan:password@api.people.com:3000/"
# end
#
# For obvious security reasons, it is probably best if such services are available
# over HTTPS.
#
# == Errors & Validation
#
# Error handling and validation is handled in much the same manner as you're used to seeing in
# Active Record. 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/999.xml
# ryan = Person.find(999) # => Raises ActiveResource::ResourceNotFound
# # => Response = 404
#
# +404+ is just one of the HTTP error response codes that ActiveResource will handle with its own exception. The
# following HTTP response codes will also result in these exceptions:
#
# 200 - 399:: Valid response, no exception
# 404:: ActiveResource::ResourceNotFound
# 409:: ActiveResource::ResourceConflict
# 422:: ActiveResource::ResourceInvalid (rescued by save as validation errors)
# 401 - 499:: ActiveResource::ClientError
# 500 - 599:: ActiveResource::ServerError
#
# These custom exceptions allow you to deal with resource errors more naturally and with more precision
# rather than returning a general HTTP error. For example:
#
# begin
# ryan = Person.find(my_id)
# rescue ActiveResource::ResourceNotFound
# redirect_to :action => 'not_found'
# rescue ActiveResource::ResourceConflict, ActiveResource::ResourceInvalid
# redirect_to :action => 'new'
# end
#
# === Validation errors
#
# Active Resource supports validations on resources and will return errors if any these validations fail
# (e.g., "First name can not be blank" and so on). These types of errors are denoted in the response by
# a response code of +422+ and an 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.
#
# ryan = Person.find(1)
# ryan.first #=> ''
# ryan.save #=> false
#
# # When
# # PUT http://api.people.com:3000/people/1.xml
# # is requested with invalid values, the response is:
# #
# # Response (422):
# # <errors><error>First cannot be empty</error></errors>
# #
#
# ryan.errors.invalid?(:first) #=> true
# ryan.errors.full_messages #=> ['First cannot be empty']
#
# Learn more about Active Resource's validation features in the ActiveResource::Validations documentation.
#
class Base class Base
# The logger for diagnosing and tracing ARes calls. # The logger for diagnosing and tracing Active Resource calls.
cattr_accessor :logger cattr_accessor :logger
class << self class << self
# Gets the URI of the resource's site # Gets the URI of the REST resources to map for this class. The site variable is required
# ActiveResource's mapping to work.
def site def site
if defined?(@site) if defined?(@site)
@site @site
@ -17,13 +160,16 @@ module ActiveResource
end end
end end
# Set the URI for the REST resources # Sets the URI of the REST resources to map for this class to the value in the +site+ argument.
# The site variable is required ActiveResource's mapping to work.
def site=(site) def site=(site)
@connection = nil @connection = nil
@site = create_site_uri_from(site) @site = create_site_uri_from(site)
end end
# Base connection to remote service # An instance of ActiveResource::Connection that is the base connection to the remote service.
# The +refresh+ parameter toggles whether or not the connection is refreshed at every request
# or not (defaults to +false+).
def connection(refresh = false) def connection(refresh = false)
@connection = Connection.new(site) if refresh || @connection.nil? @connection = Connection.new(site) if refresh || @connection.nil?
@connection @connection
@ -40,8 +186,8 @@ module ActiveResource
attr_accessor_with_default(:collection_name) { element_name.pluralize } #:nodoc: attr_accessor_with_default(:collection_name) { element_name.pluralize } #:nodoc:
attr_accessor_with_default(:primary_key, 'id') #:nodoc: attr_accessor_with_default(:primary_key, 'id') #:nodoc:
# Gets the resource prefix # Gets the prefix for a resource's nested URL (e.g., <tt>prefix/collectionname/1.xml</tt>)
# prefix/collectionname/1.xml # This method is regenerated at runtime based on what the prefix is set to.
def prefix(options={}) def prefix(options={})
default = site.path default = site.path
default << '/' unless default[-1..-1] == '/' default << '/' unless default[-1..-1] == '/'
@ -50,13 +196,15 @@ module ActiveResource
prefix(options) prefix(options)
end end
# An attribute reader for the source string for the resource path prefix. This
# method is regenerated at runtime based on what the prefix is set to.
def prefix_source def prefix_source
prefix # generate #prefix and #prefix_source methods first prefix # generate #prefix and #prefix_source methods first
prefix_source prefix_source
end end
# Sets the resource prefix # Sets the prefix for a resource's nested URL (e.g., <tt>prefix/collectionname/1.xml</tt>).
# prefix/collectionname/1.xml # Default value is <tt>site.path</tt>.
def prefix=(value = '/') def prefix=(value = '/')
# Replace :placeholders with '#{embedded options[:lookups]}' # Replace :placeholders with '#{embedded options[:lookups]}'
prefix_call = value.gsub(/:\w+/) { |key| "\#{options[#{key}]}" } prefix_call = value.gsub(/:\w+/) { |key| "\#{options[#{key}]}" }
@ -77,23 +225,53 @@ module ActiveResource
alias_method :set_element_name, :element_name= #:nodoc: alias_method :set_element_name, :element_name= #:nodoc:
alias_method :set_collection_name, :collection_name= #:nodoc: alias_method :set_collection_name, :collection_name= #:nodoc:
# Gets the element path for the given ID. If no query_options are given, they are split from the prefix options: # Gets the element path for the given ID in +id+. 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 URL's (e.g., <tt>:account_id => 19</tt>
# would yield a URL like <tt>/accounts/19/purchases.xml</tt>).
# +query_options+:: A hash to add items to the query string for the request.
#
# ==== Examples
# Post.element_path(1)
# # => /posts/1.xml
#
# Comment.element_path(1, :post_id => 5)
# # => /posts/5/comments/1.xml
#
# Comment.element_path(1, :post_id => 5, :active => 1)
# # => /posts/5/comments/1.xml?active=1
#
# Comment.element_path(1, {:post_id => 5}, {:active => 1})
# # => /posts/5/comments/1.xml?active=1
# #
# Post.element_path(1) # => /posts/1.xml
# Comment.element_path(1, :post_id => 5) # => /posts/5/comments/1.xml
# Comment.element_path(1, :post_id => 5, :active => 1) # => /posts/5/comments/1.xml?active=1
# Comment.element_path(1, {:post_id => 5}, {:active => 1}) # => /posts/5/comments/1.xml?active=1
def element_path(id, prefix_options = {}, query_options = nil) def element_path(id, prefix_options = {}, query_options = nil)
prefix_options, query_options = split_options(prefix_options) if query_options.nil? prefix_options, query_options = split_options(prefix_options) if query_options.nil?
"#{prefix(prefix_options)}#{collection_name}/#{id}.xml#{query_string(query_options)}" "#{prefix(prefix_options)}#{collection_name}/#{id}.xml#{query_string(query_options)}"
end end
# Gets the collection path. If no query_options are given, they are split from the prefix options: # Gets the collection path for the REST resources. 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 URL's (e.g., <tt>:account_id => 19</tt>
# would yield a URL like <tt>/accounts/19/purchases.xml</tt>).
# +query_options+:: A hash to add items to the query string for the request.
#
# ==== Examples
# Post.collection_path
# # => /posts.xml
#
# Comment.collection_path(:post_id => 5)
# # => /posts/5/comments.xml
#
# Comment.collection_path(:post_id => 5, :active => 1)
# # => /posts/5/comments.xml?active=1
#
# Comment.collection_path({:post_id => 5}, {:active => 1})
# # => /posts/5/comments.xml?active=1
# #
# Post.collection_path # => /posts.xml
# Comment.collection_path(:post_id => 5) # => /posts/5/comments.xml
# Comment.collection_path(:post_id => 5, :active => 1) # => /posts/5/comments.xml?active=1
# Comment.collection_path({:post_id => 5}, {:active => 1}) # => /posts/5/comments.xml?active=1
def collection_path(prefix_options = {}, query_options = nil) def collection_path(prefix_options = {}, query_options = nil)
prefix_options, query_options = split_options(prefix_options) if query_options.nil? prefix_options, query_options = split_options(prefix_options) if query_options.nil?
"#{prefix(prefix_options)}#{collection_name}.xml#{query_string(query_options)}" "#{prefix(prefix_options)}#{collection_name}.xml#{query_string(query_options)}"
@ -102,30 +280,77 @@ module ActiveResource
alias_method :set_primary_key, :primary_key= #:nodoc: alias_method :set_primary_key, :primary_key= #:nodoc:
# Create a new resource instance and request to the remote service # Create a new resource instance and request to the remote service
# that it be saved. This is equivalent to the following simultaneous calls: # that it be saved, making it equivalent to the following simultaneous calls:
# #
# ryan = Person.new(:first => 'ryan') # ryan = Person.new(:first => 'ryan')
# ryan.save # ryan.save
# #
# The newly created resource is returned. If a failure has occurred an # The newly created resource is returned. If a failure has occurred an
# exception will be raised (see save). If the resource is invalid and # exception will be raised (see save). If the resource is invalid and
# has not been saved then <tt>resource.valid?</tt> will return <tt>false</tt>, # has not been saved then valid? will return <tt>false</tt>,
# while <tt>resource.new?</tt> will still return <tt>true</tt>. # while new? will still return <tt>true</tt>.
# #
# ==== Examples
# Person.create(:name => 'Jeremy', :email => 'myname@nospam.com', :enabled => true)
# my_person = Person.find(:first)
# my_person.email
# # => myname@nospam.com
#
# dhh = Person.create(:name => 'David', :email => 'dhh@nospam.com', :enabled => true)
# dhh.valid?
# # => true
# dhh.new?
# # => false
#
# # We'll assume that there's a validation that requires the name attribute
# that_guy = Person.create(:name => '', :email => 'thatguy@nospam.com', :enabled => true)
# that_guy.valid?
# # => false
# that_guy.new?
# # => true
#
def create(attributes = {}) def create(attributes = {})
returning(self.new(attributes)) { |res| res.save } returning(self.new(attributes)) { |res| res.save }
end end
# Core method for finding resources. Used similarly to Active Record's find method. # Core method for finding resources. Used similarly to Active Record's find method.
# #
# Person.find(1) # => GET /people/1.xml # ==== Arguments
# Person.find(:all) # => GET /people.xml # The first argument is considered to be the scope of the query. That is, how many
# Person.find(:all, :params => { :title => "CEO" }) # => GET /people.xml?title=CEO # resources are returned from the request. It can be one of the following.
# Person.find(:all, :from => :managers) # => GET /people/managers.xml #
# Person.find(:all, :from => "/companies/1/people.xml") # => GET /companies/1/people.xml # +:one+:: Returns a single resource.
# Person.find(:one, :from => :leader) # => GET /people/leader.xml # +:first+:: Returns the first resource found.
# Person.find(:one, :from => "/companies/1/manager.xml") # => GET /companies/1/manager.xml # +:all+:: Returns every resource that matches the request.
# StreetAddress.find(1, :params => { :person_id => 1 }) # => GET /people/1/street_addresses/1.xml #
# ==== Options
# +from+:: Sets the path or custom method that resources will be fetched from.
# +params+:: Sets query and prefix (nested URL) parameters.
#
# ==== Examples
# Person.find(1)
# # => GET /people/1.xml
#
# Person.find(:all)
# # => GET /people.xml
#
# Person.find(:all, :params => { :title => "CEO" })
# # => GET /people.xml?title=CEO
#
# Person.find(:first, :from => :managers)
# # => GET /people/managers.xml
#
# Person.find(:all, :from => "/companies/1/people.xml")
# # => GET /companies/1/people.xml
#
# Person.find(:one, :from => :leader)
# # => GET /people/leader.xml
#
# Person.find(:one, :from => "/companies/1/manager.xml")
# # => GET /companies/1/manager.xml
#
# StreetAddress.find(1, :params => { :person_id => 1 })
# # => GET /people/1/street_addresses/1.xml
def find(*arguments) def find(*arguments)
scope = arguments.slice!(0) scope = arguments.slice!(0)
options = arguments.slice!(0) || {} options = arguments.slice!(0) || {}
@ -138,11 +363,38 @@ module ActiveResource
end end
end end
# Deletes the resources with the ID in the +id+ parameter.
#
# ==== Options
# All options specify prefix and query parameters.
#
# ==== Examples
# Event.delete(2)
# # => DELETE /events/2
#
# Event.create(:name => 'Free Concert', :location => 'Community Center')
# my_event = Event.find(:first)
# # => Events (id: 7)
# Event.delete(my_event.id)
# # => DELETE /events/7
#
# # Let's assume a request to events/5/cancel.xml
# Event.delete(params[:id])
# # => DELETE /events/5
#
def delete(id, options = {}) def delete(id, options = {})
connection.delete(element_path(id, options)) connection.delete(element_path(id, options))
end end
# Evalutes to <tt>true</tt> if the resource is found. # Asserts the existence of a resource, returning <tt>true</tt> if the resource is found.
#
# ==== Examples
# Note.create(:title => 'Hello, world.', :body => 'Nothing more for now...')
# Note.exists?(1)
# # => true
#
# Note.exists(1349)
# # => false
def exists?(id, options = {}) def exists?(id, options = {})
id && !find_single(id, options).nil? id && !find_single(id, options).nil?
rescue ActiveResource::ResourceNotFound rescue ActiveResource::ResourceNotFound
@ -226,33 +478,79 @@ module ActiveResource
attr_accessor :attributes #:nodoc: attr_accessor :attributes #:nodoc:
attr_accessor :prefix_options #:nodoc: attr_accessor :prefix_options #:nodoc:
# Constructor method for new resources; the optional +attributes+ parameter takes a +Hash+
# of attributes for the new resource.
#
# ==== Examples
# my_course = Course.new
# my_course.name = "Western Civilization"
# my_course.lecturer = "Don Trotter"
# my_course.save
#
# my_other_course = Course.new(:name => "Philosophy: Reason and Being", :lecturer => "Ralph Cling")
# my_other_course.save
def initialize(attributes = {}) def initialize(attributes = {})
@attributes = {} @attributes = {}
@prefix_options = {} @prefix_options = {}
load(attributes) load(attributes)
end end
# Is the resource a new object? # A method to determine if the resource a new object (i.e., it has not been POSTed to the remote service yet).
#
# ==== Examples
# not_new = Computer.create(:brand => 'Apple', :make => 'MacBook', :vendor => 'MacMall')
# not_new.new?
# # => false
#
# is_new = Computer.new(:brand => 'IBM', :make => 'Thinkpad', :vendor => 'IBM')
# is_new.new?
# # => true
#
# is_new.save
# is_new.new?
# # => false
#
def new? def new?
id.nil? id.nil?
end end
# Get the id of the object. # Get the +id+ attribute of the resource.
def id def id
attributes[self.class.primary_key] attributes[self.class.primary_key]
end end
# Set the id of the object. # Set the +id+ attribute of the resource.
def id=(id) def id=(id)
attributes[self.class.primary_key] = id attributes[self.class.primary_key] = id
end 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+. # Test for equality. Resource are equal 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+.
#
# ==== Examples
# ryan = Person.create(:name => 'Ryan')
# jamie = Person.create(:name => 'Jamie')
#
# ryan == jamie
# # => false (Different name attribute and id)
#
# ryan_again = Person.new(:name => 'Ryan')
# ryan == ryan_again
# # => false (ryan_again is new?)
#
# ryans_clone = Person.create(:name => 'Ryan')
# ryan == ryans_clone
# # => false (Different id attributes)
#
# ryans_twin = Person.find(ryan.id)
# ryan == ryans_twin
# # => true
#
def ==(other) def ==(other)
other.equal?(self) || (other.instance_of?(self.class) && !other.new? && other.id == id) other.equal?(self) || (other.instance_of?(self.class) && !other.new? && other.id == id)
end end
# Delegates to == # Tests for equality (delegates to ==).
def eql?(other) def eql?(other)
self == other self == other
end end
@ -263,6 +561,22 @@ module ActiveResource
id.hash id.hash
end end
# Duplicate the current resource without saving it.
#
# ==== Examples
# my_invoice = Invoice.create(:customer => 'That Company')
# next_invoice = my_invoice.dup
# next_invoice.new?
# # => true
#
# next_invoice.save
# next_invoice == my_invoice
# # => false (different id attributes)
#
# my_invoice.customer
# # => That Company
# next_invoice.customer
# # => That Company
def dup def dup
returning new do |resource| returning new do |resource|
resource.attributes = @attributes resource.attributes = @attributes
@ -270,35 +584,137 @@ module ActiveResource
end end
end end
# Delegates to +create+ if a new object, +update+ if its old. If the response to the save includes a body, # A method to save (+POST+) or update (+PUT+) a resource. It delegates to +create+ if a new object,
# it will be assumed that this body is XML for the final object as it looked after the save (which would include # +update+ if it is existing. If the response to the save includes a body, it will be assumed that this body
# attributes like created_at that wasn't part of the original submit). # is XML for the final object as it looked after the save (which would include attributes like +created_at+
# that weren't part of the original submit).
#
# ==== Examples
# my_company = Company.new(:name => 'RoleModel Software', :owner => 'Ken Auer', :size => 2)
# my_company.new?
# # => true
# my_company.save
# # => POST /companies/ (create)
#
# my_company.new?
# # => false
# my_company.size = 10
# my_company.save
# # => PUT /companies/1 (update)
def save def save
new? ? create : update new? ? create : update
end end
# Delete the resource. # Deletes the resource from the remote service.
#
# ==== Examples
# my_id = 3
# my_person = Person.find(my_id)
# my_person.destroy
# Person.find(my_id)
# # => 404 (Resource Not Found)
#
# new_person = Person.create(:name => 'James')
# new_id = new_person.id
# # => 7
# new_person.destroy
# Person.find(new_id)
# # => 404 (Resource Not Found)
def destroy def destroy
connection.delete(element_path, self.class.headers) connection.delete(element_path, self.class.headers)
end end
# Evaluates to <tt>true</tt> if this resource is found. # Evaluates to <tt>true</tt> if this resource is not +new?+ and is
# found on the remote service. Using this method, you can check for
# resources that may have been deleted between the object's instantiation
# and actions on it.
#
# ==== Examples
# Person.create(:name => 'Theodore Roosevelt')
# that_guy = Person.find(:first)
# that_guy.exists?
# # => true
#
# that_lady = Person.new(:name => 'Paul Bean')
# that_lady.exists?
# # => false
#
# guys_id = that_guy.id
# Person.delete(guys_id)
# that_guy.exists?
# # => false
def exists? def exists?
!new? && self.class.exists?(id, :params => prefix_options) !new? && self.class.exists?(id, :params => prefix_options)
end end
# Convert the resource to an XML string # A method to convert the the resource to an XML string.
#
# ==== Options
# The +options+ parameter is handed off to the +to_xml+ method on each
# attribute, so it has the same options as the +to_xml+ methods in
# ActiveSupport.
#
# indent:: Set the indent level for the XML output (default is +2+).
# dasherize:: Boolean option to determine whether or not element names should
# replace underscores with dashes (default is +false+).
# skip_instruct:: Toggle skipping the +instruct!+ call on the XML builder
# that generates the XML declaration (default is +false+).
#
# ==== Examples
# my_group = SubsidiaryGroup.find(:first)
# my_group.to_xml
# # => <?xml version="1.0" encoding="UTF-8"?>
# # <subsidiary_group> [...] </subsidiary_group>
#
# my_group.to_xml(:dasherize => true)
# # => <?xml version="1.0" encoding="UTF-8"?>
# # <subsidiary-group> [...] </subsidiary-group>
#
# my_group.to_xml(:skip_instruct => true)
# # => <subsidiary_group> [...] </subsidiary_group>
def to_xml(options={}) def to_xml(options={})
attributes.to_xml({:root => self.class.element_name}.merge(options)) attributes.to_xml({:root => self.class.element_name}.merge(options))
end end
# Reloads the attributes of this object from the remote web service. # A method to reload the attributes of this object from the remote web service.
#
# ==== Examples
# my_branch = Branch.find(:first)
# my_branch.name
# # => Wislon Raod
#
# # Another client fixes the typo...
#
# my_branch.name
# # => Wislon Raod
# my_branch.reload
# my_branch.name
# # => Wilson Road
def reload def reload
self.load(self.class.find(id, :params => @prefix_options).attributes) self.load(self.class.find(id, :params => @prefix_options).attributes)
end end
# Manually load attributes from a hash. Recursively loads collections of # A method to manually load attributes from a hash. Recursively loads collections of
# resources. # resources. This method is called in initialize and create when a +Hash+ of attributes
# is provided.
#
# ==== Examples
# my_attrs = {:name => 'J&J Textiles', :industry => 'Cloth and textiles'}
#
# the_supplier = Supplier.find(:first)
# the_supplier.name
# # => 'J&M Textiles'
# the_supplier.load(my_attrs)
# the_supplier.name('J&J Textiles')
#
# # These two calls are the same as Supplier.new(my_attrs)
# my_supplier = Supplier.new
# my_supplier.load(my_attrs)
#
# # These three calls are the same as Supplier.create(my_attrs)
# your_supplier = Supplier.new
# your_supplier.load(my_attrs)
# your_supplier.save
def load(attributes) def load(attributes)
raise ArgumentError, "expected an attributes Hash, got #{attributes.inspect}" unless attributes.is_a?(Hash) raise ArgumentError, "expected an attributes Hash, got #{attributes.inspect}" unless attributes.is_a?(Hash)
@prefix_options, attributes = split_options(attributes) @prefix_options, attributes = split_options(attributes)
@ -321,8 +737,9 @@ module ActiveResource
# For checking respond_to? without searching the attributes (which is faster). # For checking respond_to? without searching the attributes (which is faster).
alias_method :respond_to_without_attributes?, :respond_to? alias_method :respond_to_without_attributes?, :respond_to?
# A Person object with a name attribute can ask person.respond_to?("name"), person.respond_to?("name="), and # A method to determine if an object responds to a message (e.g., a method call). In Active Resource, a +Person+ object with a
# person.respond_to?("name?") which will all return true. # +name+ attribute can answer +true+ to +my_person.respond_to?("name")+, +my_person.respond_to?("name=")+, and
# +my_person.respond_to?("name?")+.
def respond_to?(method, include_priv = false) def respond_to?(method, include_priv = false)
method_name = method.to_s method_name = method.to_s
if attributes.nil? if attributes.nil?

View file

@ -5,7 +5,7 @@ require 'uri'
require 'benchmark' require 'benchmark'
module ActiveResource module ActiveResource
class ConnectionError < StandardError class ConnectionError < StandardError # :nodoc:
attr_reader :response attr_reader :response
def initialize(response, message = nil) def initialize(response, message = nil)
@ -18,20 +18,28 @@ module ActiveResource
end end
end end
class ClientError < ConnectionError; end # 4xx Client Error # 4xx Client Error
class ResourceNotFound < ClientError; end # 404 Not Found class ClientError < ConnectionError; end # :nodoc:
class ResourceConflict < ClientError; end # 409 Conflict
# 404 Not Found
class ResourceNotFound < ClientError; end # :nodoc:
# 409 Conflict
class ResourceConflict < ClientError; end # :nodoc:
class ServerError < ConnectionError; end # 5xx Server Error # 5xx Server Error
class ServerError < ConnectionError; end # :nodoc:
# 405 Method Not Allowed # 405 Method Not Allowed
class MethodNotAllowed < ClientError class MethodNotAllowed < ClientError # :nodoc:
def allowed_methods def allowed_methods
@response['Allow'].split(',').map { |verb| verb.strip.downcase.to_sym } @response['Allow'].split(',').map { |verb| verb.strip.downcase.to_sym }
end end
end end
# Class to handle connections to remote services. # Class to handle connections to remote web services.
# This class is used by ActiveResource::Base to interface with REST
# services.
class Connection class Connection
attr_reader :site attr_reader :site
@ -46,6 +54,8 @@ module ActiveResource
end end
end end
# The +site+ parameter is required and will set the +site+
# attribute to the URI for the remote resource service.
def initialize(site) def initialize(site)
raise ArgumentError, 'Missing site URI' unless site raise ArgumentError, 'Missing site URI' unless site
self.site = site self.site = site
@ -84,7 +94,6 @@ module ActiveResource
from_xml_data(Hash.from_xml(response.body)) from_xml_data(Hash.from_xml(response.body))
end end
private private
# Makes request to remote service. # Makes request to remote service.
def request(method, path, *arguments) def request(method, path, *arguments)
@ -152,6 +161,5 @@ module ActiveResource
data data
end end
end end
end end
end end

View file

@ -1,31 +1,34 @@
# Support custom methods and sub-resources for REST. # A module to support custom REST methods and sub-resources, allowing you to break out
# of the "default" REST methods with your own custom resource requests. For example,
# say you use Rails to expose a REST service and configure your routes with:
# #
# Say you on the server configure your routes with: # map.resources :people, :new => { :register => :post },
# :element => { :promote => :put, :deactivate => :delete }
# :collection => { :active => :get }
# #
# map.resources :people, :new => { :register => :post }, # This route set creates routes for the following http requests:
# :element => { :promote => :put, :deactivate => :delete }
# :collection => { :active => :get }
# #
# Which creates routes for the following http requests: # POST /people/new/register.xml #=> PeopleController.register
# PUT /people/1/promote.xml #=> PeopleController.promote with :id => 1
# DELETE /people/1/deactivate.xml #=> PeopleController.deactivate with :id => 1
# GET /people/active.xml #=> PeopleController.active
# #
# POST /people/new/register.xml #=> PeopleController.register # Using this module, Active Resource can use these custom REST methods just like the
# PUT /people/1/promote.xml #=> PeopleController.promote with :id => 1 # standard methods.
# DELETE /people/1/deactivate.xml #=> PeopleController.deactivate with :id => 1
# GET /people/active.xml #=> PeopleController.active
#
# This module provides the ability for Active Resource to call these
# custom REST methods and get the response back.
# #
# class Person < ActiveResource::Base # class Person < ActiveResource::Base
# self.site = "http://37s.sunrise.i:3000" # self.site = "http://37s.sunrise.i:3000"
# end # end
# #
# Person.new(:name => 'Ryan).post(:register) #=> { :id => 1, :name => 'Ryan' } # Person.new(:name => 'Ryan).post(:register) # POST /people/new/register.xml
# # => { :id => 1, :name => 'Ryan' }
# #
# Person.find(1).put(:promote, :position => 'Manager') # Person.find(1).put(:promote, :position => 'Manager') # PUT /people/1/promote.xml
# Person.find(1).delete(:deactivate) # Person.find(1).delete(:deactivate) # DELETE /people/1/deactivate.xml
#
# Person.get(:active) # GET /people/active.xml
# # => [{:id => 1, :name => 'Ryan'}, {:id => 2, :name => 'Joe'}]
# #
# Person.get(:active) #=> [{:id => 1, :name => 'Ryan'}, {:id => 2, :name => 'Joe'}]
module ActiveResource module ActiveResource
module CustomMethods module CustomMethods
def self.included(within) def self.included(within)

View file

@ -14,23 +14,76 @@ module ActiveResource
@base, @errors = base, {} @base, @errors = base, {}
end end
# Add an error to the base Active Resource object rather than an attribute.
#
# ==== Examples
# my_folder = Folder.find(1)
# my_folder.errors.add_to_base("You can't edit an existing folder")
# my_folder.errors.on_base
# # => "You can't edit an existing folder"
#
# my_folder.errors.add_to_base("This folder has been tagged as frozen")
# my_folder.valid?
# # => false
# my_folder.errors.on_base
# # => ["You can't edit an existing folder", "This folder has been tagged as frozen"]
#
def add_to_base(msg) def add_to_base(msg)
add(:base, msg) add(:base, msg)
end end
# Adds an error to an Active Resource object's attribute (named for the +attribute+ parameter)
# with the error message in +msg+.
#
# ==== Examples
# my_resource = Node.find(1)
# my_resource.errors.add('name', 'can not be "base"') if my_resource.name == 'base'
# my_resource.errors.on('name')
# # => 'can not be "base"!'
#
# my_resource.errors.add('desc', 'can not be blank') if my_resource.desc == ''
# my_resource.valid?
# # => false
# my_resource.errors.on('desc')
# # => 'can not be blank!'
#
def add(attribute, msg) def add(attribute, msg)
@errors[attribute.to_s] = [] if @errors[attribute.to_s].nil? @errors[attribute.to_s] = [] if @errors[attribute.to_s].nil?
@errors[attribute.to_s] << msg @errors[attribute.to_s] << msg
end end
# Returns true if the specified +attribute+ has errors associated with it. # Returns true if the specified +attribute+ has errors associated with it.
#
# ==== Examples
# my_resource = Disk.find(1)
# my_resource.errors.add('location', 'must be Main') unless my_resource.location == 'Main'
# my_resource.errors.on('location')
# # => 'must be Main!'
#
# my_resource.errors.invalid?('location')
# # => true
# my_resource.errors.invalid?('name')
# # => false
def invalid?(attribute) def invalid?(attribute)
!@errors[attribute.to_s].nil? !@errors[attribute.to_s].nil?
end end
# * Returns nil, if no errors are associated with the specified +attribute+. # A method to return the errors associated with +attribute+, which returns nil, if no errors are
# * Returns the error message, if one error is associated with the specified +attribute+. # associated with the specified +attribute+, the error message if one error is associated with the specified +attribute+,
# * Returns an array of error messages, if more than one error is associated with the specified +attribute+. # or an array of error messages if more than one error is associated with the specified +attribute+.
#
# ==== Examples
# my_person = Person.new(params[:person])
# my_person.errors.on('login')
# # => nil
#
# my_person.errors.add('login', 'can not be empty') if my_person.login == ''
# my_person.errors.on('login')
# # => 'can not be empty'
#
# my_person.errors.add('login', 'can not be longer than 10 characters') if my_person.login.length > 10
# my_person.errors.on('login')
# # => ['can not be empty', 'can not be longer than 10 characters']
def on(attribute) def on(attribute)
errors = @errors[attribute.to_s] errors = @errors[attribute.to_s]
return nil if errors.nil? return nil if errors.nil?
@ -39,23 +92,72 @@ module ActiveResource
alias :[] :on alias :[] :on
# Returns errors assigned to base object through add_to_base according to the normal rules of on(attribute). # A method to return errors assigned to +base+ object through add_to_base, which returns nil, if no errors are
# associated with the specified +attribute+, the error message if one error is associated with the specified +attribute+,
# or an array of error messages if more than one error is associated with the specified +attribute+.
#
# ==== Examples
# my_account = Account.find(1)
# my_account.errors.on_base
# # => nil
#
# my_account.errors.add_to_base("This account is frozen")
# my_account.errors.on_base
# # => "This account is frozen"
#
# my_account.errors.add_to_base("This account has been closed")
# my_account.errors.on_base
# # => ["This account is frozen", "This account has been closed"]
#
def on_base def on_base
on(:base) on(:base)
end end
# Yields each attribute and associated message per error added. # Yields each attribute and associated message per error added.
#
# ==== Examples
# my_person = Person.new(params[:person])
#
# my_person.errors.add('login', 'can not be empty') if my_person.login == ''
# my_person.errors.add('password', 'can not be empty') if my_person.password == ''
# messages = ''
# my_person.errors.each {|attr, msg| messages += attr.humanize + " " + msg + "<br />"}
# messages
# # => "Login can not be empty<br />Password can not be empty<br />"
#
def each def each
@errors.each_key { |attr| @errors[attr].each { |msg| yield attr, msg } } @errors.each_key { |attr| @errors[attr].each { |msg| yield attr, msg } }
end end
# Yields each full error message added. So Person.errors.add("first_name", "can't be empty") will be returned # Yields each full error message added. So Person.errors.add("first_name", "can't be empty") will be returned
# through iteration as "First name can't be empty". # through iteration as "First name can't be empty".
#
# ==== Examples
# my_person = Person.new(params[:person])
#
# my_person.errors.add('login', 'can not be empty') if my_person.login == ''
# my_person.errors.add('password', 'can not be empty') if my_person.password == ''
# messages = ''
# my_person.errors.each_full {|msg| messages += msg + "<br/>"}
# messages
# # => "Login can not be empty<br />Password can not be empty<br />"
#
def each_full def each_full
full_messages.each { |msg| yield msg } full_messages.each { |msg| yield msg }
end end
# Returns all the full error messages in an array. # Returns all the full error messages in an array.
#
# ==== Examples
# my_person = Person.new(params[:person])
#
# my_person.errors.add('login', 'can not be empty') if my_person.login == ''
# my_person.errors.add('password', 'can not be empty') if my_person.password == ''
# messages = ''
# my_person.errors.full_messages.each {|msg| messages += msg + "<br/>"}
# messages
# # => "Login can not be empty<br />Password can not be empty<br />"
#
def full_messages def full_messages
full_messages = [] full_messages = []
@ -79,6 +181,17 @@ module ActiveResource
# Returns the total number of errors added. Two errors added to the same attribute will be counted as such # Returns the total number of errors added. Two errors added to the same attribute will be counted as such
# with this as well. # with this as well.
#
# ==== Examples
# my_person = Person.new(params[:person])
# my_person.errors.size
# # => 0
#
# my_person.errors.add('login', 'can not be empty') if my_person.login == ''
# my_person.errors.add('password', 'can not be empty') if my_person.password == ''
# my_person.error.size
# # => 2
#
def size def size
@errors.values.inject(0) { |error_count, attribute| error_count + attribute.size } @errors.values.inject(0) { |error_count, attribute| error_count + attribute.size }
end end
@ -86,6 +199,7 @@ module ActiveResource
alias_method :count, :size alias_method :count, :size
alias_method :length, :size alias_method :length, :size
# Grabs errors from the XML response.
def from_xml(xml) def from_xml(xml)
clear clear
humanized_attributes = @base.attributes.keys.inject({}) { |h, attr_name| h.update(attr_name.humanize => attr_name) } humanized_attributes = @base.attributes.keys.inject({}) { |h, attr_name| h.update(attr_name.humanize => attr_name) }
@ -102,9 +216,12 @@ module ActiveResource
end end
end end
# Module to allow validation of ActiveResource objects, which are implemented by overriding +Base#validate+ or its variants. # Module to allow validation of ActiveResource objects, which creates an Errors instance for every resource.
# Each of these methods can inspect the state of the object, which usually means ensuring that a number of # Methods are implemented by overriding +Base#validate+ or its variants Each of these methods can inspect
# attributes have a certain value (such as not empty, within a given range, matching a certain regular expression). For example: # 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 and so on).
#
# ==== Example
# #
# class Person < ActiveResource::Base # class Person < ActiveResource::Base
# self.site = "http://www.localhost.com:3000/" # self.site = "http://www.localhost.com:3000/"
@ -133,7 +250,6 @@ module ActiveResource
# person.attributes = { "last_name" => "Halpert", "phone_number" => "555-5555" } # person.attributes = { "last_name" => "Halpert", "phone_number" => "555-5555" }
# person.save # => true (and person is now saved to the remote service) # person.save # => true (and person is now saved to the remote service)
# #
# An Errors object is automatically created for every resource.
module Validations module Validations
def self.included(base) # :nodoc: def self.included(base) # :nodoc:
base.class_eval do base.class_eval do
@ -141,6 +257,7 @@ module ActiveResource
end end
end end
# Validate a resource and save (POST) it to the remote web service.
def save_with_validation def save_with_validation
save_without_validation save_without_validation
true true
@ -149,6 +266,16 @@ module ActiveResource
false false
end end
# Checks for errors on an object (i.e., is resource.errors empty?).
#
# ==== Examples
# my_person = Person.create(params[:person])
# my_person.valid?
# # => true
#
# my_person.errors.add('login', 'can not be empty') if my_person.login == ''
# my_person.valid?
# # => false
def valid? def valid?
errors.empty? errors.empty?
end end