restly 0.0.1.alpha.16 → 0.0.1.alpha.18

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. data/README.md +2 -112
  2. data/lib/restly.rb +26 -1
  3. data/lib/restly/associations.rb +25 -12
  4. data/lib/restly/associations/base/conditionals.rb +2 -2
  5. data/lib/restly/base.rb +1 -0
  6. data/lib/restly/base/fields.rb +21 -17
  7. data/lib/restly/base/includes.rb +7 -2
  8. data/lib/restly/base/instance.rb +31 -12
  9. data/lib/restly/base/instance/actions.rb +2 -2
  10. data/lib/restly/base/instance/attributes.rb +46 -40
  11. data/lib/restly/base/instance/comparable.rb +13 -0
  12. data/lib/restly/base/instance/error_handling.rb +60 -0
  13. data/lib/restly/base/instance/persistence.rb +1 -1
  14. data/lib/restly/base/resource/specification/fields.rb +2 -2
  15. data/lib/restly/base/resource/specification/mass_assignment_security.rb +1 -1
  16. data/lib/restly/client.rb +20 -7
  17. data/lib/restly/collection.rb +48 -12
  18. data/lib/restly/collection/error_handling.rb +17 -0
  19. data/lib/restly/connection.rb +55 -11
  20. data/lib/restly/error.rb +5 -9
  21. data/lib/restly/middleware.rb +15 -2
  22. data/lib/restly/nested_attributes.rb +29 -23
  23. data/lib/restly/notifications.rb +65 -0
  24. data/lib/restly/version.rb +1 -1
  25. data/spec/restly/active_model_lint_spec.rb +11 -0
  26. data/spec/restly/associations/base/builders_spec.rb +8 -0
  27. data/spec/restly/associations/base/conditionals_spec.rb +8 -0
  28. data/spec/restly/associations/base/loaders_spec.rb +8 -0
  29. data/spec/restly/associations/base/modifiers_spec.rb +8 -0
  30. data/spec/restly/associations/base/stubs_spec.rb +8 -0
  31. data/spec/restly/associations/class_methods.rb +0 -0
  32. data/spec/restly/base/instance/comparable_spec.rb +8 -0
  33. metadata +22 -2
@@ -0,0 +1,13 @@
1
+ module Restly::Base::Instance::Comparable
2
+
3
+ def ==(object)
4
+ reload!
5
+ object.reload!
6
+
7
+ this_object = Marshal.dump attributes.except(:id, :created_at, :updated_at)
8
+ other_object = Marshal.dump object.attributes.except(:id, :created_at, :updated_at)
9
+
10
+ Marshal.dump(this_object) == Marshal.dump(other_object) && self.class == object.class
11
+ end
12
+
13
+ end
@@ -0,0 +1,60 @@
1
+ module Restly::Base::Instance::ErrorHandling
2
+ extend ActiveSupport::Concern
3
+
4
+ private
5
+
6
+ def response_has_errors?(response=self.response)
7
+ @response.status >= 400 ||
8
+ (parsed_response(response).is_a?(Hash) &&
9
+ (parsed_response(response)[:errors] || parsed_response(response)[:error]))
10
+ end
11
+
12
+ def set_errors_from_response(response = self.response)
13
+
14
+ response_errors = parsed_response(response)[:errors] || parsed_response(response)[:error]
15
+
16
+ case response_errors
17
+
18
+ when Hash
19
+ response_errors.each do |name, error|
20
+ case error
21
+
22
+ when Array
23
+ error.each { |e| self.errors.add(name.to_sym, e) }
24
+
25
+ when String
26
+ self.errors.add(name.to_sym, error)
27
+
28
+ end
29
+ end
30
+
31
+ when Array
32
+ response_errors.each do |error|
33
+ self.errors.add(:base, error)
34
+ end
35
+
36
+ when String
37
+ self.errors.add(:base, response_errors)
38
+
39
+ end
40
+
41
+ self.errors
42
+ end
43
+
44
+ def read_attribute_for_validation(attr)
45
+ send attr
46
+ end
47
+
48
+ module ClassMethods
49
+
50
+ def human_attribute_name(attr, options = {})
51
+ attr.to_s.humanize
52
+ end
53
+
54
+ def lookup_ancestors
55
+ [self]
56
+ end
57
+
58
+ end
59
+
60
+ end
@@ -25,7 +25,7 @@ module Restly::Base::Instance::Persistence
25
25
  end
26
26
 
27
27
  def reload!
28
- raise Restly::Error::MissingId, "Cannot reload #{resource_name}, either it hasn't been created or it is missing an ID." unless id
28
+ raise Restly::Error::MissingId, "Cannot reload #{resource_name}, either it hasn't been created or it is missing an ID." unless new_record?
29
29
  set_attributes_from_response connection.get(path_with_format, force: true)
30
30
  @loaded = true
31
31
  self
@@ -6,7 +6,7 @@ class Restly::Base::Resource::Specification::Fields < Restly::Proxies::Base
6
6
  @spec = spec
7
7
  @removed = Set.new
8
8
  @added = Set.new
9
- super Restly::Base::Fields::FieldSet.new
9
+ super Restly::Base::Fields::FieldSet.new(spec.model)
10
10
  end
11
11
 
12
12
  def -(field)
@@ -34,7 +34,7 @@ class Restly::Base::Resource::Specification::Fields < Restly::Proxies::Base
34
34
  def reload_specification!
35
35
  from_spec = spec[:attributes] || []
36
36
  fields = (from_spec - @removed.to_a) + @added.to_a
37
- __setobj__ Restly::Base::Fields::FieldSet.new(fields)
37
+ __setobj__ Restly::Base::Fields::FieldSet.new(spec.model, fields)
38
38
  end
39
39
 
40
40
  end
@@ -45,7 +45,7 @@ module Restly::Base::Resource::Specification::MassAssignmentSecurity
45
45
 
46
46
  def reload_specification!
47
47
  accepts = spec.actions.map { |action| action['accepts_parameters'] }.flatten if spec.actions.present?
48
- __setobj__ ActiveModel::MassAssignmentSecurity::BlackList.new accepts
48
+ __setobj__ ActiveModel::MassAssignmentSecurity::WhiteList.new accepts
49
49
  end
50
50
 
51
51
  end
@@ -1,17 +1,30 @@
1
1
  class Restly::Client < OAuth2::Client
2
2
 
3
3
  attr_accessor :id, :secret, :site
4
- attr_reader :format
4
+ attr_reader :format, :resource
5
5
 
6
6
  def initialize(*args, &block)
7
7
  opts = args.extract_options!
8
- self.id = args[0] || Restly::Configuration.oauth_options[:client_id]
9
- self.secret = args[1] || Restly::Configuration.oauth_options[:client_secret]
10
- self.site = opts.delete(:site) || Restly::Configuration.site
11
- self.options = Restly::Configuration.client_options.merge(opts)
12
- self.ssl = opts.delete(:ssl) || Restly::Configuration.ssl
13
- self.format = @format = opts.delete(:format) || Restly::Configuration.default_format
8
+ opts.merge!(raise_errors: false)
9
+
10
+ self.resource = opts.delete(:resource) if opts[:resource]
11
+ self.id = args[0] || Restly::Configuration.oauth_options[:client_id]
12
+ self.secret = args[1] || Restly::Configuration.oauth_options[:client_secret]
13
+ self.site = opts.delete(:site) || Restly::Configuration.site
14
+ self.options = Restly::Configuration.client_options.merge(opts)
15
+ self.ssl = opts.delete(:ssl) || Restly::Configuration.ssl
16
+ self.format = @format = opts.delete(:format) || Restly::Configuration.default_format
14
17
  self.options[:connection_build] ||= block
18
+
19
+ end
20
+
21
+ def resource=(resource)
22
+ raise InvalidObject, "Resource must be a descendant of Restly::Base" unless resource.ancestors.include?(Restly::Base)
23
+ @resource = resource
24
+ end
25
+
26
+ def resource_name
27
+ resource.name.parameterize
15
28
  end
16
29
 
17
30
  def ssl=(val)
@@ -1,21 +1,28 @@
1
1
  class Restly::Collection < Array
2
2
  extend ActiveSupport::Autoload
3
3
  autoload :Pagination
4
+ autoload :ErrorHandling
4
5
 
5
6
  include Restly::Base::Resource::Finders
6
7
  include Restly::Base::Resource::BatchActions
7
8
  include Restly::Base::GenericMethods
9
+ include ErrorHandling
8
10
 
9
11
  delegate :resource_name, :new, :client, to: :resource
10
12
 
11
- attr_reader :resource
13
+ attr_reader :resource, :response
12
14
 
13
15
  def initialize(resource, array=[], opts={})
14
- @resource = resource
15
- @response = opts[:response]
16
- @connection
17
- array = items_from_response if @response.is_a?(OAuth2::Response)
18
- super(array)
16
+ ActiveSupport::Notifications.instrument("load_collection.restly", model: resource.name) do
17
+ @errors = []
18
+ @resource = resource
19
+ @connection
20
+ if opts[:response]
21
+ set_response(opts[:response])
22
+ else
23
+ replace array
24
+ end
25
+ end
19
26
  end
20
27
 
21
28
  [:path, :connection, :params].each do |attr|
@@ -52,24 +59,41 @@ class Restly::Collection < Array
52
59
  super(instance)
53
60
  end
54
61
 
62
+ def replace(array)
63
+ array.each do |instance|
64
+ raise Restly::Error::InvalidObject, "Object is not an instance of #{resource}" unless accepts?(instance)
65
+ end
66
+ super
67
+ end
68
+
55
69
  def reload!
56
70
  replace collection_from_response(connection.get path)
57
71
  end
58
72
 
59
73
  private
60
74
 
75
+ def set_response(response)
76
+ raise Restly::Error::InvalidResponse unless response.is_a? OAuth2::Response
77
+ @response = response
78
+ if response.try(:body)
79
+ if response_has_errors?(response)
80
+ set_errors_from_response
81
+ else
82
+ set_items_from_response
83
+ end
84
+ end
85
+ end
86
+
61
87
  def serializable_hash(options = nil)
62
- self.collect do |i|
88
+ self.map do |i|
63
89
  i.serializable_hash(options)
64
90
  end
65
91
  end
66
92
 
67
- def items_from_response
68
- parsed = @response.parsed || {}
69
- parsed = parsed[resource_name.pluralize] if parsed.is_a?(Hash) && parsed[resource_name.pluralize]
70
- parsed.collect do |instance|
93
+ def set_items_from_response(response=self.response)
94
+ parsed_response(response).reduce(self) do |collection, instance|
71
95
  instance = instance[resource_name] if instance[resource_name]
72
- resource.new(instance, connection: connection)
96
+ collection << resource.new(instance, connection: connection, loaded: false)
73
97
  end
74
98
  end
75
99
 
@@ -77,4 +101,16 @@ class Restly::Collection < Array
77
101
  instance.class.name == resource.name
78
102
  end
79
103
 
104
+ def parsed_response(response=self.response)
105
+ return {} unless response
106
+ parsed = response.parsed || {}
107
+ if parsed.is_a?(Hash) && parsed[resource_name.pluralize]
108
+ parsed[resource_name.pluralize]
109
+ elsif parsed.is_a?(Hash)
110
+ parsed.with_indifferent_access
111
+ else
112
+ parsed
113
+ end
114
+ end
115
+
80
116
  end
@@ -0,0 +1,17 @@
1
+ module Restly::Collection::ErrorHandling
2
+ extend ActiveSupport::Concern
3
+
4
+ def response_has_errors?(response=self.response)
5
+ @response.status >= 400 ||
6
+ (parsed_response(response).is_a?(Hash) &&
7
+ (parsed_response(response)[:errors] || parsed_response(response)[:error]))
8
+ end
9
+
10
+ def set_errors_from_response(response = self.response)
11
+ if (error = parsed_response(response)[:errors] || parsed_response(response)[:error])
12
+ @errors << error
13
+ end
14
+ replace []
15
+ end
16
+
17
+ end
@@ -2,6 +2,8 @@ class Restly::Connection < OAuth2::AccessToken
2
2
 
3
3
  attr_accessor :cache, :cache_options
4
4
 
5
+ delegate :resource, :resource_name, to: :client
6
+
5
7
  # TODO: Refactor with subclasses that have their own tokenize methods.
6
8
  def self.tokenize(client, object)
7
9
 
@@ -63,37 +65,79 @@ class Restly::Connection < OAuth2::AccessToken
63
65
 
64
66
  def request(verb, path, opts={}, &block)
65
67
  if cache && !opts[:force]
66
- cached_request(verb, path, opts, &block)
68
+ request_log("Restly::CacheRequest", path, verb) do
69
+ cached_request(verb, path, opts, &block)
70
+ end
67
71
  else
68
- forced_request(verb, path, opts, &block)
72
+ request_log("Restly::Request", path, verb) do
73
+ forced_request(verb, path, opts, &block)
74
+ end
69
75
  end
70
76
  end
71
77
 
72
- private
78
+ def id_from_path(path)
79
+ capture = path.match /(?<id>[0-9])\.\w*$/
80
+ capture[:id] if capture
81
+ end
73
82
 
74
83
  def cached_request(verb, path, opts={}, &block)
75
- options_hash = { verb: verb, token: token, opts: opts, cache_opts: cache_options, block: block }
84
+ id = id_from_path(path)
85
+
86
+ cache_options = self.cache_options.dup
87
+ options_hash = { path: path, verb: verb, token: token, opts: opts, cache_opts: cache_options, block: block }
76
88
  options_packed = [Marshal.dump(options_hash)].pack('m')
77
89
  options_hex = Digest::MD5.hexdigest(options_packed)
78
- cache_key = [path.parameterize, options_hex].join('_')
90
+
91
+ # Keys
92
+ collection_expire_key = [resource_name, "*"].compact.join('_')
93
+ instance_expire_key = [id, resource_name, "*"].compact.join('_')
94
+ cache_key = [id, resource_name, options_hex].compact.join('_')
95
+
79
96
 
80
97
  # Force a cache miss for all methods except get
81
98
  cache_options[:force] = true unless [:get, :options].include?(verb)
82
99
 
83
100
  # Set the response
84
- response = Rails.cache.fetch cache_key, cache_options.symbolize_keys do
101
+ unless verb.to_s.upcase =~ /GET|OPTIONS/
85
102
 
86
- Rails.cache.delete_matched("#{path.parameterize}*") if ![:get, :options].include?(verb)
87
- opts.merge!({force: true})
88
- request(verb, path, opts, &block)
103
+ # Expire Collections
104
+ cache_log("Restly::CacheExpire", instance_expire_key, :yellow) do
105
+ Rails.cache.delete_matched(collection_expire_key)
106
+ end
89
107
 
108
+ # Expire Instances
109
+ cache_log("Restly::CacheExpire", instance_expire_key, :yellow) do
110
+ Rails.cache.delete_matched(instance_expire_key)
111
+ end
90
112
  end
91
113
 
92
- # Clear the cache if there is an error
93
- Rails.cache.delete(cache_key) and puts "deleted cache for: #{verb} #{path}" if response.error
114
+ response = Rails.cache.fetch cache_key, cache_options.symbolize_keys do
115
+ cache_log("Restly::CacheMiss", cache_key, :red) do
116
+ forced_request(verb, path, opts, &block)
117
+ end
118
+ end
119
+
120
+ cache_log("Restly::CacheExpire", cache_key, :yellow) { Rails.cache.delete(cache_key) } if response.error
121
+
122
+ raise Restly::Error::ConnectionError, "#{response.status}: #{status_string(response.status)}" if response.status >= 500
94
123
 
95
124
  # Return the response
96
125
  response
97
126
 
98
127
  end
128
+
129
+ def request_log(name, path, verb, color=:light_green, &block)
130
+ site = URI.parse(client.site)
131
+ formatted_path = ["#{site.scheme}://#{site.host}", path].join("/")
132
+ ActiveSupport::Notifications.instrument("request.restly", url: formatted_path, method: verb, name: name, color: color, &block)
133
+ end
134
+
135
+ def cache_log(name, key, color=:light_green, &block)
136
+ ActiveSupport::Notifications.instrument("cache.restly", key: key, name: name, color: color, &block)
137
+ end
138
+
139
+ def status_string(int)
140
+ Rack::Utils::HTTP_STATUS_CODES[int.to_i]
141
+ end
142
+
99
143
  end
@@ -1,26 +1,22 @@
1
1
  module Restly::Error
2
2
 
3
- class StandardError < ::StandardError
4
-
5
- def message
6
- defined?(IRB) ? super.red : super
7
- end
8
-
9
- end
10
-
11
3
  errors = %w{
12
4
  RecordNotFound
13
5
  InvalidClient
14
6
  InvalidObject
15
7
  InvalidConnection
8
+ ConnectionError
16
9
  MissingId
17
10
  InvalidSpec
18
11
  InvalidField
12
+ InvalidAssociation
13
+ InvalidAttribute
14
+ InvalidNestedAttribute
19
15
  AssociationError
20
16
  }
21
17
 
22
18
  errors.each do |error|
23
- const_set(error.to_sym, Class.new(StandardError))
19
+ const_set error.to_sym, Class.new(StandardError)
24
20
  end
25
21
 
26
22
  end
@@ -8,8 +8,21 @@ class Restly::Middleware
8
8
 
9
9
  def call(env)
10
10
  @env = env
11
- Restly::Base.current_token = Restly::Connection.tokenize(Restly::Base.client, self).to_hash
12
- self.app.call(self.env)
11
+
12
+ Restly::Base.current_token = nil
13
+
14
+ token = Restly::Connection.tokenize(Restly::Base.client, self).to_hash
15
+
16
+ if token[:access_token].present? && !@env['PATH_INFO'].match(/^\/assets\//)
17
+ Restly::Base.current_token = token
18
+ end
19
+
20
+ self.app.call(env)
21
+
22
+ ensure
23
+
24
+ Restly::Base.current_token = nil
25
+
13
26
  end
14
27
 
15
28
  end
@@ -15,19 +15,21 @@ module Restly::NestedAttributes
15
15
  private
16
16
 
17
17
  # One To One Association
18
- def assign_nested_attributes_for_one_to_one_resource_association(association_name, attributes, assignment_opts = {})
19
- options = self.nested_attributes_options[association_name]
18
+ def assign_nested_attributes_for_one_to_one_resource_association( association_name, attributes )
19
+
20
20
  association_attributes[association_name] = attributes.delete("#{association_name}_attributes") || {}
21
21
  associated_instance = send(association_name) ||
22
22
  self.class.reflect_on_resource_association(association_name).build(self)
23
23
  associated_instance.attributes = association_attributes
24
+
24
25
  end
25
26
 
26
27
  # Collection Association
27
- def assign_nested_attributes_for_collection_resource_association(association_name, attributes_collection, assignment_opts = {})
28
+ def assign_nested_attributes_for_collection_resource_association(association_name, attributes_collection)
28
29
 
29
30
  unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array)
30
- raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
31
+ raise ArgumentError,
32
+ "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
31
33
  end
32
34
 
33
35
  if attributes_collection.is_a? Hash
@@ -46,7 +48,7 @@ module Restly::NestedAttributes
46
48
  attributes = attributes.with_indifferent_access
47
49
  if attributes[:id].blank?
48
50
  send(association_name) << association.build(self, attributes.except(:id))
49
- elsif existing_record = existing_records.find{ |record| record.id.to_s == attributes['id'].to_s }
51
+ elsif (existing_record = existing_records.find{ |record| record.id.to_s == attributes['id'].to_s })
50
52
  existing_record.attributes = attributes
51
53
  end
52
54
  end
@@ -54,13 +56,28 @@ module Restly::NestedAttributes
54
56
  end
55
57
 
56
58
  def set_nested_attributes_for_save
57
- @attributes = @attributes.inject(HashWithIndifferentAccess.new) do |hash, (k, v)|
58
- k = [resource_nested_attributes_options[k.to_sym][:write_prefix], k, resource_nested_attributes_options[k.to_sym][:write_suffix]].compact.join('_') if resource_nested_attributes_options[k.to_sym].present?
59
- hash[k] = v
59
+ @attributes = @attributes.reduce(HashWithIndifferentAccess.new) do |hash, (key, v)|
60
+ options = resource_nested_attributes_options[key.to_sym]
61
+ key = [ options[:write_prefix], key, options[:write_suffix] ].compact.join('_') if options.present?
62
+ hash[key] = v
60
63
  hash
61
64
  end
62
65
  end
63
66
 
67
+ def nested_attribute_missing(m, *args)
68
+ if !!(/(?<attr>\w+)_attributes=$/ =~ m.to_s) && (options = resource_nested_attributes_options[attr])
69
+ send( "assign_nested_attributes_for_#{options[:association_type]}_resource_association", attr, *args )
70
+ else
71
+ raise Restly::Error::InvalidNestedAttribute, "Nested Attribute does not exist!"
72
+ end
73
+ end
74
+
75
+ def method_missing(m, *args, &block)
76
+ nested_attribute_missing(m, *args)
77
+ rescue Restly::Error::InvalidNestedAttribute
78
+ super
79
+ end
80
+
64
81
  module ClassMethods
65
82
  REJECT_ALL_BLANK_PROC = proc { |attributes| attributes.all? { |key, value| key == '_destroy' || value.blank? } }
66
83
 
@@ -73,22 +90,11 @@ module Restly::NestedAttributes
73
90
  before_save :set_nested_attributes_for_save
74
91
 
75
92
  attr_names.each do |association_name|
76
- if reflection = reflect_on_resource_association(association_name)
77
- reflection.options[:autosave] = true
78
-
79
- resource_nested_attributes_options = self.resource_nested_attributes_options.dup
80
- resource_nested_attributes_options[association_name.to_sym] = options
81
- self.resource_nested_attributes_options = resource_nested_attributes_options
82
93
 
83
- type = (reflection.collection? ? :collection : :one_to_one)
84
-
85
- if method_defined?("#{association_name}_attributes=")
86
- remove_method("#{association_name}_attributes=")
87
- end
88
-
89
- define_method "#{association_name}_attributes=" do |attributes|
90
- send("assign_nested_attributes_for_#{type}_resource_association", association_name.to_sym, attributes, mass_assignment_options)
91
- end
94
+ if ( reflection = reflect_on_resource_association(association_name) )
95
+ reflection.options[:autosave] = true
96
+ options[:association_type] = (reflection.collection? ? :collection : :one_to_one)
97
+ self.resource_nested_attributes_options[association_name.to_sym] = options
92
98
 
93
99
  else
94
100
  raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?"