Mega documentation patches. #7025, #7069 [rwdaigle]

git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@5962 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
This commit is contained in:
Rick Olson 2007-01-16 06:34:10 +00:00
parent 56c5535466
commit 932e7b003c
7 changed files with 336 additions and 25 deletions

@ -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]

@ -1 +1,230 @@
= Active Resource -- Object-oriented REST services
= 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.

@ -8,6 +8,7 @@ class Base
cattr_accessor :logger
class << self
# Gets the URI of the resource's site
def site
if defined?(@site)
@site
@ -16,20 +17,24 @@ def site
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 @@ def prefix(options={})
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 @@ def prefix(options={}) "#{prefix_call}" end
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 @@ def delete(id)
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 @@ def exists?(id, options = {})
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 @@ def prefix_parameters
@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 @@ def query_string(options)
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 @@ def initialize(attributes = {}, prefix_options = {})
@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 @@ def hash
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 @@ def connection(refresh = false)
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 @@ def collection_path(options = nil)
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 @@ def find_or_create_resource_for(name)
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

@ -24,7 +24,7 @@ class ResourceConflict < ClientError; end # 409 Conflict
class ServerError < ConnectionError; end # 5xx Server Error
# Class to handle connections to remote services.
class Connection
attr_reader :site
@ -44,27 +44,37 @@ def initialize(site)
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 @@ def request(method, path, *arguments)
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 @@ def handle_response(response)
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 @@ def http
@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

@ -1,7 +1,7 @@
require 'active_resource/connection'
module ActiveResource
class InvalidRequestError < StandardError; end
class InvalidRequestError < StandardError; end #:nodoc:
class HttpMock
class Responder

@ -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)

@ -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 @@ def from_xml(xml)
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