From ae4838fff20857b11b1092d82b34ef7d32edfcab Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 23 Jun 2007 17:29:54 +0000 Subject: [PATCH] Big documentation upgrade for ARes (closes #8694) [jeremymcanally] git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@7098 5ecf4fe2-1ee6-0310-87b1-e25e094e27de --- activeresource/README | 144 ++--- activeresource/lib/active_resource/base.rb | 511 ++++++++++++++++-- .../lib/active_resource/connection.rb | 28 +- .../lib/active_resource/custom_methods.rb | 37 +- .../lib/active_resource/validations.rb | 143 ++++- 5 files changed, 674 insertions(+), 189 deletions(-) diff --git a/activeresource/README b/activeresource/README index 696b5e4fed..042af28e06 100644 --- a/activeresource/README +++ b/activeresource/README @@ -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 -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). +Active Resource (ARes) connects business objects and Representational State Transfer (REST) +web services. It implements object-relational mapping for REST webservices to provide transparent +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 -class variable: +Active Resource attempts to provide a coherent wrapper object-relational mapping for REST +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 site class variable to it: 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 +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. # 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 +As you can see, the methods are quite similar to Active Record's methods for dealing with database +records. But rather than dealing with - # Resource creation can also use the convenience create method which - # will request a resource save after instantiation. - ryan = Person.create(:first => 'Ryan', :last => 'Daigle') - ryan.exists? #=> true +==== Protocol - # 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 +Active Resource is built on a standard XML format for requesting and submitting resources over HTTP. It mirrors the RESTful routing +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: - # And destruction - # DELETE http://api.people.com:3000/people/1.xml - # - ryan = Person.find(1) - ryan.destroy #=> true # Or Person.delete(ryan.id) +* GET requests are used for finding and retrieving resources. +* POST requests are used to create new resources. +* PUT requests are used to update existing resources. +* DELETE requests are used to delete resources. - -=== 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. +For more information on how this protocol works with Active Resource, see the ActiveResource::Base documentation; +for more general information on REST web services, see the article here[http://en.wikipedia.org/wiki/Representational_State_Transfer]. ==== 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 -=== 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): - # First cannot be empty - # - 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. diff --git a/activeresource/lib/active_resource/base.rb b/activeresource/lib/active_resource/base.rb index b4a9888ee5..0255eefc55 100644 --- a/activeresource/lib/active_resource/base.rb +++ b/activeresource/lib/active_resource/base.rb @@ -3,12 +3,155 @@ require 'cgi' require 'set' 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 http://api.people.com:3000/people/, 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): + # # First cannot be empty + # # + # + # 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 - # The logger for diagnosing and tracing ARes calls. + # The logger for diagnosing and tracing Active Resource calls. cattr_accessor :logger 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 if defined?(@site) @site @@ -17,13 +160,16 @@ module ActiveResource 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) @connection = nil @site = create_site_uri_from(site) 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) @connection = Connection.new(site) if refresh || @connection.nil? @connection @@ -40,8 +186,8 @@ module ActiveResource 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 + # Gets the prefix for a resource's nested URL (e.g., prefix/collectionname/1.xml) + # This method is regenerated at runtime based on what the prefix is set to. def prefix(options={}) default = site.path default << '/' unless default[-1..-1] == '/' @@ -50,13 +196,15 @@ module ActiveResource prefix(options) 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 prefix # generate #prefix and #prefix_source methods first prefix_source end - # Sets the resource prefix - # prefix/collectionname/1.xml + # Sets the prefix for a resource's nested URL (e.g., prefix/collectionname/1.xml). + # Default value is site.path. def prefix=(value = '/') # Replace :placeholders with '#{embedded options[:lookups]}' 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_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., :account_id => 19 + # would yield a URL like /accounts/19/purchases.xml). + # +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) prefix_options, query_options = split_options(prefix_options) if query_options.nil? "#{prefix(prefix_options)}#{collection_name}/#{id}.xml#{query_string(query_options)}" 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., :account_id => 19 + # would yield a URL like /accounts/19/purchases.xml). + # +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) prefix_options, query_options = split_options(prefix_options) if query_options.nil? "#{prefix(prefix_options)}#{collection_name}.xml#{query_string(query_options)}" @@ -102,30 +280,77 @@ module ActiveResource alias_method :set_primary_key, :primary_key= #:nodoc: # 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.save # # The newly created resource is returned. If a failure has occurred an # exception will be raised (see save). If the resource is invalid and - # has not been saved then resource.valid? will return false, - # while resource.new? will still return true. - # + # has not been saved then valid? will return false, + # while new? will still return true. + # + # ==== 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 = {}) returning(self.new(attributes)) { |res| res.save } end # Core method for finding resources. Used similarly to Active Record's find method. # - # 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(:all, :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 + # ==== Arguments + # The first argument is considered to be the scope of the query. That is, how many + # resources are returned from the request. It can be one of the following. + # + # +:one+:: Returns a single resource. + # +:first+:: Returns the first resource found. + # +:all+:: Returns every resource that matches the request. + # + # ==== 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) scope = arguments.slice!(0) options = arguments.slice!(0) || {} @@ -138,11 +363,38 @@ module ActiveResource 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 = {}) connection.delete(element_path(id, options)) end - # Evalutes to true if the resource is found. + # Asserts the existence of a resource, returning true 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 = {}) id && !find_single(id, options).nil? rescue ActiveResource::ResourceNotFound @@ -226,33 +478,79 @@ module ActiveResource attr_accessor :attributes #: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 = {}) @attributes = {} @prefix_options = {} load(attributes) 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? id.nil? end - # Get the id of the object. + # Get the +id+ attribute of the resource. def id attributes[self.class.primary_key] end - # Set the id of the object. + # Set the +id+ attribute of the resource. 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+. + # 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) other.equal?(self) || (other.instance_of?(self.class) && !other.new? && other.id == id) end - # Delegates to == + # Tests for equality (delegates to ==). def eql?(other) self == other end @@ -263,6 +561,22 @@ module ActiveResource id.hash 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 returning new do |resource| resource.attributes = @attributes @@ -270,35 +584,137 @@ module ActiveResource end end - # Delegates to +create+ if a new object, +update+ if its old. If the response to the save includes a body, - # it will be assumed that this body is XML for the final object as it looked after the save (which would include - # attributes like created_at that wasn't part of the original submit). + # A method to save (+POST+) or update (+PUT+) a resource. It delegates to +create+ if a new object, + # +update+ if it is existing. If the response to the save includes a body, it will be assumed that this body + # 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 new? ? create : update 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 connection.delete(element_path, self.class.headers) end - # Evaluates to true if this resource is found. + # Evaluates to true 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? !new? && self.class.exists?(id, :params => prefix_options) 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 + # # => + # # [...] + # + # my_group.to_xml(:dasherize => true) + # # => + # # [...] + # + # my_group.to_xml(:skip_instruct => true) + # # => [...] def to_xml(options={}) attributes.to_xml({:root => self.class.element_name}.merge(options)) 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 self.load(self.class.find(id, :params => @prefix_options).attributes) end - # Manually load attributes from a hash. Recursively loads collections of - # resources. + # A method to manually load attributes from a hash. Recursively loads collections of + # 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) raise ArgumentError, "expected an attributes Hash, got #{attributes.inspect}" unless attributes.is_a?(Hash) @prefix_options, attributes = split_options(attributes) @@ -321,8 +737,9 @@ module ActiveResource # For checking respond_to? without searching the attributes (which is faster). 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 - # person.respond_to?("name?") which will all return true. + # A method to determine if an object responds to a message (e.g., a method call). In Active Resource, a +Person+ object with a + # +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) method_name = method.to_s if attributes.nil? diff --git a/activeresource/lib/active_resource/connection.rb b/activeresource/lib/active_resource/connection.rb index 8245c797b3..5aef6f4d42 100644 --- a/activeresource/lib/active_resource/connection.rb +++ b/activeresource/lib/active_resource/connection.rb @@ -5,7 +5,7 @@ require 'uri' require 'benchmark' module ActiveResource - class ConnectionError < StandardError + class ConnectionError < StandardError # :nodoc: attr_reader :response def initialize(response, message = nil) @@ -18,20 +18,28 @@ module ActiveResource end end - class ClientError < ConnectionError; end # 4xx Client Error - class ResourceNotFound < ClientError; end # 404 Not Found - class ResourceConflict < ClientError; end # 409 Conflict + # 4xx Client Error + class ClientError < ConnectionError; end # :nodoc: + + # 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 - class MethodNotAllowed < ClientError + class MethodNotAllowed < ClientError # :nodoc: def allowed_methods @response['Allow'].split(',').map { |verb| verb.strip.downcase.to_sym } 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 attr_reader :site @@ -46,6 +54,8 @@ module ActiveResource end end + # The +site+ parameter is required and will set the +site+ + # attribute to the URI for the remote resource service. def initialize(site) raise ArgumentError, 'Missing site URI' unless site self.site = site @@ -84,7 +94,6 @@ module ActiveResource from_xml_data(Hash.from_xml(response.body)) end - private # Makes request to remote service. def request(method, path, *arguments) @@ -152,6 +161,5 @@ module ActiveResource data end end - end -end +end \ No newline at end of file diff --git a/activeresource/lib/active_resource/custom_methods.rb b/activeresource/lib/active_resource/custom_methods.rb index 2e382cdd7e..a8857b8461 100644 --- a/activeresource/lib/active_resource/custom_methods.rb +++ b/activeresource/lib/active_resource/custom_methods.rb @@ -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 }, -# :element => { :promote => :put, :deactivate => :delete } -# :collection => { :active => :get } +# This route set creates routes for the following http requests: # -# 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 -# 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 -# -# This module provides the ability for Active Resource to call these -# custom REST methods and get the response back. +# Using this module, Active Resource can use these custom REST methods just like the +# standard methods. # # class Person < ActiveResource::Base # self.site = "http://37s.sunrise.i:3000" # 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).delete(:deactivate) +# Person.find(1).put(:promote, :position => 'Manager') # PUT /people/1/promote.xml +# 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 CustomMethods def self.included(within) diff --git a/activeresource/lib/active_resource/validations.rb b/activeresource/lib/active_resource/validations.rb index 76aa7d2f00..57d2ae559d 100644 --- a/activeresource/lib/active_resource/validations.rb +++ b/activeresource/lib/active_resource/validations.rb @@ -14,23 +14,76 @@ module ActiveResource @base, @errors = base, {} 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) add(:base, msg) 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) @errors[attribute.to_s] = [] if @errors[attribute.to_s].nil? @errors[attribute.to_s] << msg end # 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) !@errors[attribute.to_s].nil? end - # * Returns nil, if no errors are associated with the specified +attribute+. - # * Returns 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+. + # A method to return the errors associated with +attribute+, 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_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) errors = @errors[attribute.to_s] return nil if errors.nil? @@ -39,23 +92,72 @@ module ActiveResource 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 on(:base) end # 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 + "
"} + # messages + # # => "Login can not be empty
Password can not be empty
" + # def each @errors.each_key { |attr| @errors[attr].each { |msg| yield attr, msg } } end # 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". + # + # ==== 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 + "
"} + # messages + # # => "Login can not be empty
Password can not be empty
" + # def each_full full_messages.each { |msg| yield msg } end # 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 + "
"} + # messages + # # => "Login can not be empty
Password can not be empty
" + # def 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 # 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 @errors.values.inject(0) { |error_count, attribute| error_count + attribute.size } end @@ -86,6 +199,7 @@ module ActiveResource alias_method :count, :size alias_method :length, :size + # Grabs errors from the XML response. def from_xml(xml) clear humanized_attributes = @base.attributes.keys.inject({}) { |h, attr_name| h.update(attr_name.humanize => attr_name) } @@ -102,9 +216,12 @@ 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: + # Module to allow validation of ActiveResource objects, which creates an Errors instance for every resource. + # Methods 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 and so on). + # + # ==== Example # # class Person < ActiveResource::Base # self.site = "http://www.localhost.com:3000/" @@ -133,7 +250,6 @@ module ActiveResource # 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 @@ -141,6 +257,7 @@ module ActiveResource end end + # Validate a resource and save (POST) it to the remote web service. def save_with_validation save_without_validation true @@ -149,6 +266,16 @@ module ActiveResource false 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? errors.empty? end