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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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?"