morpheus 0.4.0 → 0.5.0

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 (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