require 'active_resource/connection' 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 Active Resource calls. cattr_accessor :logger class << self # 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 elsif superclass != Object and superclass.site superclass.site.dup.freeze end end # 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 = site.nil? ? nil : create_site_uri_from(site) end # 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) if defined?(@connection) or superclass == Object @connection = Connection.new(site) if refresh || @connection.nil? @connection else superclass.connection end end def headers @headers ||= {} end # Do not include any modules in the default element name. This makes it easier to seclude ARes objects # in a separate namespace without having to set element_name repeatedly. attr_accessor_with_default(:element_name) { to_s.split("::").last.underscore } #:nodoc: attr_accessor_with_default(:collection_name) { element_name.pluralize } #:nodoc: attr_accessor_with_default(:primary_key, 'id') #:nodoc: # 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] == '/' # generate the actual method based on the current site path self.prefix = default 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 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}]}" } # Redefine the new methods. code = <<-end_code def prefix_source() "#{value}" end def prefix(options={}) "#{prefix_call}" end end_code silence_warnings { instance_eval code, __FILE__, __LINE__ } rescue logger.error "Couldn't set prefix: #{$!}\n #{code}" raise end alias_method :set_prefix, :prefix= #:nodoc: alias_method :set_element_name, :element_name= #:nodoc: alias_method :set_collection_name, :collection_name= #:nodoc: # 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 # 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 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 # 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)}" end alias_method :set_primary_key, :primary_key= #:nodoc: # Create a new resource instance and request to the remote service # 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 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. # # ==== 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) || {} case scope when :all then find_every(options) when :first then find_every(options).first when :one then find_one(options) else find_single(scope, options) 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 # 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 false end private # Find every resource def find_every(options) case from = options[:from] when Symbol instantiate_collection(get(from, options[:params])) when String path = "#{from}#{query_string(options[:params])}" instantiate_collection(connection.get(path, headers) || []) else prefix_options, query_options = split_options(options[:params]) path = collection_path(prefix_options, query_options) instantiate_collection( (connection.get(path, headers) || []), prefix_options ) end end # Find a single resource from a one-off URL def find_one(options) case from = options[:from] when Symbol instantiate_record(get(from, options[:params])) when String path = "#{from}#{query_string(options[:params])}" instantiate_record(connection.get(path, headers)) end end # Find a single resource from the default URL def find_single(scope, options) prefix_options, query_options = split_options(options[:params]) path = element_path(scope, prefix_options, query_options) instantiate_record(connection.get(path, headers), prefix_options) end def instantiate_collection(collection, prefix_options = {}) collection.collect! { |record| instantiate_record(record, prefix_options) } end def instantiate_record(record, prefix_options = {}) returning new(record) do |resource| resource.prefix_options = prefix_options end 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 # contains a set of the current prefix parameters. 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) "?#{options.to_query}" unless options.nil? || options.empty? end # split an option hash into two hashes, one containing the prefix options, # and the other containing the leftovers. def split_options(options = {}) prefix_options, query_options = {}, {} (options || {}).each do |key, value| next if key.blank? (prefix_parameters.include?(key.to_sym) ? prefix_options : query_options)[key.to_sym] = value end [ prefix_options, query_options ] end end 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 # 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+ attribute of the resource. def id attributes[self.class.primary_key] end # Set the +id+ attribute of the resource. def id=(id) attributes[self.class.primary_key] = id end # 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 # Tests for equality (delegates to ==). def eql?(other) self == other end # Delegates to id in order to allow two resources of the same type and id to work with something like: # [Person.find(1), Person.find(2)] & [Person.find(1), Person.find(4)] # => [Person.find(1)] def hash 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 resource.prefix_options = @prefix_options end end # 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 # 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 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 # 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 # 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 # 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) attributes.each do |key, value| @attributes[key.to_s] = case value when Array resource = find_or_create_resource_for_collection(key) value.map { |attrs| resource.new(attrs) } when Hash resource = find_or_create_resource_for(key) resource.new(value) else value.dup rescue value end end self end # For checking respond_to? without searching the attributes (which is faster). alias_method :respond_to_without_attributes?, :respond_to? # 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? return super elsif attributes.has_key?(method_name) return true elsif ['?','='].include?(method_name.last) && attributes.has_key?(method_name.first(-1)) return true end # super must be called at the end of the method, because the inherited respond_to? # would return true for generated readers, even if the attribute wasn't present super end protected def connection(refresh = false) self.class.connection(refresh) end # Update the resource on the remote service. def update returning connection.put(element_path(prefix_options), to_xml, self.class.headers) do |response| load_attributes_from_response(response) end end # Create (i.e., save to the remote service) the new resource. def create returning connection.post(collection_path, to_xml, self.class.headers) do |response| self.id = id_from_response(response) load_attributes_from_response(response) end end def load_attributes_from_response(response) if response['Content-size'] != "0" && response.body.strip.size > 0 load(connection.xml_from_response(response)) end end # Takes a response from a typical create post and pulls the ID out def id_from_response(response) response['Location'][/\/([^\/]*?)(\.\w+)?$/, 1] end def element_path(options = nil) self.class.element_path(id, options || prefix_options) end def collection_path(options = nil) self.class.collection_path(options || prefix_options) 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 # FIXME: Make it generic enough to support any depth of module nesting if (ancestors = self.class.name.split("::")).size > 1 begin ancestors.first.constantize.const_get(resource_name) rescue NameError self.class.const_get(resource_name) end else self.class.const_get(resource_name) end rescue NameError resource = self.class.const_set(resource_name, Class.new(ActiveResource::Base)) resource.prefix = self.class.prefix resource.site = self.class.site resource end def split_options(options = {}) self.class.send(:split_options, options) end def method_missing(method_symbol, *arguments) #:nodoc: method_name = method_symbol.to_s case method_name.last when "=" attributes[method_name.first(-1)] = arguments.first when "?" attributes[method_name.first(-1)] else attributes.has_key?(method_name) ? attributes[method_name] : super end end end end