morpheus 0.4.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) hide show
  1. data/.rspec +1 -1
  2. data/CHANGELOG.md +17 -0
  3. data/Gemfile +1 -1
  4. data/lib/morpheus.rb +3 -23
  5. data/lib/morpheus/base.rb +11 -11
  6. data/lib/morpheus/client.rb +10 -0
  7. data/lib/morpheus/client/associations.rb +6 -6
  8. data/lib/morpheus/client/cached_request_formatter.rb +20 -0
  9. data/lib/morpheus/client/log_subscriber.rb +2 -28
  10. data/lib/morpheus/client/railtie.rb +4 -4
  11. data/lib/morpheus/client/request_formatter.rb +50 -0
  12. data/lib/morpheus/client/uncached_request_formatter.rb +40 -0
  13. data/lib/morpheus/configuration.rb +30 -9
  14. data/lib/morpheus/mixins.rb +16 -0
  15. data/lib/morpheus/mixins/associations.rb +39 -37
  16. data/lib/morpheus/mixins/associations/association.rb +112 -0
  17. data/lib/morpheus/mixins/associations/belongs_to_association.rb +47 -0
  18. data/lib/morpheus/mixins/associations/has_many_association.rb +72 -0
  19. data/lib/morpheus/mixins/associations/has_one_association.rb +48 -0
  20. data/lib/morpheus/mixins/attributes.rb +97 -95
  21. data/lib/morpheus/mixins/conversion.rb +16 -14
  22. data/lib/morpheus/mixins/filtering.rb +13 -11
  23. data/lib/morpheus/mixins/finders.rb +47 -45
  24. data/lib/morpheus/mixins/introspection.rb +15 -13
  25. data/lib/morpheus/mixins/persistence.rb +32 -30
  26. data/lib/morpheus/mixins/reflections.rb +17 -15
  27. data/lib/morpheus/mixins/request_handling.rb +27 -25
  28. data/lib/morpheus/mixins/response_parsing.rb +10 -8
  29. data/lib/morpheus/mixins/url_support.rb +27 -25
  30. data/lib/morpheus/reflection.rb +5 -2
  31. data/lib/morpheus/type_caster.rb +1 -1
  32. data/lib/morpheus/version.rb +1 -1
  33. data/morpheus.gemspec +2 -2
  34. data/spec/dummy/app/resources/book.rb +1 -1
  35. data/spec/morpheus/base_spec.rb +35 -35
  36. data/spec/morpheus/client/cached_request_formatter_spec.rb +28 -0
  37. data/spec/morpheus/client/log_subscriber_spec.rb +53 -10
  38. data/spec/morpheus/client/request_formatter_spec.rb +5 -0
  39. data/spec/morpheus/client/uncached_request_formatter_spec.rb +29 -0
  40. data/spec/morpheus/configuration_spec.rb +49 -11
  41. data/spec/morpheus/{associations → mixins/associations}/association_spec.rb +1 -1
  42. data/spec/morpheus/{associations → mixins/associations}/belongs_to_association_spec.rb +1 -1
  43. data/spec/morpheus/{associations → mixins/associations}/has_many_association_spec.rb +1 -1
  44. data/spec/morpheus/{associations → mixins/associations}/has_one_association_spec.rb +1 -1
  45. data/spec/morpheus/mixins/associations_spec.rb +1 -1
  46. data/spec/morpheus/mixins/attributes_spec.rb +27 -6
  47. data/spec/morpheus/mixins/conversion_spec.rb +1 -1
  48. data/spec/morpheus/mixins/filtering_spec.rb +2 -2
  49. data/spec/morpheus/mixins/finders_spec.rb +1 -1
  50. data/spec/morpheus/mixins/introspection_spec.rb +1 -1
  51. data/spec/morpheus/mixins/persistence_spec.rb +1 -1
  52. data/spec/morpheus/mixins/reflections_spec.rb +1 -1
  53. data/spec/morpheus/mixins/request_handling_spec.rb +2 -2
  54. data/spec/morpheus/mixins/response_parsing_spec.rb +2 -2
  55. data/spec/morpheus/mixins/url_support_spec.rb +2 -2
  56. data/spec/morpheus/response_parser_spec.rb +5 -1
  57. data/spec/regressions/sorting_resources_spec.rb +119 -0
  58. data/spec/spec_helper.rb +3 -2
  59. metadata +159 -87
  60. data/lib/morpheus/associations/association.rb +0 -110
  61. data/lib/morpheus/associations/belongs_to_association.rb +0 -45
  62. data/lib/morpheus/associations/has_many_association.rb +0 -70
  63. data/lib/morpheus/associations/has_one_association.rb +0 -46
@@ -0,0 +1,112 @@
1
+ # The Association class serves as the basis for associating resources.
2
+ # Resources have an association DSL that follows that of ActiveRecord:
3
+ #
4
+ # has_many :automobiles
5
+ #
6
+ # has_one :car
7
+ #
8
+ # belongs_to :car_club
9
+ #
10
+ # This class acts as a proxy that will allow for lazy evaluation of an
11
+ # association. The proxy waits to make the request for the object until
12
+ # a method on the association is called. When this happens, because the
13
+ # method does not exist on the proxy object, method_missing will be
14
+ # called, the target object will be loaded, and then the method will be
15
+ # called on the loaded target.
16
+ module Morpheus
17
+ module Mixins
18
+ module Associations
19
+ class Association < ActiveSupport::BasicObject
20
+
21
+ # Associations can be loaded with several options.
22
+ def initialize(owner, association, settings = {})
23
+ # @owner stores the class that the association exists on.
24
+ @owner = owner
25
+
26
+ # @association stores the associated class, as named in the association
27
+ # method (i.e. :automobile, :car, :car_club)
28
+ @association = association
29
+
30
+ # @target stores the loaded object. It is not typically accessed directly,
31
+ # but instead should be accessed through the loaded_target method.
32
+ @target = settings[:target]
33
+
34
+ @filters = settings[:filters] || []
35
+
36
+ @includes = []
37
+
38
+ # @options holds the chosen options for the association. Several of these
39
+ # options are set in the subclass' initializer.
40
+ @options = settings[:options] || {}
41
+
42
+ # In some cases, the association name will not match that of the class
43
+ # that should be instantiated when it is invoked. Here, we can specify
44
+ # that this association uses a specified class as its target. When the
45
+ # request is made for the association, this class will be used to
46
+ # instantiate this object or collection.
47
+ @options[:class_name] = settings[:options][:class_name] || @association.to_s.classify
48
+ end
49
+
50
+ # The proxy implements a few methods that need to be delegated to the target
51
+ # so that they will work as expected.
52
+ def id
53
+ loaded_target.id
54
+ end
55
+
56
+ def nil?
57
+ loaded_target.nil?
58
+ end
59
+
60
+ def to_param
61
+ loaded_target.to_param
62
+ end
63
+
64
+ def try(method, *args, &block)
65
+ loaded_target.try(method, *args, &block)
66
+ end
67
+
68
+ def includes(*associations)
69
+ associations.each do |association|
70
+ @includes << association unless @includes.include?(association)
71
+ end
72
+ self
73
+ end
74
+
75
+ # This is left to be implemented by the subclasses as it will operate
76
+ # differently in each case.
77
+ def load_target!
78
+ end
79
+
80
+ private
81
+
82
+ # The loaded_target method holds a cached version of the loaded target.
83
+ # This method is used to access the proxied object. Since a request will
84
+ # be made when a method is invoked on the object, and this can happen
85
+ # very often, we are caching the target here, so that only a single
86
+ # request will be made.
87
+ def loaded_target
88
+ @target ||= load_target!
89
+ if ::Array === @target && !@filters.empty?
90
+ @filters.uniq.inject(@target.dup) do |target, filter|
91
+ filter.call(target)
92
+ end
93
+ else
94
+ @target
95
+ end
96
+ end
97
+
98
+ # The method_missing hook will be called when methods that do not exist
99
+ # on the proxy object are invoked. This is the point at which the proxied
100
+ # object is loaded, if it has not been loaded already.
101
+ def method_missing(m, *args, &block)
102
+ if filter = @association_class.find_filter(m)
103
+ with_filter(filter)
104
+ else
105
+ loaded_target.send(m, *args, &block)
106
+ end
107
+ end
108
+
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,47 @@
1
+ module Morpheus
2
+ module Mixins
3
+ module Associations
4
+ class BelongsToAssociation < Association
5
+
6
+ # The initializer calls out to the superclass' initializer, then will set the
7
+ # options particular to itself.
8
+ def initialize(owner, association, settings = {})
9
+ super
10
+
11
+ # The foreign key is used to generate the url for a request. The default uses
12
+ # the association name with an '_id' suffix as the generated key. For example,
13
+ # belongs_to :school, will have the foreign key :school_id.
14
+ @options[:foreign_key] ||= "#{@association.to_s}_id".to_sym
15
+
16
+ # The primary key defaults to :id.
17
+ @options[:primary_key] ||= :id
18
+
19
+ # Associations can be marked as polymorphic. These associations will use
20
+ # the returned type to instantiate the associated object.
21
+ @options[:polymorphic] = settings[:options][:polymorphic] || false
22
+
23
+ # @association_class stores the class of the association, constantized
24
+ # from the named association (i.e. Automobile, Car, CarClub)
25
+ @association_class = @options[:class_name].constantize
26
+ end
27
+
28
+ def with_filter(filter)
29
+ BelongsToAssociation.new(@owner, @association, :target => @target, :filters => @filters.dup.push(filter), :options => @options)
30
+ end
31
+
32
+ # When loading the target, the association will only be loaded if the foreign_key
33
+ # has been set. Additionally, the class used to find the record will be inferred
34
+ # by calling the method which is the name of the association with a '_type' suffix.
35
+ # Alternatively, the class name can be set by using the :class_name option.
36
+ def load_target!
37
+ if association_id = @owner.send(@options[:foreign_key])
38
+ polymorphic_class = @options[:polymorphic] ? @owner.send("#{@association}_type".to_sym).constantize : @options[:class_name].constantize
39
+ attributes = [UrlBuilder.belongs_to(polymorphic_class, association_id), nil, { :id => association_id }]
40
+ polymorphic_class.find(association_id)
41
+ end
42
+ end
43
+
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,72 @@
1
+ module Morpheus
2
+ module Mixins
3
+ module Associations
4
+ class HasManyAssociation < Association
5
+
6
+ # The initializer calls out to the superclass' initializer and then
7
+ # sets the options particular to itself.
8
+ def initialize(owner, association, settings = {})
9
+ super
10
+
11
+ # The foreign key is used to generate the url for the association
12
+ # request when the association is transformed into a relation.
13
+ # The default is to use the class of the owner object with an '_id'
14
+ # suffix.
15
+ @options[:foreign_key] ||= "#{@owner.class.to_s.underscore}_id".to_sym
16
+
17
+ # The primary key is used in the generated url for the target. It
18
+ # defaults to :id.
19
+ @options[:primary_key] ||= :id
20
+
21
+ # @association_class stores the class of the association, constantized
22
+ # from the named association (i.e. Automobile, Car, CarClub)
23
+ @association_class = @options[:class_name].constantize
24
+ end
25
+
26
+ def with_filter(filter)
27
+ HasManyAssociation.new(@owner, @association, :target => @target, :filters => @filters.dup.push(filter), :options => @options)
28
+ end
29
+
30
+ # When loading the target, the primary key is first checked. If the
31
+ # key is nil, then an empty array is returned. Otherwise, the target
32
+ # is requested at the generated url. For a has_many :meetings
33
+ # association on a class called Course, the generated url might look
34
+ # like this: /meetings?course_id=1, where the 1 is the primary key.
35
+ def load_target!
36
+ if primary_key = @owner.send(@options[:primary_key])
37
+ Relation.new(@association.to_s.classify.constantize).where(@options[:foreign_key] => primary_key).all
38
+ else
39
+ []
40
+ end
41
+ end
42
+
43
+ # The where, limit, and page methods delegate to a Relation object.
44
+ # The association generates a relation, and then calls the very
45
+ # same where, limit, or page method on that relation object.
46
+ def where(query_attributes)
47
+ transform_association_into_relation.where(query_attributes)
48
+ end
49
+
50
+ def limit(amount)
51
+ transform_association_into_relation.limit(amount)
52
+ end
53
+
54
+ def page(page_number)
55
+ transform_association_into_relation.page(page_number)
56
+ end
57
+
58
+ def transform_association_into_relation
59
+ Relation.new(@association.to_s.classify.constantize).where(@options[:foreign_key] => @owner.send(@options[:primary_key]))
60
+ end
61
+ private :transform_association_into_relation
62
+
63
+ # The append operator is used to append new resources to the association.
64
+ def <<(one_of_many)
65
+ @target ||= []
66
+ @target << one_of_many
67
+ end
68
+
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,48 @@
1
+ module Morpheus
2
+ module Mixins
3
+ module Associations
4
+ class HasOneAssociation < Association
5
+
6
+ # The initializer calls out to the superclass' initializer and then
7
+ # sets the options particular to itself.
8
+ def initialize(owner, association, settings = {})
9
+ super
10
+
11
+ # The foreign key is used to generate the url for the association
12
+ # request when the association is transformed into a relation.
13
+ # The default is to use the class of the owner object with an '_id'
14
+ # suffix.
15
+ @options[:foreign_key] ||= "#{@owner.class.to_s.underscore}_id".to_sym
16
+
17
+ # The primary key is used in the generated url for the target. It
18
+ # defaults to :id.
19
+ @options[:primary_key] ||= :id
20
+
21
+ # @association_class stores the class of the association, constantized
22
+ # from the named association (i.e. Automobile, Car, CarClub)
23
+ if @options[:class_name]
24
+ @association_class = @options[:class_name].constantize
25
+ else
26
+ @association_class = @association
27
+ end
28
+ end
29
+
30
+ def with_filter(filter)
31
+ HasOneAssociation.new(@owner, @association, :target => @target, :filters => @filters.dup.push(filter), :options => @options)
32
+ end
33
+
34
+ # When loading the target, the primary key is first checked. If the
35
+ # key is nil, then an nil is returned. Otherwise, the target
36
+ # is requested at the generated url. For a has_one :meeting
37
+ # association on a class called Course, the generated url might look
38
+ # like this: /meetings?course_id=1, where the 1 is the primary key.
39
+ def load_target!
40
+ if primary_key = @owner.send(@options[:primary_key])
41
+ Relation.new(@association_class.to_s.classify.constantize).where(@options[:foreign_key] => primary_key).limit(1).first
42
+ end
43
+ end
44
+
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,132 +1,134 @@
1
1
  module Morpheus
2
- module Attributes
2
+ module Mixins
3
+ module Attributes
3
4
 
4
- def self.included(base)
5
- base.extend(ClassMethods)
6
- end
7
-
8
- module ClassMethods
9
-
10
- def property(name, typecast_class = nil)
11
- typecast_attributes.store(name.to_sym, typecast_class)
12
- default_attributes.store(name.to_sym, nil)
13
-
14
- define_attribute_accessor(name)
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
15
7
  end
16
8
 
17
- def default_attributes
18
- @attributes ||= HashWithIndifferentAccess.new(superclass.respond_to?(:default_attributes) ? superclass.default_attributes : {})
19
- end
9
+ module ClassMethods
20
10
 
21
- def typecast_attributes
22
- @typecast_attributes ||= HashWithIndifferentAccess.new(superclass.respond_to?(:default_attributes) ? superclass.default_attributes : {})
23
- end
11
+ def property(name, typecast_class = nil)
12
+ typecast_attributes.store(name.to_sym, typecast_class)
13
+ default_attributes.store(name.to_sym, nil)
24
14
 
25
- def attribute_defined?(attribute)
26
- default_attributes.keys.include?(attribute.to_s)
27
- end
15
+ define_attribute_accessor(name)
16
+ end
28
17
 
29
- def define_attribute_accessor(method)
30
- method = method.to_s.delete('=')
31
- define_method("#{method}=") do |value|
32
- send("#{method}_will_change!")
33
- update_attribute(method, value)
18
+ def default_attributes
19
+ @attributes ||= HashWithIndifferentAccess.new(superclass.respond_to?(:default_attributes) ? superclass.default_attributes : {})
34
20
  end
35
21
 
36
- define_method(method) do
37
- attributes[method]
22
+ def typecast_attributes
23
+ @typecast_attributes ||= HashWithIndifferentAccess.new(superclass.respond_to?(:default_attributes) ? superclass.default_attributes : {})
38
24
  end
39
25
 
40
- define_attribute_methods [method]
41
- end
42
- private :define_attribute_accessor
26
+ def attribute_defined?(attribute)
27
+ default_attributes.keys.include?(attribute.to_s)
28
+ end
43
29
 
44
- end
30
+ def define_attribute_accessor(method)
31
+ method = method.to_s.delete('=')
32
+ define_method("#{method}=") do |value|
33
+ send("#{method}_will_change!")
34
+ update_attribute(method, value)
35
+ end
45
36
 
46
- def attributes
47
- @attributes ||= self.class.default_attributes.dup
48
- end
37
+ define_method(method) do
38
+ attributes[method]
39
+ end
49
40
 
50
- def read_attribute_for_validation(key)
51
- attributes[key]
52
- end
41
+ define_attribute_methods [method]
42
+ end
43
+ private :define_attribute_accessor
53
44
 
54
- def typecast(attribute, value)
55
- TypeCaster.cast(value, self.class.typecast_attributes[attribute.to_sym])
56
- end
45
+ end
57
46
 
58
- def attributes_without_basic_attributes
59
- attributes_to_reject = %w( id errors valid )
60
- self.class.reflections.keys.each do |key|
61
- attributes_to_reject.push(key.to_s)
47
+ def attributes
48
+ @attributes ||= self.class.default_attributes.dup
62
49
  end
63
- attributes.reject do |key, value|
64
- attributes_to_reject.include?(key)
50
+
51
+ def read_attribute_for_validation(key)
52
+ attributes[key]
65
53
  end
66
- end
67
54
 
68
- def update_attribute(attribute, value)
69
- reflection = self.class.reflect_on_association(attribute)
70
- if reflection
71
- update_reflection(reflection, attribute, value)
72
- else
73
- attributes[attribute.to_sym] = typecast(attribute, value)
55
+ def typecast(attribute, value)
56
+ TypeCaster.cast(value, self.class.typecast_attributes[attribute.to_sym])
57
+ end
58
+
59
+ def attributes_without_basic_attributes
60
+ attributes_to_reject = %w( id errors valid )
61
+ self.class.reflections.keys.each do |key|
62
+ attributes_to_reject.push(key.to_s)
63
+ end
64
+ attributes.reject do |key, value|
65
+ attributes_to_reject.include?(key)
66
+ end
74
67
  end
75
- end
76
68
 
77
- def update_reflection(reflection, attribute, value)
78
- return unless value
79
- if reflection.macro == :has_many # need to construct each member of array one-by-one
80
- association_object = send(attribute)
81
- value.each do |a_value|
82
- if a_value.instance_of? reflection.klass
83
- target = a_value
69
+ def update_attribute(attribute, value)
70
+ reflection = self.class.reflect_on_association(attribute)
71
+ if reflection
72
+ update_reflection(reflection, attribute, value)
73
+ else
74
+ attributes[attribute.to_sym] = typecast(attribute, value)
75
+ end
76
+ end
77
+
78
+ def update_reflection(reflection, attribute, value)
79
+ return unless value
80
+ if reflection.macro == :has_many # need to construct each member of array one-by-one
81
+ association_object = send(attribute)
82
+ value.each do |a_value|
83
+ if a_value.instance_of? reflection.klass
84
+ target = a_value
85
+ else
86
+ target = reflection.build_association(a_value)
87
+ end
88
+ association_object << target
89
+ end
90
+ elsif reflection.macro == :belongs_to
91
+ if value.instance_of? reflection.klass
92
+ target = value
84
93
  else
85
- target = reflection.build_association(a_value)
94
+ if reflection.options[:polymorphic]
95
+ polymorphic_class = send("#{reflection.name}_type".to_sym)
96
+ polymorphic_class = value['type'] if value.include?('type')
97
+ target = polymorphic_class.constantize.new(value)
98
+ else
99
+ target = reflection.build_association(value)
100
+ end
86
101
  end
87
- association_object << target
88
- end
89
- elsif reflection.macro == :belongs_to
90
- if value.instance_of? reflection.klass
91
- target = value
102
+ send("#{attribute}=", target)
92
103
  else
93
- if reflection.options[:polymorphic]
94
- polymorphic_class = send("#{reflection.name}_type".to_sym)
95
- polymorphic_class = value['type'] if value.include?('type')
96
- target = polymorphic_class.constantize.new(value)
104
+ if value.instance_of? reflection.klass
105
+ target = value
97
106
  else
98
107
  target = reflection.build_association(value)
99
108
  end
109
+ send("#{attribute}=", target)
100
110
  end
101
- send("#{attribute}=", target)
102
- else
103
- if value.instance_of? reflection.klass
104
- target = value
105
- else
106
- target = reflection.build_association(value)
107
- end
108
- send("#{attribute}=", target)
109
111
  end
110
- end
111
112
 
112
- def merge_attributes(new_attributes)
113
- new_attributes.each do |key, value|
114
- case key.to_sym
115
- when :errors
116
- value.each do |k, v|
117
- v.each do |message|
118
- errors.add(k, message)
113
+ def merge_attributes(new_attributes)
114
+ new_attributes.each do |key, value|
115
+ case key.to_sym
116
+ when :errors
117
+ value.each do |k, v|
118
+ v.each do |message|
119
+ errors.add(k, message)
120
+ end
119
121
  end
122
+ when :valid
123
+ @valid = value
124
+ else
125
+ update_attribute(key, value)
120
126
  end
121
- when :valid
122
- @valid = value
123
- else
124
- update_attribute(key, value)
125
127
  end
128
+ self
126
129
  end
127
- self
128
- end
129
- private :merge_attributes
130
+ private :merge_attributes
130
131
 
132
+ end
131
133
  end
132
134
  end