Ruby on Rails | Screencasts | Download | Documentation | Weblog | Community | Source

root/trunk/activeresource/lib/active_resource/base.rb

Revision 9145, 36.5 kB (checked in by bitsweat, 3 months ago)

Don't shadow attrs var

Line 
1 require 'active_resource/connection'
2 require 'cgi'
3 require 'set'
4
5 module ActiveResource
6   # ActiveResource::Base is the main class for mapping RESTful resources as models in a Rails application.
7   #
8   # For an outline of what Active Resource is capable of, see link:files/vendor/rails/activeresource/README.html.
9   #
10   # == Automated mapping
11   #
12   # Active Resource objects represent your RESTful resources as manipulatable Ruby objects.  To map resources
13   # to Ruby objects, Active Resource only needs a class name that corresponds to the resource name (e.g., the class
14   # Person maps to the resources people, very similarly to Active Record) and a +site+ value, which holds the
15   # URI of the resources.
16   #
17   #     class Person < ActiveResource::Base
18   #       self.site = "http://api.people.com:3000/"
19   #     end
20   #
21   # Now the Person class is mapped to RESTful resources located at <tt>http://api.people.com:3000/people/</tt>, and
22   # you can now use Active Resource's lifecycles methods to manipulate resources. 
23   #
24   # == Lifecycle methods
25   #
26   # Active Resource exposes methods for creating, finding, updating, and deleting resources
27   # from REST web services.
28   #
29   #   ryan = Person.new(:first => 'Ryan', :last => 'Daigle')
30   #   ryan.save  #=> true
31   #   ryan.id  #=> 2
32   #   Person.exists?(ryan.id)  #=> true
33   #   ryan.exists?  #=> true
34   #
35   #   ryan = Person.find(1)
36   #   # => Resource holding our newly created Person object
37   #
38   #   ryan.first = 'Rizzle'
39   #   ryan.save  #=> true
40   #
41   #   ryan.destroy  #=> true
42   #
43   # As you can see, these are very similar to Active Record's lifecycle methods for database records.
44   # You can read more about each of these methods in their respective documentation.
45   #
46   # === Custom REST methods
47   #
48   # Since simple CRUD/lifecycle methods can't accomplish every task, Active Resource also supports
49   # defining your own custom REST methods. To invoke them, Active Resource provides the <tt>get</tt>,
50   # <tt>post</tt>, <tt>put</tt> and <tt>delete</tt> methods where you can specify a custom REST method
51   # name to invoke.
52   #
53   #   # POST to the custom 'register' REST method, i.e. POST /people/new/register.xml.
54   #   Person.new(:name => 'Ryan').post(:register)
55   #   # => { :id => 1, :name => 'Ryan', :position => 'Clerk' }
56   #
57   #   # PUT an update by invoking the 'promote' REST method, i.e. PUT /people/1/promote.xml?position=Manager.
58   #   Person.find(1).put(:promote, :position => 'Manager')
59   #   # => { :id => 1, :name => 'Ryan', :position => 'Manager' }
60   #
61   #   # GET all the positions available, i.e. GET /people/positions.xml.
62   #   Person.get(:positions)
63   #   # => [{:name => 'Manager'}, {:name => 'Clerk'}]
64   #
65   #   # DELETE to 'fire' a person, i.e. DELETE /people/1/fire.xml.
66   #   Person.find(1).delete(:fire)
67   #
68   # For more information on using custom REST methods, see the
69   # ActiveResource::CustomMethods documentation.
70   #
71   # == Validations
72   #
73   # You can validate resources client side by overriding validation methods in the base class.
74   #
75   #     class Person < ActiveResource::Base
76   #        self.site = "http://api.people.com:3000/"
77   #        protected
78   #          def validate
79   #            errors.add("last", "has invalid characters") unless last =~ /[a-zA-Z]*/
80   #          end
81   #     end
82   #
83   # See the ActiveResource::Validations documentation for more information.
84   #
85   # == Authentication
86   #
87   # Many REST APIs will require authentication, usually in the form of basic
88   # HTTP authentication.  Authentication can be specified by:
89   # * putting the credentials in the URL for the +site+ variable.
90   #
91   #    class Person < ActiveResource::Base
92   #      self.site = "http://ryan:password@api.people.com:3000/"
93   #    end
94   #
95   # * defining +user+ and/or +password+ variables
96   #
97   #    class Person < ActiveResource::Base
98   #      self.site = "http://api.people.com:3000/"
99   #      self.user = "ryan"
100   #      self.password = "password"
101   #    end
102   #
103   # For obvious security reasons, it is probably best if such services are available
104   # over HTTPS.
105   #
106   # Note: Some values cannot be provided in the URL passed to site.  e.g. email addresses
107   # as usernames.  In those situations you should use the seperate user and password option.
108   # == Errors & Validation
109   #
110   # Error handling and validation is handled in much the same manner as you're used to seeing in
111   # Active Record.  Both the response code in the HTTP response and the body of the response are used to
112   # indicate that an error occurred.
113   #
114   # === Resource errors
115   #
116   # When a GET is requested for a resource that does not exist, the HTTP <tt>404</tt> (Resource Not Found)
117   # response code will be returned from the server which will raise an ActiveResource::ResourceNotFound
118   # exception.
119   #
120   #   # GET http://api.people.com:3000/people/999.xml
121   #   ryan = Person.find(999) # => Raises ActiveResource::ResourceNotFound
122   #   # => Response = 404
123   #
124   # <tt>404</tt> is just one of the HTTP error response codes that ActiveResource will handle with its own exception. The
125   # following HTTP response codes will also result in these exceptions:
126   #
127   # 200 - 399:: Valid response, no exception
128   # 404:: ActiveResource::ResourceNotFound
129   # 409:: ActiveResource::ResourceConflict
130   # 422:: ActiveResource::ResourceInvalid (rescued by save as validation errors)
131   # 401 - 499:: ActiveResource::ClientError
132   # 500 - 599:: ActiveResource::ServerError
133   #
134   # These custom exceptions allow you to deal with resource errors more naturally and with more precision
135   # rather than returning a general HTTP error.  For example:
136   #
137   #   begin
138   #     ryan = Person.find(my_id)
139   #   rescue ActiveResource::ResourceNotFound
140   #     redirect_to :action => 'not_found'
141   #   rescue ActiveResource::ResourceConflict, ActiveResource::ResourceInvalid
142   #     redirect_to :action => 'new'
143   #   end
144   #
145   # === Validation errors
146   #
147   # Active Resource supports validations on resources and will return errors if any these validations fail
148   # (e.g., "First name can not be blank" and so on).  These types of errors are denoted in the response by
149   # a response code of <tt>422</tt> and an XML representation of the validation errors.  The save operation will
150   # then fail (with a <tt>false</tt> return value) and the validation errors can be accessed on the resource in question.
151   #
152   #   ryan = Person.find(1)
153   #   ryan.first #=> ''
154   #   ryan.save  #=> false
155   #
156   #   # When
157   #   # PUT http://api.people.com:3000/people/1.xml
158   #   # is requested with invalid values, the response is:
159   #   #
160   #   # Response (422):
161   #   # <errors type="array"><error>First cannot be empty</error></errors>
162   #   #
163   #
164   #   ryan.errors.invalid?(:first)  #=> true
165   #   ryan.errors.full_messages  #=> ['First cannot be empty']
166   #
167   # Learn more about Active Resource's validation features in the ActiveResource::Validations documentation.
168   #
169   class Base
170     # The logger for diagnosing and tracing Active Resource calls.
171     cattr_accessor :logger
172
173     class << self
174       # Gets the URI of the REST resources to map for this class.  The site variable is required
175       # ActiveResource's mapping to work.
176       def site
177         # Not using superclass_delegating_reader because don't want subclasses to modify superclass instance
178         #
179         # With superclass_delegating_reader
180         #
181         #   Parent.site = 'http://anonymous@test.com'
182         #   Subclass.site # => 'http://anonymous@test.com'
183         #   Subclass.site.user = 'david'
184         #   Parent.site # => 'http://david@test.com'
185         #
186         # Without superclass_delegating_reader (expected behaviour)
187         #
188         #   Parent.site = 'http://anonymous@test.com'
189         #   Subclass.site # => 'http://anonymous@test.com'
190         #   Subclass.site.user = 'david' # => TypeError: can't modify frozen object
191         #
192         if defined?(@site)
193           @site
194         elsif superclass != Object && superclass.site
195           superclass.site.dup.freeze
196         end
197       end
198
199       # Sets the URI of the REST resources to map for this class to the value in the +site+ argument.
200       # The site variable is required ActiveResource's mapping to work.
201       def site=(site)
202         @connection = nil
203         if site.nil?
204           @site = nil
205         else
206           @site = create_site_uri_from(site)
207           @user = URI.decode(@site.user) if @site.user
208           @password = URI.decode(@site.password) if @site.password
209         end
210       end
211
212       # Gets the user for REST HTTP authentication
213       def user
214         # Not using superclass_delegating_reader. See +site+ for explanation
215         if defined?(@user)
216           @user
217         elsif superclass != Object && superclass.user
218           superclass.user.dup.freeze
219         end
220       end
221
222       # Sets the user for REST HTTP authentication
223       def user=(user)
224         @connection = nil
225         @user = user
226       end
227
228       # Gets the password for REST HTTP authentication
229       def password
230         # Not using superclass_delegating_reader. See +site+ for explanation
231         if defined?(@password)
232           @password
233         elsif superclass != Object && superclass.password
234           superclass.password.dup.freeze
235         end
236       end
237
238       # Sets the password for REST HTTP authentication
239       def password=(password)
240         @connection = nil
241         @password = password
242       end
243
244       # Sets the format that attributes are sent and received in from a mime type reference. Example:
245       #
246       #   Person.format = :json
247       #   Person.find(1) # => GET /people/1.json
248       #
249       #   Person.format = ActiveResource::Formats::XmlFormat
250       #   Person.find(1) # => GET /people/1.xml
251       #
252       # Default format is :xml.
253       def format=(mime_type_reference_or_format)
254         format = mime_type_reference_or_format.is_a?(Symbol) ?
255           ActiveResource::Formats[mime_type_reference_or_format] : mime_type_reference_or_format
256
257         write_inheritable_attribute("format", format)
258         connection.format = format if site
259       end
260      
261       # Returns the current format, default is ActiveResource::Formats::XmlFormat
262       def format # :nodoc:
263         read_inheritable_attribute("format") || ActiveResource::Formats[:xml]
264       end
265
266       # An instance of ActiveResource::Connection that is the base connection to the remote service.
267       # The +refresh+ parameter toggles whether or not the connection is refreshed at every request
268       # or not (defaults to <tt>false</tt>).
269       def connection(refresh = false)
270         if defined?(@connection) || superclass == Object
271           @connection = Connection.new(site, format) if refresh || @connection.nil?
272           @connection.user = user if user
273           @connection.password = password if password
274           @connection
275         else
276           superclass.connection
277         end
278       end
279
280       def headers
281         @headers ||= {}
282       end
283
284       # Do not include any modules in the default element name. This makes it easier to seclude ARes objects
285       # in a separate namespace without having to set element_name repeatedly.
286       attr_accessor_with_default(:element_name)    { to_s.split("::").last.underscore } #:nodoc:
287
288       attr_accessor_with_default(:collection_name) { element_name.pluralize } #:nodoc:
289       attr_accessor_with_default(:primary_key, 'id') #:nodoc:
290      
291       # Gets the prefix for a resource's nested URL (e.g., <tt>prefix/collectionname/1.xml</tt>)
292       # This method is regenerated at runtime based on what the prefix is set to.
293       def prefix(options={})
294         default = site.path
295         default << '/' unless default[-1..-1] == '/'
296         # generate the actual method based on the current site path
297         self.prefix = default
298         prefix(options)
299       end
300
301       # An attribute reader for the source string for the resource path prefix.  This
302       # method is regenerated at runtime based on what the prefix is set to.
303       def prefix_source
304         prefix # generate #prefix and #prefix_source methods first
305         prefix_source
306       end
307
308       # Sets the prefix for a resource's nested URL (e.g., <tt>prefix/collectionname/1.xml</tt>).
309       # Default value is <tt>site.path</tt>.
310       def prefix=(value = '/')
311         # Replace :placeholders with '#{embedded options[:lookups]}'
312         prefix_call = value.gsub(/:\w+/) { |key| "\#{options[#{key}]}" }
313
314         # Redefine the new methods.
315         code = <<-end_code
316           def prefix_source() "#{value}" end
317           def prefix(options={}) "#{prefix_call}" end
318         end_code
319         silence_warnings { instance_eval code, __FILE__, __LINE__ }
320       rescue
321         logger.error "Couldn't set prefix: #{$!}\n  #{code}"
322         raise
323       end
324
325       alias_method :set_prefix, :prefix=  #:nodoc:
326
327       alias_method :set_element_name, :element_name=  #:nodoc:
328       alias_method :set_collection_name, :collection_name=  #:nodoc:
329
330       # Gets the element path for the given ID in +id+.  If the +query_options+ parameter is omitted, Rails
331       # will split from the prefix options.
332       #
333       # ==== Options
334       # +prefix_options+:: A hash to add a prefix to the request for nested URL's (e.g., <tt>:account_id => 19</tt>
335       #                    would yield a URL like <tt>/accounts/19/purchases.xml</tt>).
336       # +query_options+:: A hash to add items to the query string for the request.
337       #
338       # ==== Examples
339       #   Post.element_path(1)
340       #   # => /posts/1.xml
341       #
342       #   Comment.element_path(1, :post_id => 5)
343       #   # => /posts/5/comments/1.xml
344       #
345       #   Comment.element_path(1, :post_id => 5, :active => 1)
346       #   # => /posts/5/comments/1.xml?active=1
347       #
348       #   Comment.element_path(1, {:post_id => 5}, {:active => 1})
349       #   # => /posts/5/comments/1.xml?active=1
350       #
351       def element_path(id, prefix_options = {}, query_options = nil)
352         prefix_options, query_options = split_options(prefix_options) if query_options.nil?
353         "#{prefix(prefix_options)}#{collection_name}/#{id}.#{format.extension}#{query_string(query_options)}"       
354       end
355
356       # Gets the collection path for the REST resources.  If the +query_options+ parameter is omitted, Rails
357       # will split from the +prefix_options+.
358       #
359       # ==== Options
360       # +prefix_options+:: A hash to add a prefix to the request for nested URL's (e.g., <tt>:account_id => 19</tt>
361       #                    would yield a URL like <tt>/accounts/19/purchases.xml</tt>).
362       # +query_options+:: A hash to add items to the query string for the request.
363       #
364       # ==== Examples
365       #   Post.collection_path
366       #   # => /posts.xml
367       #
368       #   Comment.collection_path(:post_id => 5)
369       #   # => /posts/5/comments.xml
370       #
371       #   Comment.collection_path(:post_id => 5, :active => 1)
372       #   # => /posts/5/comments.xml?active=1
373       #
374       #   Comment.collection_path({:post_id => 5}, {:active => 1})
375       #   # => /posts/5/comments.xml?active=1
376       #
377       def collection_path(prefix_options = {}, query_options = nil)
378         prefix_options, query_options = split_options(prefix_options) if query_options.nil?
379         "#{prefix(prefix_options)}#{collection_name}.#{format.extension}#{query_string(query_options)}"
380       end
381
382       alias_method :set_primary_key, :primary_key=  #:nodoc:
383
384       # Create a new resource instance and request to the remote service
385       # that it be saved, making it equivalent to the following simultaneous calls:
386       #
387       #   ryan = Person.new(:first => 'ryan')
388       #   ryan.save
389       #
390       # The newly created resource is returned.  If a failure has occurred an
391       # exception will be raised (see save).  If the resource is invalid and
392       # has not been saved then valid? will return <tt>false</tt>,
393       # while new? will still return <tt>true</tt>.
394       #
395       # ==== Examples
396       #   Person.create(:name => 'Jeremy', :email => 'myname@nospam.com', :enabled => true)
397       #   my_person = Person.find(:first)
398       #   my_person.email
399       #   # => myname@nospam.com
400       #
401       #   dhh = Person.create(:name => 'David', :email => 'dhh@nospam.com', :enabled => true)
402       #   dhh.valid?
403       #   # => true
404       #   dhh.new?
405       #   # => false
406       #
407       #   # We'll assume that there's a validation that requires the name attribute
408       #   that_guy = Person.create(:name => '', :email => 'thatguy@nospam.com', :enabled => true)
409       #   that_guy.valid?
410       #   # => false
411       #   that_guy.new?
412       #   # => true
413       #
414       def create(attributes = {})
415         returning(self.new(attributes)) { |res| res.save }       
416       end
417
418       # Core method for finding resources.  Used similarly to Active Record's find method.
419       #
420       # ==== Arguments
421       # The first argument is considered to be the scope of the query.  That is, how many
422       # resources are returned from the request.  It can be one of the following.
423       #
424       # +:one+:: Returns a single resource.
425       # +:first+:: Returns the first resource found.
426       # +:all+:: Returns every resource that matches the request.
427       #
428       # ==== Options
429       # +from+:: Sets the path or custom method that resources will be fetched from.
430       # +params+:: Sets query and prefix (nested URL) parameters.
431       #
432       # ==== Examples
433       #   Person.find(1)                                         
434       #   # => GET /people/1.xml
435       #
436       #   Person.find(:all)                                     
437       #   # => GET /people.xml
438       #
439       #   Person.find(:all, :params => { :title => "CEO" })     
440       #   # => GET /people.xml?title=CEO
441       #
442       #   Person.find(:first, :from => :managers)                 
443       #   # => GET /people/managers.xml
444       #
445       #   Person.find(:all, :from => "/companies/1/people.xml") 
446       #   # => GET /companies/1/people.xml
447       #
448       #   Person.find(:one, :from => :leader)                   
449       #   # => GET /people/leader.xml
450       #
451       #   Person.find(:all, :from => :developers, :params => { :language => 'ruby' })
452       #   # => GET /people/developers.xml?language=ruby
453       #
454       #   Person.find(:one, :from => "/companies/1/manager.xml")
455       #   # => GET /companies/1/manager.xml
456       #
457       #   StreetAddress.find(1, :params => { :person_id => 1 }) 
458       #   # => GET /people/1/street_addresses/1.xml
459       def find(*arguments)
460         scope   = arguments.slice!(0)
461         options = arguments.slice!(0) || {}
462
463         case scope
464           when :all   then find_every(options)
465           when :first then find_every(options).first
466           when :one   then find_one(options)
467           else             find_single(scope, options)
468         end
469       end
470
471       # Deletes the resources with the ID in the +id+ parameter.
472       #
473       # ==== Options
474       # All options specify prefix and query parameters.
475       #
476       # ==== Examples
477       #   Event.delete(2)
478       #   # => DELETE /events/2
479       #
480       #   Event.create(:name => 'Free Concert', :location => 'Community Center')
481       #   my_event = Event.find(:first)
482       #   # => Events (id: 7)
483       #   Event.delete(my_event.id)
484       #   # => DELETE /events/7
485       #
486       #   # Let's assume a request to events/5/cancel.xml
487       #   Event.delete(params[:id])
488       #   # => DELETE /events/5
489       #
490       def delete(id, options = {})
491         connection.delete(element_path(id, options))
492       end
493
494       # Asserts the existence of a resource, returning <tt>true</tt> if the resource is found.
495       #
496       # ==== Examples
497       #   Note.create(:title => 'Hello, world.', :body => 'Nothing more for now...')
498       #   Note.exists?(1)
499       #   # => true
500       #
501       #   Note.exists(1349)
502       #   # => false
503       def exists?(id, options = {})
504         if id
505           prefix_options, query_options = split_options(options[:params])
506           path = element_path(id, prefix_options, query_options)
507           response = connection.head(path, headers)
508           response.code == 200
509         end
510         # id && !find_single(id, options).nil?
511       rescue ActiveResource::ResourceNotFound
512         false
513       end
514
515       private
516         # Find every resource
517         def find_every(options)
518           case from = options[:from]
519           when Symbol
520             instantiate_collection(get(from, options[:params]))
521           when String
522             path = "#{from}#{query_string(options[:params])}"
523             instantiate_collection(connection.get(path, headers) || [])
524           else
525             prefix_options, query_options = split_options(options[:params])
526             path = collection_path(prefix_options, query_options)
527             instantiate_collection( (connection.get(path, headers) || []), prefix_options )
528           end
529         end
530        
531         # Find a single resource from a one-off URL
532         def find_one(options)
533           case from = options[:from]
534           when Symbol
535             instantiate_record(get(from, options[:params]))
536           when String
537             path = "#{from}#{query_string(options[:params])}"
538             instantiate_record(connection.get(path, headers))
539           end
540         end
541
542         # Find a single resource from the default URL
543         def find_single(scope, options)
544           prefix_options, query_options = split_options(options[:params])
545           path = element_path(scope, prefix_options, query_options)
546           instantiate_record(connection.get(path, headers), prefix_options)
547         end
548        
549         def instantiate_collection(collection, prefix_options = {})
550           collection.collect! { |record| instantiate_record(record, prefix_options) }
551         end
552
553         def instantiate_record(record, prefix_options = {})
554           returning new(record) do |resource|
555             resource.prefix_options = prefix_options
556           end
557         end
558
559
560         # Accepts a URI and creates the site URI from that.
561         def create_site_uri_from(site)
562           site.is_a?(URI) ? site.dup : URI.parse(site)
563         end
564
565         # contains a set of the current prefix parameters.
566         def prefix_parameters
567           @prefix_parameters ||= prefix_source.scan(/:\w+/).map { |key| key[1..-1].to_sym }.to_set
568         end
569
570         # Builds the query string for the request.
571         def query_string(options)
572           "?#{options.to_query}" unless options.nil? || options.empty?
573         end
574
575         # split an option hash into two hashes, one containing the prefix options,
576         # and the other containing the leftovers.
577         def split_options(options = {})
578           prefix_options, query_options = {}, {}
579
580           (options || {}).each do |key, value|
581             next if key.blank?
582             (prefix_parameters.include?(key.to_sym) ? prefix_options : query_options)[key.to_sym] = value
583           end
584
585           [ prefix_options, query_options ]
586         end
587     end
588
589     attr_accessor :attributes #:nodoc:
590     attr_accessor :prefix_options #:nodoc:
591
592     # Constructor method for new resources; the optional +attributes+ parameter takes a +Hash+
593     # of attributes for the new resource.
594     #
595     # ==== Examples
596     #   my_course = Course.new
597     #   my_course.name = "Western Civilization"
598     #   my_course.lecturer = "Don Trotter"
599     #   my_course.save
600     #
601     #   my_other_course = Course.new(:name => "Philosophy: Reason and Being", :lecturer => "Ralph Cling")
602     #   my_other_course.save
603     def initialize(attributes = {})
604       @attributes     = {}
605       @prefix_options = {}
606       load(attributes)
607     end
608
609     # Returns a clone of the resource that hasn't been assigned an id yet and
610     # is treated as a new resource.
611     #
612     #  ryan = Person.find(1)
613     #  not_ryan = ryan.clone
614     #  not_ryan.new?  # => true
615     #
616     # Any active resource member attributes will NOT be cloned, though all other
617     # attributes are.  This is to prevent the conflict between any prefix_options
618     # that refer to the original parent resource and the newly cloned parent
619     # resource that does not exist.
620     #
621     #  ryan = Person.find(1)
622     #  ryan.address = StreetAddress.find(1, :person_id => ryan.id)
623     #  ryan.hash = {:not => "an ARes instance"}
624     #
625     #  not_ryan = ryan.clone
626     #  not_ryan.new?            # => true
627     #  not_ryan.address         # => NoMethodError
628     #  not_ryan.hash            # => {:not => "an ARes instance"}
629     #
630     def clone
631       # Clone all attributes except the pk and any nested ARes
632       cloned = attributes.reject {|k,v| k == self.class.primary_key || v.is_a?(ActiveResource::Base)}.inject({}) do |attrs, (k, v)|
633         attrs[k] = v.clone
634         attrs
635       end
636       # Form the new resource - bypass initialize of resource with 'new' as that will call 'load' which
637       # attempts to convert hashes into member objects and arrays into collections of objects.  We want
638       # the raw objects to be cloned so we bypass load by directly setting the attributes hash.
639       resource = self.class.new({})
640       resource.prefix_options = self.prefix_options
641       resource.send :instance_variable_set, '@attributes', cloned
642       resource
643     end
644
645
646     # A method to determine if the resource a new object (i.e., it has not been POSTed to the remote service yet).
647     #
648     # ==== Examples
649     #   not_new = Computer.create(:brand => 'Apple', :make => 'MacBook', :vendor => 'MacMall')
650     #   not_new.new?
651     #   # => false
652     #
653     #   is_new = Computer.new(:brand => 'IBM', :make => 'Thinkpad', :vendor => 'IBM')
654     #   is_new.new?
655     #   # => true
656     #
657     #   is_new.save